feat: restore live preview and build the project editor workflow
Recover the verified cloud preview checkpoint and salvage the interrupted icon and navigation edits. Add native project/model pickers, safe unsaved-change handling, body/equipment navigation and configuration, Open With integration, and a two-pass color still renderer. Record all requested production-render stages in the design without claiming unimplemented maps.
@@ -1,3 +1,4 @@
1
1
*.glb binary
2
2
*.fbx binary
3
3
*.png binary
4
*.ico binary
@@ -1,3 +1,4 @@1
*.glb binary1
*.glb binary2
*.fbx binary2
*.fbx binary3
*.png binary3
*.png binary4
*.ico binary@@ -109,6 +109,181 @@ dependencies = [
109
109
"libloading",
110
110
]
111
111
112
[[package]]
113
name = "ashpd"
114
version = "0.11.1"
115
source = "registry+https://github.com/rust-lang/crates.io-index"
116
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
117
dependencies = [
118
"async-fs",
119
"async-net",
120
"enumflags2",
121
"futures-channel",
122
"futures-util",
123
"rand",
124
"raw-window-handle",
125
"serde",
126
"serde_repr",
127
"url",
128
"wayland-backend",
129
"wayland-client",
130
"wayland-protocols",
131
"zbus",
132
]
133
134
[[package]]
135
name = "async-broadcast"
136
version = "0.7.2"
137
source = "registry+https://github.com/rust-lang/crates.io-index"
138
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
139
dependencies = [
140
"event-listener",
141
"event-listener-strategy",
142
"futures-core",
143
"pin-project-lite",
144
]
145
146
[[package]]
147
name = "async-channel"
148
version = "2.5.0"
149
source = "registry+https://github.com/rust-lang/crates.io-index"
150
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
151
dependencies = [
152
"concurrent-queue",
153
"event-listener-strategy",
154
"futures-core",
155
"pin-project-lite",
156
]
157
158
[[package]]
159
name = "async-executor"
160
version = "1.14.1"
161
source = "registry+https://github.com/rust-lang/crates.io-index"
162
checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37"
163
dependencies = [
164
"async-task",
165
"concurrent-queue",
166
"fastrand",
167
"futures-lite",
168
"pin-project-lite",
169
"slab",
170
]
171
172
[[package]]
173
name = "async-fs"
174
version = "2.2.0"
175
source = "registry+https://github.com/rust-lang/crates.io-index"
176
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
177
dependencies = [
178
"async-lock",
179
"blocking",
180
"futures-lite",
181
]
182
183
[[package]]
184
name = "async-io"
185
version = "2.6.0"
186
source = "registry+https://github.com/rust-lang/crates.io-index"
187
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
188
dependencies = [
189
"autocfg",
190
"cfg-if",
191
"concurrent-queue",
192
"futures-io",
193
"futures-lite",
194
"parking",
195
"polling",
196
"rustix 1.1.6",
197
"slab",
198
"windows-sys 0.61.2",
199
]
200
201
[[package]]
202
name = "async-lock"
203
version = "3.5.0"
204
source = "registry+https://github.com/rust-lang/crates.io-index"
205
checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f"
206
dependencies = [
207
"event-listener",
208
"event-listener-strategy",
209
"pin-project-lite",
210
]
211
212
[[package]]
213
name = "async-net"
214
version = "2.0.0"
215
source = "registry+https://github.com/rust-lang/crates.io-index"
216
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
217
dependencies = [
218
"async-io",
219
"blocking",
220
"futures-lite",
221
]
222
223
[[package]]
224
name = "async-process"
225
version = "2.5.0"
226
source = "registry+https://github.com/rust-lang/crates.io-index"
227
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
228
dependencies = [
229
"async-channel",
230
"async-io",
231
"async-lock",
232
"async-signal",
233
"async-task",
234
"blocking",
235
"cfg-if",
236
"event-listener",
237
"futures-lite",
238
"rustix 1.1.6",
239
]
240
241
[[package]]
242
name = "async-recursion"
243
version = "1.2.0"
244
source = "registry+https://github.com/rust-lang/crates.io-index"
245
checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
246
dependencies = [
247
"proc-macro2",
248
"quote",
249
"syn 3.0.9",
250
]
251
252
[[package]]
253
name = "async-signal"
254
version = "0.2.14"
255
source = "registry+https://github.com/rust-lang/crates.io-index"
256
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
257
dependencies = [
258
"async-io",
259
"async-lock",
260
"atomic-waker",
261
"cfg-if",
262
"futures-core",
263
"futures-io",
264
"rustix 1.1.6",
265
"signal-hook-registry",
266
"slab",
267
"windows-sys 0.61.2",
268
]
269
270
[[package]]
271
name = "async-task"
272
version = "4.7.1"
273
source = "registry+https://github.com/rust-lang/crates.io-index"
274
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
275
276
[[package]]
277
name = "async-trait"
278
version = "0.1.92"
279
source = "registry+https://github.com/rust-lang/crates.io-index"
280
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
281
dependencies = [
282
"proc-macro2",
283
"quote",
284
"syn 3.0.9",
285
]
286
112
287
[[package]]
113
288
name = "atomic-waker"
114
289
version = "1.1.2"
@@ -169,6 +344,28 @@ dependencies = [
169
344
"objc2 0.5.3",
170
345
]
171
346
347
[[package]]
348
name = "block2"
349
version = "0.6.2"
350
source = "registry+https://github.com/rust-lang/crates.io-index"
351
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
352
dependencies = [
353
"objc2 0.6.5",
354
]
355
356
[[package]]
357
name = "blocking"
358
version = "1.7.0"
359
source = "registry+https://github.com/rust-lang/crates.io-index"
360
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
361
dependencies = [
362
"async-channel",
363
"async-task",
364
"futures-io",
365
"futures-lite",
366
"piper",
367
]
368
172
369
[[package]]
173
370
name = "bumpalo"
174
371
version = "3.20.3"
@@ -445,6 +642,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
445
642
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
446
643
dependencies = [
447
644
"bitflags 2.13.2",
645
"block2 0.6.2",
646
"libc",
448
647
"objc2 0.6.5",
449
648
]
450
649
@@ -609,6 +808,33 @@ dependencies = [
609
808
"bytemuck",
610
809
]
611
810
811
[[package]]
812
name = "endi"
813
version = "1.1.1"
814
source = "registry+https://github.com/rust-lang/crates.io-index"
815
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
816
817
[[package]]
818
name = "enumflags2"
819
version = "0.7.12"
820
source = "registry+https://github.com/rust-lang/crates.io-index"
821
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
822
dependencies = [
823
"enumflags2_derive",
824
"serde",
825
]
826
827
[[package]]
828
name = "enumflags2_derive"
829
version = "0.7.12"
830
source = "registry+https://github.com/rust-lang/crates.io-index"
831
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
832
dependencies = [
833
"proc-macro2",
834
"quote",
835
"syn 2.0.119",
836
]
837
612
838
[[package]]
613
839
name = "epaint"
614
840
version = "0.29.1"
@@ -654,6 +880,32 @@ version = "3.4.0"
654
880
source = "registry+https://github.com/rust-lang/crates.io-index"
655
881
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
656
882
883
[[package]]
884
name = "event-listener"
885
version = "5.4.2"
886
source = "registry+https://github.com/rust-lang/crates.io-index"
887
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
888
dependencies = [
889
"parking",
890
"pin-project-lite",
891
]
892
893
[[package]]
894
name = "event-listener-strategy"
895
version = "0.5.4"
896
source = "registry+https://github.com/rust-lang/crates.io-index"
897
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
898
dependencies = [
899
"event-listener",
900
"pin-project-lite",
901
]
902
903
[[package]]
904
name = "fastrand"
905
version = "2.5.0"
906
source = "registry+https://github.com/rust-lang/crates.io-index"
907
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
908
657
909
[[package]]
658
910
name = "fdeflate"
659
911
version = "0.3.7"
@@ -722,12 +974,51 @@ dependencies = [
722
974
"percent-encoding",
723
975
]
724
976
977
[[package]]
978
name = "futures-channel"
979
version = "0.3.34"
980
source = "registry+https://github.com/rust-lang/crates.io-index"
981
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
982
dependencies = [
983
"futures-core",
984
]
985
725
986
[[package]]
726
987
name = "futures-core"
727
988
version = "0.3.34"
728
989
source = "registry+https://github.com/rust-lang/crates.io-index"
729
990
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
730
991
992
[[package]]
993
name = "futures-io"
994
version = "0.3.34"
995
source = "registry+https://github.com/rust-lang/crates.io-index"
996
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
997
998
[[package]]
999
name = "futures-lite"
1000
version = "2.6.1"
1001
source = "registry+https://github.com/rust-lang/crates.io-index"
1002
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
1003
dependencies = [
1004
"fastrand",
1005
"futures-core",
1006
"futures-io",
1007
"parking",
1008
"pin-project-lite",
1009
]
1010
1011
[[package]]
1012
name = "futures-macro"
1013
version = "0.3.34"
1014
source = "registry+https://github.com/rust-lang/crates.io-index"
1015
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1016
dependencies = [
1017
"proc-macro2",
1018
"quote",
1019
"syn 3.0.9",
1020
]
1021
731
1022
[[package]]
732
1023
name = "futures-task"
733
1024
version = "0.3.34"
@@ -741,7 +1032,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
741
1032
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
742
1033
dependencies = [
743
1034
"futures-core",
1035
"futures-io",
1036
"futures-macro",
744
1037
"futures-task",
1038
"memchr",
745
1039
"pin-project-lite",
746
1040
"slab",
747
1041
]
@@ -1007,6 +1301,12 @@ version = "0.5.3"
1007
1301
source = "registry+https://github.com/rust-lang/crates.io-index"
1008
1302
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1009
1303
1304
[[package]]
1305
name = "hex"
1306
version = "0.4.3"
1307
source = "registry+https://github.com/rust-lang/crates.io-index"
1308
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1309
1010
1310
[[package]]
1011
1311
name = "hexf-parse"
1012
1312
version = "0.2.1"
@@ -1539,7 +1839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1539
1839
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
1540
1840
dependencies = [
1541
1841
"bitflags 2.13.2",
1542
"block2",
1842
"block2 0.5.1",
1543
1843
"libc",
1544
1844
"objc2 0.5.3",
1545
1845
"objc2-core-data",
@@ -1555,6 +1855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1555
1855
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
1556
1856
dependencies = [
1557
1857
"bitflags 2.13.2",
1858
"block2 0.6.2",
1558
1859
"objc2 0.6.5",
1559
1860
"objc2-core-foundation",
1560
1861
"objc2-core-graphics",
@@ -1568,7 +1869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1568
1869
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
1569
1870
dependencies = [
1570
1871
"bitflags 2.13.2",
1571
"block2",
1872
"block2 0.5.1",
1572
1873
"objc2 0.5.3",
1573
1874
"objc2-core-location",
1574
1875
"objc2-foundation 0.2.2",
@@ -1580,7 +1881,7 @@ version = "0.2.2"
1580
1881
source = "registry+https://github.com/rust-lang/crates.io-index"
1581
1882
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
1582
1883
dependencies = [
1583
"block2",
1884
"block2 0.5.1",
1584
1885
"objc2 0.5.3",
1585
1886
"objc2-foundation 0.2.2",
1586
1887
]
@@ -1592,7 +1893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1592
1893
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
1593
1894
dependencies = [
1594
1895
"bitflags 2.13.2",
1595
"block2",
1896
"block2 0.5.1",
1596
1897
"objc2 0.5.3",
1597
1898
"objc2-foundation 0.2.2",
1598
1899
]
@@ -1627,7 +1928,7 @@ version = "0.2.2"
1627
1928
source = "registry+https://github.com/rust-lang/crates.io-index"
1628
1929
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
1629
1930
dependencies = [
1630
"block2",
1931
"block2 0.5.1",
1631
1932
"objc2 0.5.3",
1632
1933
"objc2-foundation 0.2.2",
1633
1934
"objc2-metal",
@@ -1639,7 +1940,7 @@ version = "0.2.2"
1639
1940
source = "registry+https://github.com/rust-lang/crates.io-index"
1640
1941
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
1641
1942
dependencies = [
1642
"block2",
1943
"block2 0.5.1",
1643
1944
"objc2 0.5.3",
1644
1945
"objc2-contacts",
1645
1946
"objc2-foundation 0.2.2",
@@ -1658,7 +1959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1658
1959
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
1659
1960
dependencies = [
1660
1961
"bitflags 2.13.2",
1661
"block2",
1962
"block2 0.5.1",
1662
1963
"dispatch",
1663
1964
"libc",
1664
1965
"objc2 0.5.3",
@@ -1692,7 +1993,7 @@ version = "0.2.2"
1692
1993
source = "registry+https://github.com/rust-lang/crates.io-index"
1693
1994
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
1694
1995
dependencies = [
1695
"block2",
1996
"block2 0.5.1",
1696
1997
"objc2 0.5.3",
1697
1998
"objc2-app-kit 0.2.2",
1698
1999
"objc2-foundation 0.2.2",
@@ -1705,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1705
2006
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
1706
2007
dependencies = [
1707
2008
"bitflags 2.13.2",
1708
"block2",
2009
"block2 0.5.1",
1709
2010
"objc2 0.5.3",
1710
2011
"objc2-foundation 0.2.2",
1711
2012
]
@@ -1717,7 +2018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1717
2018
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
1718
2019
dependencies = [
1719
2020
"bitflags 2.13.2",
1720
"block2",
2021
"block2 0.5.1",
1721
2022
"objc2 0.5.3",
1722
2023
"objc2-foundation 0.2.2",
1723
2024
"objc2-metal",
@@ -1740,7 +2041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1740
2041
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
1741
2042
dependencies = [
1742
2043
"bitflags 2.13.2",
1743
"block2",
2044
"block2 0.5.1",
1744
2045
"objc2 0.5.3",
1745
2046
"objc2-cloud-kit",
1746
2047
"objc2-core-data",
@@ -1760,7 +2061,7 @@ version = "0.2.2"
1760
2061
source = "registry+https://github.com/rust-lang/crates.io-index"
1761
2062
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
1762
2063
dependencies = [
1763
"block2",
2064
"block2 0.5.1",
1764
2065
"objc2 0.5.3",
1765
2066
"objc2-foundation 0.2.2",
1766
2067
]
@@ -1772,7 +2073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1772
2073
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
1773
2074
dependencies = [
1774
2075
"bitflags 2.13.2",
1775
"block2",
2076
"block2 0.5.1",
1776
2077
"objc2 0.5.3",
1777
2078
"objc2-core-location",
1778
2079
"objc2-foundation 0.2.2",
@@ -1794,6 +2095,16 @@ dependencies = [
1794
2095
"libredox",
1795
2096
]
1796
2097
2098
[[package]]
2099
name = "ordered-stream"
2100
version = "0.2.0"
2101
source = "registry+https://github.com/rust-lang/crates.io-index"
2102
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
2103
dependencies = [
2104
"futures-core",
2105
"pin-project-lite",
2106
]
2107
1797
2108
[[package]]
1798
2109
name = "owned_ttf_parser"
1799
2110
version = "0.25.1"
@@ -1812,12 +2123,22 @@ dependencies = [
1812
2123
"glow 0.14.2",
1813
2124
"gltf",
1814
2125
"libloading",
2126
"png",
2127
"rfd",
1815
2128
"serde",
1816
2129
"serde_json",
1817
2130
"tobj",
1818
2131
"ufbx",
2132
"winreg",
2133
"winresource",
1819
2134
]
1820
2135
2136
[[package]]
2137
name = "parking"
2138
version = "2.2.1"
2139
source = "registry+https://github.com/rust-lang/crates.io-index"
2140
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2141
1821
2142
[[package]]
1822
2143
name = "parking_lot"
1823
2144
version = "0.12.5"
@@ -1879,6 +2200,17 @@ version = "0.2.17"
1879
2200
source = "registry+https://github.com/rust-lang/crates.io-index"
1880
2201
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1881
2202
2203
[[package]]
2204
name = "piper"
2205
version = "0.2.5"
2206
source = "registry+https://github.com/rust-lang/crates.io-index"
2207
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
2208
dependencies = [
2209
"atomic-waker",
2210
"fastrand",
2211
"futures-io",
2212
]
2213
1882
2214
[[package]]
1883
2215
name = "pkg-config"
1884
2216
version = "0.3.34"
@@ -1918,6 +2250,12 @@ dependencies = [
1918
2250
"windows-sys 0.61.2",
1919
2251
]
1920
2252
2253
[[package]]
2254
name = "pollster"
2255
version = "0.4.0"
2256
source = "registry+https://github.com/rust-lang/crates.io-index"
2257
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
2258
1921
2259
[[package]]
1922
2260
name = "potential_utf"
1923
2261
version = "0.1.6"
@@ -1927,6 +2265,15 @@ dependencies = [
1927
2265
"zerovec",
1928
2266
]
1929
2267
2268
[[package]]
2269
name = "ppv-lite86"
2270
version = "0.2.21"
2271
source = "registry+https://github.com/rust-lang/crates.io-index"
2272
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2273
dependencies = [
2274
"zerocopy",
2275
]
2276
1930
2277
[[package]]
1931
2278
name = "presser"
1932
2279
version = "0.3.1"
@@ -1993,6 +2340,35 @@ version = "6.0.0"
1993
2340
source = "registry+https://github.com/rust-lang/crates.io-index"
1994
2341
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1995
2342
2343
[[package]]
2344
name = "rand"
2345
version = "0.9.5"
2346
source = "registry+https://github.com/rust-lang/crates.io-index"
2347
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2348
dependencies = [
2349
"rand_chacha",
2350
"rand_core",
2351
]
2352
2353
[[package]]
2354
name = "rand_chacha"
2355
version = "0.9.0"
2356
source = "registry+https://github.com/rust-lang/crates.io-index"
2357
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2358
dependencies = [
2359
"ppv-lite86",
2360
"rand_core",
2361
]
2362
2363
[[package]]
2364
name = "rand_core"
2365
version = "0.9.5"
2366
source = "registry+https://github.com/rust-lang/crates.io-index"
2367
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2368
dependencies = [
2369
"getrandom 0.3.4",
2370
]
2371
1996
2372
[[package]]
1997
2373
name = "raw-window-handle"
1998
2374
version = "0.6.2"
@@ -2032,6 +2408,30 @@ version = "1.1.0"
2032
2408
source = "registry+https://github.com/rust-lang/crates.io-index"
2033
2409
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2034
2410
2411
[[package]]
2412
name = "rfd"
2413
version = "0.15.4"
2414
source = "registry+https://github.com/rust-lang/crates.io-index"
2415
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
2416
dependencies = [
2417
"ashpd",
2418
"block2 0.6.2",
2419
"dispatch2",
2420
"js-sys",
2421
"log",
2422
"objc2 0.6.5",
2423
"objc2-app-kit 0.3.2",
2424
"objc2-core-foundation",
2425
"objc2-foundation 0.3.2",
2426
"pollster",
2427
"raw-window-handle",
2428
"urlencoding",
2429
"wasm-bindgen",
2430
"wasm-bindgen-futures",
2431
"web-sys",
2432
"windows-sys 0.59.0",
2433
]
2434
2035
2435
[[package]]
2036
2436
name = "rustc-hash"
2037
2437
version = "1.1.0"
@@ -2155,12 +2555,42 @@ dependencies = [
2155
2555
"zmij",
2156
2556
]
2157
2557
2558
[[package]]
2559
name = "serde_repr"
2560
version = "0.1.21"
2561
source = "registry+https://github.com/rust-lang/crates.io-index"
2562
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
2563
dependencies = [
2564
"proc-macro2",
2565
"quote",
2566
"syn 3.0.9",
2567
]
2568
2569
[[package]]
2570
name = "serde_spanned"
2571
version = "1.1.3"
2572
source = "registry+https://github.com/rust-lang/crates.io-index"
2573
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
2574
dependencies = [
2575
"serde_core",
2576
]
2577
2158
2578
[[package]]
2159
2579
name = "shlex"
2160
2580
version = "2.0.1"
2161
2581
source = "registry+https://github.com/rust-lang/crates.io-index"
2162
2582
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2163
2583
2584
[[package]]
2585
name = "signal-hook-registry"
2586
version = "1.4.8"
2587
source = "registry+https://github.com/rust-lang/crates.io-index"
2588
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
2589
dependencies = [
2590
"errno",
2591
"libc",
2592
]
2593
2164
2594
[[package]]
2165
2595
name = "simd-adler32"
2166
2596
version = "0.3.10"
@@ -2341,6 +2771,19 @@ dependencies = [
2341
2771
"syn 3.0.9",
2342
2772
]
2343
2773
2774
[[package]]
2775
name = "tempfile"
2776
version = "3.27.0"
2777
source = "registry+https://github.com/rust-lang/crates.io-index"
2778
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2779
dependencies = [
2780
"fastrand",
2781
"getrandom 0.4.3",
2782
"once_cell",
2783
"rustix 1.1.6",
2784
"windows-sys 0.61.2",
2785
]
2786
2344
2787
[[package]]
2345
2788
name = "termcolor"
2346
2789
version = "1.4.1"
@@ -2418,6 +2861,21 @@ dependencies = [
2418
2861
"pin-project-lite",
2419
2862
]
2420
2863
2864
[[package]]
2865
name = "toml"
2866
version = "1.2.1+spec-1.1.0"
2867
source = "registry+https://github.com/rust-lang/crates.io-index"
2868
checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
2869
dependencies = [
2870
"indexmap",
2871
"serde_core",
2872
"serde_spanned",
2873
"toml_datetime",
2874
"toml_parser",
2875
"toml_writer",
2876
"winnow",
2877
]
2878
2421
2879
[[package]]
2422
2880
name = "toml_datetime"
2423
2881
version = "1.1.3+spec-1.1.0"
@@ -2448,6 +2906,12 @@ dependencies = [
2448
2906
"winnow",
2449
2907
]
2450
2908
2909
[[package]]
2910
name = "toml_writer"
2911
version = "1.2.0+spec-1.1.0"
2912
source = "registry+https://github.com/rust-lang/crates.io-index"
2913
checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
2914
2451
2915
[[package]]
2452
2916
name = "tracing"
2453
2917
version = "0.1.44"
@@ -2456,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2456
2920
dependencies = [
2457
2921
"log",
2458
2922
"pin-project-lite",
2923
"tracing-attributes",
2459
2924
"tracing-core",
2460
2925
]
2461
2926
2927
[[package]]
2928
name = "tracing-attributes"
2929
version = "0.1.31"
2930
source = "registry+https://github.com/rust-lang/crates.io-index"
2931
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2932
dependencies = [
2933
"proc-macro2",
2934
"quote",
2935
"syn 2.0.119",
2936
]
2937
2462
2938
[[package]]
2463
2939
name = "tracing-core"
2464
2940
version = "0.1.36"
2465
2941
source = "registry+https://github.com/rust-lang/crates.io-index"
2466
2942
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2943
dependencies = [
2944
"once_cell",
2945
]
2467
2946
2468
2947
[[package]]
2469
2948
name = "ttf-parser"
@@ -2480,6 +2959,17 @@ dependencies = [
2480
2959
"rustc-hash 2.1.3",
2481
2960
]
2482
2961
2962
[[package]]
2963
name = "uds_windows"
2964
version = "1.2.1"
2965
source = "registry+https://github.com/rust-lang/crates.io-index"
2966
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
2967
dependencies = [
2968
"memoffset",
2969
"tempfile",
2970
"windows-sys 0.61.2",
2971
]
2972
2483
2973
[[package]]
2484
2974
name = "ufbx"
2485
2975
version = "0.11.5"
@@ -2523,6 +3013,7 @@ dependencies = [
2523
3013
"idna",
2524
3014
"percent-encoding",
2525
3015
"serde",
3016
"serde_derive",
2526
3017
]
2527
3018
2528
3019
[[package]]
@@ -2546,6 +3037,17 @@ version = "1.0.0"
2546
3037
source = "registry+https://github.com/rust-lang/crates.io-index"
2547
3038
checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
2548
3039
3040
[[package]]
3041
name = "uuid"
3042
version = "1.29.0"
3043
source = "registry+https://github.com/rust-lang/crates.io-index"
3044
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
3045
dependencies = [
3046
"js-sys",
3047
"serde_core",
3048
"wasm-bindgen",
3049
]
3050
2549
3051
[[package]]
2550
3052
name = "version_check"
2551
3053
version = "0.9.5"
@@ -3061,7 +3563,7 @@ dependencies = [
3061
3563
"android-activity",
3062
3564
"atomic-waker",
3063
3565
"bitflags 2.13.2",
3064
"block2",
3566
"block2 0.5.1",
3065
3567
"bytemuck",
3066
3568
"calloop 0.13.0",
3067
3569
"cfg_aliases 0.2.2",
@@ -3111,6 +3613,26 @@ dependencies = [
3111
3613
"memchr",
3112
3614
]
3113
3615
3616
[[package]]
3617
name = "winreg"
3618
version = "0.55.0"
3619
source = "registry+https://github.com/rust-lang/crates.io-index"
3620
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
3621
dependencies = [
3622
"cfg-if",
3623
"windows-sys 0.59.0",
3624
]
3625
3626
[[package]]
3627
name = "winresource"
3628
version = "0.1.34"
3629
source = "registry+https://github.com/rust-lang/crates.io-index"
3630
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
3631
dependencies = [
3632
"toml",
3633
"version_check",
3634
]
3635
3114
3636
[[package]]
3115
3637
name = "wit-bindgen"
3116
3638
version = "0.57.1"
@@ -3209,6 +3731,76 @@ dependencies = [
3209
3731
"synstructure",
3210
3732
]
3211
3733
3734
[[package]]
3735
name = "zbus"
3736
version = "5.19.0"
3737
source = "registry+https://github.com/rust-lang/crates.io-index"
3738
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
3739
dependencies = [
3740
"async-broadcast",
3741
"async-executor",
3742
"async-io",
3743
"async-lock",
3744
"async-process",
3745
"async-recursion",
3746
"async-task",
3747
"async-trait",
3748
"blocking",
3749
"enumflags2",
3750
"event-listener",
3751
"futures-core",
3752
"futures-lite",
3753
"hex",
3754
"libc",
3755
"ordered-stream",
3756
"rustix 1.1.6",
3757
"serde",
3758
"serde_repr",
3759
"tracing",
3760
"uds_windows",
3761
"uuid",
3762
"windows-sys 0.61.2",
3763
"winnow",
3764
"zbus_macros",
3765
"zbus_names",
3766
"zvariant",
3767
]
3768
3769
[[package]]
3770
name = "zbus_macros"
3771
version = "5.19.0"
3772
source = "registry+https://github.com/rust-lang/crates.io-index"
3773
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
3774
dependencies = [
3775
"proc-macro-crate",
3776
"proc-macro2",
3777
"quote",
3778
"syn 3.0.9",
3779
"zbus_names",
3780
"zvariant",
3781
"zvariant_utils",
3782
]
3783
3784
[[package]]
3785
name = "zbus_names"
3786
version = "4.3.5"
3787
source = "registry+https://github.com/rust-lang/crates.io-index"
3788
checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
3789
dependencies = [
3790
"serde",
3791
"winnow",
3792
"zvariant",
3793
]
3794
3795
[[package]]
3796
name = "zcheapstr"
3797
version = "1.2.0"
3798
source = "registry+https://github.com/rust-lang/crates.io-index"
3799
checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"
3800
dependencies = [
3801
"serde",
3802
]
3803
3212
3804
[[package]]
3213
3805
name = "zerocopy"
3214
3806
version = "0.8.63"
@@ -3309,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3309
3901
dependencies = [
3310
3902
"zune-core",
3311
3903
]
3904
3905
[[package]]
3906
name = "zvariant"
3907
version = "5.15.1"
3908
source = "registry+https://github.com/rust-lang/crates.io-index"
3909
checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
3910
dependencies = [
3911
"endi",
3912
"enumflags2",
3913
"serde",
3914
"url",
3915
"winnow",
3916
"zcheapstr",
3917
"zvariant_derive",
3918
"zvariant_utils",
3919
]
3920
3921
[[package]]
3922
name = "zvariant_derive"
3923
version = "5.15.1"
3924
source = "registry+https://github.com/rust-lang/crates.io-index"
3925
checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
3926
dependencies = [
3927
"proc-macro-crate",
3928
"proc-macro2",
3929
"quote",
3930
"syn 3.0.9",
3931
"zvariant_utils",
3932
]
3933
3934
[[package]]
3935
name = "zvariant_utils"
3936
version = "4.2.0"
3937
source = "registry+https://github.com/rust-lang/crates.io-index"
3938
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
3939
dependencies = [
3940
"proc-macro2",
3941
"quote",
3942
"serde",
3943
"syn 3.0.9",
3944
"winnow",
3945
]
@@ -109,6 +109,181 @@ dependencies = [109
"libloading",109
"libloading",110
]110
]111
111
112
[[package]]113
name = "ashpd"114
version = "0.11.1"115
source = "registry+https://github.com/rust-lang/crates.io-index"116
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"117
dependencies = [118
"async-fs",119
"async-net",120
"enumflags2",121
"futures-channel",122
"futures-util",123
"rand",124
"raw-window-handle",125
"serde",126
"serde_repr",127
"url",128
"wayland-backend",129
"wayland-client",130
"wayland-protocols",131
"zbus",132
]133
134
[[package]]135
name = "async-broadcast"136
version = "0.7.2"137
source = "registry+https://github.com/rust-lang/crates.io-index"138
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"139
dependencies = [140
"event-listener",141
"event-listener-strategy",142
"futures-core",143
"pin-project-lite",144
]145
146
[[package]]147
name = "async-channel"148
version = "2.5.0"149
source = "registry+https://github.com/rust-lang/crates.io-index"150
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"151
dependencies = [152
"concurrent-queue",153
"event-listener-strategy",154
"futures-core",155
"pin-project-lite",156
]157
158
[[package]]159
name = "async-executor"160
version = "1.14.1"161
source = "registry+https://github.com/rust-lang/crates.io-index"162
checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37"163
dependencies = [164
"async-task",165
"concurrent-queue",166
"fastrand",167
"futures-lite",168
"pin-project-lite",169
"slab",170
]171
172
[[package]]173
name = "async-fs"174
version = "2.2.0"175
source = "registry+https://github.com/rust-lang/crates.io-index"176
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"177
dependencies = [178
"async-lock",179
"blocking",180
"futures-lite",181
]182
183
[[package]]184
name = "async-io"185
version = "2.6.0"186
source = "registry+https://github.com/rust-lang/crates.io-index"187
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"188
dependencies = [189
"autocfg",190
"cfg-if",191
"concurrent-queue",192
"futures-io",193
"futures-lite",194
"parking",195
"polling",196
"rustix 1.1.6",197
"slab",198
"windows-sys 0.61.2",199
]200
201
[[package]]202
name = "async-lock"203
version = "3.5.0"204
source = "registry+https://github.com/rust-lang/crates.io-index"205
checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f"206
dependencies = [207
"event-listener",208
"event-listener-strategy",209
"pin-project-lite",210
]211
212
[[package]]213
name = "async-net"214
version = "2.0.0"215
source = "registry+https://github.com/rust-lang/crates.io-index"216
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"217
dependencies = [218
"async-io",219
"blocking",220
"futures-lite",221
]222
223
[[package]]224
name = "async-process"225
version = "2.5.0"226
source = "registry+https://github.com/rust-lang/crates.io-index"227
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"228
dependencies = [229
"async-channel",230
"async-io",231
"async-lock",232
"async-signal",233
"async-task",234
"blocking",235
"cfg-if",236
"event-listener",237
"futures-lite",238
"rustix 1.1.6",239
]240
241
[[package]]242
name = "async-recursion"243
version = "1.2.0"244
source = "registry+https://github.com/rust-lang/crates.io-index"245
checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"246
dependencies = [247
"proc-macro2",248
"quote",249
"syn 3.0.9",250
]251
252
[[package]]253
name = "async-signal"254
version = "0.2.14"255
source = "registry+https://github.com/rust-lang/crates.io-index"256
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"257
dependencies = [258
"async-io",259
"async-lock",260
"atomic-waker",261
"cfg-if",262
"futures-core",263
"futures-io",264
"rustix 1.1.6",265
"signal-hook-registry",266
"slab",267
"windows-sys 0.61.2",268
]269
270
[[package]]271
name = "async-task"272
version = "4.7.1"273
source = "registry+https://github.com/rust-lang/crates.io-index"274
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"275
276
[[package]]277
name = "async-trait"278
version = "0.1.92"279
source = "registry+https://github.com/rust-lang/crates.io-index"280
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"281
dependencies = [282
"proc-macro2",283
"quote",284
"syn 3.0.9",285
]286
112
[[package]]287
[[package]]113
name = "atomic-waker"288
name = "atomic-waker"114
version = "1.1.2"289
version = "1.1.2"@@ -169,6 +344,28 @@ dependencies = [169
"objc2 0.5.3",344
"objc2 0.5.3",170
]345
]171
346
347
[[package]]348
name = "block2"349
version = "0.6.2"350
source = "registry+https://github.com/rust-lang/crates.io-index"351
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"352
dependencies = [353
"objc2 0.6.5",354
]355
356
[[package]]357
name = "blocking"358
version = "1.7.0"359
source = "registry+https://github.com/rust-lang/crates.io-index"360
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"361
dependencies = [362
"async-channel",363
"async-task",364
"futures-io",365
"futures-lite",366
"piper",367
]368
172
[[package]]369
[[package]]173
name = "bumpalo"370
name = "bumpalo"174
version = "3.20.3"371
version = "3.20.3"@@ -445,6 +642,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"445
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"642
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"446
dependencies = [643
dependencies = [447
"bitflags 2.13.2",644
"bitflags 2.13.2",645
"block2 0.6.2",646
"libc",448
"objc2 0.6.5",647
"objc2 0.6.5",449
]648
]450
649
@@ -609,6 +808,33 @@ dependencies = [609
"bytemuck",808
"bytemuck",610
]809
]611
810
811
[[package]]812
name = "endi"813
version = "1.1.1"814
source = "registry+https://github.com/rust-lang/crates.io-index"815
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"816
817
[[package]]818
name = "enumflags2"819
version = "0.7.12"820
source = "registry+https://github.com/rust-lang/crates.io-index"821
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"822
dependencies = [823
"enumflags2_derive",824
"serde",825
]826
827
[[package]]828
name = "enumflags2_derive"829
version = "0.7.12"830
source = "registry+https://github.com/rust-lang/crates.io-index"831
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"832
dependencies = [833
"proc-macro2",834
"quote",835
"syn 2.0.119",836
]837
612
[[package]]838
[[package]]613
name = "epaint"839
name = "epaint"614
version = "0.29.1"840
version = "0.29.1"@@ -654,6 +880,32 @@ version = "3.4.0"654
source = "registry+https://github.com/rust-lang/crates.io-index"880
source = "registry+https://github.com/rust-lang/crates.io-index"655
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"881
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"656
882
883
[[package]]884
name = "event-listener"885
version = "5.4.2"886
source = "registry+https://github.com/rust-lang/crates.io-index"887
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"888
dependencies = [889
"parking",890
"pin-project-lite",891
]892
893
[[package]]894
name = "event-listener-strategy"895
version = "0.5.4"896
source = "registry+https://github.com/rust-lang/crates.io-index"897
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"898
dependencies = [899
"event-listener",900
"pin-project-lite",901
]902
903
[[package]]904
name = "fastrand"905
version = "2.5.0"906
source = "registry+https://github.com/rust-lang/crates.io-index"907
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"908
657
[[package]]909
[[package]]658
name = "fdeflate"910
name = "fdeflate"659
version = "0.3.7"911
version = "0.3.7"@@ -722,12 +974,51 @@ dependencies = [722
"percent-encoding",974
"percent-encoding",723
]975
]724
976
977
[[package]]978
name = "futures-channel"979
version = "0.3.34"980
source = "registry+https://github.com/rust-lang/crates.io-index"981
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"982
dependencies = [983
"futures-core",984
]985
725
[[package]]986
[[package]]726
name = "futures-core"987
name = "futures-core"727
version = "0.3.34"988
version = "0.3.34"728
source = "registry+https://github.com/rust-lang/crates.io-index"989
source = "registry+https://github.com/rust-lang/crates.io-index"729
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"990
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"730
991
992
[[package]]993
name = "futures-io"994
version = "0.3.34"995
source = "registry+https://github.com/rust-lang/crates.io-index"996
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"997
998
[[package]]999
name = "futures-lite"1000
version = "2.6.1"1001
source = "registry+https://github.com/rust-lang/crates.io-index"1002
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"1003
dependencies = [1004
"fastrand",1005
"futures-core",1006
"futures-io",1007
"parking",1008
"pin-project-lite",1009
]1010
1011
[[package]]1012
name = "futures-macro"1013
version = "0.3.34"1014
source = "registry+https://github.com/rust-lang/crates.io-index"1015
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"1016
dependencies = [1017
"proc-macro2",1018
"quote",1019
"syn 3.0.9",1020
]1021
731
[[package]]1022
[[package]]732
name = "futures-task"1023
name = "futures-task"733
version = "0.3.34"1024
version = "0.3.34"@@ -741,7 +1032,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"741
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"1032
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"742
dependencies = [1033
dependencies = [743
"futures-core",1034
"futures-core",1035
"futures-io",1036
"futures-macro",744
"futures-task",1037
"futures-task",1038
"memchr",745
"pin-project-lite",1039
"pin-project-lite",746
"slab",1040
"slab",747
]1041
]@@ -1007,6 +1301,12 @@ version = "0.5.3"1007
source = "registry+https://github.com/rust-lang/crates.io-index"1301
source = "registry+https://github.com/rust-lang/crates.io-index"1008
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"1302
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"1009
1303
1304
[[package]]1305
name = "hex"1306
version = "0.4.3"1307
source = "registry+https://github.com/rust-lang/crates.io-index"1308
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"1309
1010
[[package]]1310
[[package]]1011
name = "hexf-parse"1311
name = "hexf-parse"1012
version = "0.2.1"1312
version = "0.2.1"@@ -1539,7 +1839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1539
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"1839
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"1540
dependencies = [1840
dependencies = [1541
"bitflags 2.13.2",1841
"bitflags 2.13.2",1542
"block2",1842
"block2 0.5.1",1543
"libc",1843
"libc",1544
"objc2 0.5.3",1844
"objc2 0.5.3",1545
"objc2-core-data",1845
"objc2-core-data",@@ -1555,6 +1855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1555
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"1855
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"1556
dependencies = [1856
dependencies = [1557
"bitflags 2.13.2",1857
"bitflags 2.13.2",1858
"block2 0.6.2",1558
"objc2 0.6.5",1859
"objc2 0.6.5",1559
"objc2-core-foundation",1860
"objc2-core-foundation",1560
"objc2-core-graphics",1861
"objc2-core-graphics",@@ -1568,7 +1869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1568
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"1869
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"1569
dependencies = [1870
dependencies = [1570
"bitflags 2.13.2",1871
"bitflags 2.13.2",1571
"block2",1872
"block2 0.5.1",1572
"objc2 0.5.3",1873
"objc2 0.5.3",1573
"objc2-core-location",1874
"objc2-core-location",1574
"objc2-foundation 0.2.2",1875
"objc2-foundation 0.2.2",@@ -1580,7 +1881,7 @@ version = "0.2.2"1580
source = "registry+https://github.com/rust-lang/crates.io-index"1881
source = "registry+https://github.com/rust-lang/crates.io-index"1581
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"1882
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"1582
dependencies = [1883
dependencies = [1583
"block2",1884
"block2 0.5.1",1584
"objc2 0.5.3",1885
"objc2 0.5.3",1585
"objc2-foundation 0.2.2",1886
"objc2-foundation 0.2.2",1586
]1887
]@@ -1592,7 +1893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1592
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"1893
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"1593
dependencies = [1894
dependencies = [1594
"bitflags 2.13.2",1895
"bitflags 2.13.2",1595
"block2",1896
"block2 0.5.1",1596
"objc2 0.5.3",1897
"objc2 0.5.3",1597
"objc2-foundation 0.2.2",1898
"objc2-foundation 0.2.2",1598
]1899
]@@ -1627,7 +1928,7 @@ version = "0.2.2"1627
source = "registry+https://github.com/rust-lang/crates.io-index"1928
source = "registry+https://github.com/rust-lang/crates.io-index"1628
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"1929
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"1629
dependencies = [1930
dependencies = [1630
"block2",1931
"block2 0.5.1",1631
"objc2 0.5.3",1932
"objc2 0.5.3",1632
"objc2-foundation 0.2.2",1933
"objc2-foundation 0.2.2",1633
"objc2-metal",1934
"objc2-metal",@@ -1639,7 +1940,7 @@ version = "0.2.2"1639
source = "registry+https://github.com/rust-lang/crates.io-index"1940
source = "registry+https://github.com/rust-lang/crates.io-index"1640
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"1941
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"1641
dependencies = [1942
dependencies = [1642
"block2",1943
"block2 0.5.1",1643
"objc2 0.5.3",1944
"objc2 0.5.3",1644
"objc2-contacts",1945
"objc2-contacts",1645
"objc2-foundation 0.2.2",1946
"objc2-foundation 0.2.2",@@ -1658,7 +1959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1658
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"1959
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"1659
dependencies = [1960
dependencies = [1660
"bitflags 2.13.2",1961
"bitflags 2.13.2",1661
"block2",1962
"block2 0.5.1",1662
"dispatch",1963
"dispatch",1663
"libc",1964
"libc",1664
"objc2 0.5.3",1965
"objc2 0.5.3",@@ -1692,7 +1993,7 @@ version = "0.2.2"1692
source = "registry+https://github.com/rust-lang/crates.io-index"1993
source = "registry+https://github.com/rust-lang/crates.io-index"1693
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"1994
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"1694
dependencies = [1995
dependencies = [1695
"block2",1996
"block2 0.5.1",1696
"objc2 0.5.3",1997
"objc2 0.5.3",1697
"objc2-app-kit 0.2.2",1998
"objc2-app-kit 0.2.2",1698
"objc2-foundation 0.2.2",1999
"objc2-foundation 0.2.2",@@ -1705,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1705
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"2006
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"1706
dependencies = [2007
dependencies = [1707
"bitflags 2.13.2",2008
"bitflags 2.13.2",1708
"block2",2009
"block2 0.5.1",1709
"objc2 0.5.3",2010
"objc2 0.5.3",1710
"objc2-foundation 0.2.2",2011
"objc2-foundation 0.2.2",1711
]2012
]@@ -1717,7 +2018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1717
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"2018
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"1718
dependencies = [2019
dependencies = [1719
"bitflags 2.13.2",2020
"bitflags 2.13.2",1720
"block2",2021
"block2 0.5.1",1721
"objc2 0.5.3",2022
"objc2 0.5.3",1722
"objc2-foundation 0.2.2",2023
"objc2-foundation 0.2.2",1723
"objc2-metal",2024
"objc2-metal",@@ -1740,7 +2041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1740
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"2041
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"1741
dependencies = [2042
dependencies = [1742
"bitflags 2.13.2",2043
"bitflags 2.13.2",1743
"block2",2044
"block2 0.5.1",1744
"objc2 0.5.3",2045
"objc2 0.5.3",1745
"objc2-cloud-kit",2046
"objc2-cloud-kit",1746
"objc2-core-data",2047
"objc2-core-data",@@ -1760,7 +2061,7 @@ version = "0.2.2"1760
source = "registry+https://github.com/rust-lang/crates.io-index"2061
source = "registry+https://github.com/rust-lang/crates.io-index"1761
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"2062
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"1762
dependencies = [2063
dependencies = [1763
"block2",2064
"block2 0.5.1",1764
"objc2 0.5.3",2065
"objc2 0.5.3",1765
"objc2-foundation 0.2.2",2066
"objc2-foundation 0.2.2",1766
]2067
]@@ -1772,7 +2073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"1772
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"2073
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"1773
dependencies = [2074
dependencies = [1774
"bitflags 2.13.2",2075
"bitflags 2.13.2",1775
"block2",2076
"block2 0.5.1",1776
"objc2 0.5.3",2077
"objc2 0.5.3",1777
"objc2-core-location",2078
"objc2-core-location",1778
"objc2-foundation 0.2.2",2079
"objc2-foundation 0.2.2",@@ -1794,6 +2095,16 @@ dependencies = [1794
"libredox",2095
"libredox",1795
]2096
]1796
2097
2098
[[package]]2099
name = "ordered-stream"2100
version = "0.2.0"2101
source = "registry+https://github.com/rust-lang/crates.io-index"2102
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"2103
dependencies = [2104
"futures-core",2105
"pin-project-lite",2106
]2107
1797
[[package]]2108
[[package]]1798
name = "owned_ttf_parser"2109
name = "owned_ttf_parser"1799
version = "0.25.1"2110
version = "0.25.1"@@ -1812,12 +2123,22 @@ dependencies = [1812
"glow 0.14.2",2123
"glow 0.14.2",1813
"gltf",2124
"gltf",1814
"libloading",2125
"libloading",2126
"png",2127
"rfd",1815
"serde",2128
"serde",1816
"serde_json",2129
"serde_json",1817
"tobj",2130
"tobj",1818
"ufbx",2131
"ufbx",2132
"winreg",2133
"winresource",1819
]2134
]1820
2135
2136
[[package]]2137
name = "parking"2138
version = "2.2.1"2139
source = "registry+https://github.com/rust-lang/crates.io-index"2140
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"2141
1821
[[package]]2142
[[package]]1822
name = "parking_lot"2143
name = "parking_lot"1823
version = "0.12.5"2144
version = "0.12.5"@@ -1879,6 +2200,17 @@ version = "0.2.17"1879
source = "registry+https://github.com/rust-lang/crates.io-index"2200
source = "registry+https://github.com/rust-lang/crates.io-index"1880
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2201
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"1881
2202
2203
[[package]]2204
name = "piper"2205
version = "0.2.5"2206
source = "registry+https://github.com/rust-lang/crates.io-index"2207
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"2208
dependencies = [2209
"atomic-waker",2210
"fastrand",2211
"futures-io",2212
]2213
1882
[[package]]2214
[[package]]1883
name = "pkg-config"2215
name = "pkg-config"1884
version = "0.3.34"2216
version = "0.3.34"@@ -1918,6 +2250,12 @@ dependencies = [1918
"windows-sys 0.61.2",2250
"windows-sys 0.61.2",1919
]2251
]1920
2252
2253
[[package]]2254
name = "pollster"2255
version = "0.4.0"2256
source = "registry+https://github.com/rust-lang/crates.io-index"2257
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"2258
1921
[[package]]2259
[[package]]1922
name = "potential_utf"2260
name = "potential_utf"1923
version = "0.1.6"2261
version = "0.1.6"@@ -1927,6 +2265,15 @@ dependencies = [1927
"zerovec",2265
"zerovec",1928
]2266
]1929
2267
2268
[[package]]2269
name = "ppv-lite86"2270
version = "0.2.21"2271
source = "registry+https://github.com/rust-lang/crates.io-index"2272
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"2273
dependencies = [2274
"zerocopy",2275
]2276
1930
[[package]]2277
[[package]]1931
name = "presser"2278
name = "presser"1932
version = "0.3.1"2279
version = "0.3.1"@@ -1993,6 +2340,35 @@ version = "6.0.0"1993
source = "registry+https://github.com/rust-lang/crates.io-index"2340
source = "registry+https://github.com/rust-lang/crates.io-index"1994
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"2341
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"1995
2342
2343
[[package]]2344
name = "rand"2345
version = "0.9.5"2346
source = "registry+https://github.com/rust-lang/crates.io-index"2347
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"2348
dependencies = [2349
"rand_chacha",2350
"rand_core",2351
]2352
2353
[[package]]2354
name = "rand_chacha"2355
version = "0.9.0"2356
source = "registry+https://github.com/rust-lang/crates.io-index"2357
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"2358
dependencies = [2359
"ppv-lite86",2360
"rand_core",2361
]2362
2363
[[package]]2364
name = "rand_core"2365
version = "0.9.5"2366
source = "registry+https://github.com/rust-lang/crates.io-index"2367
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"2368
dependencies = [2369
"getrandom 0.3.4",2370
]2371
1996
[[package]]2372
[[package]]1997
name = "raw-window-handle"2373
name = "raw-window-handle"1998
version = "0.6.2"2374
version = "0.6.2"@@ -2032,6 +2408,30 @@ version = "1.1.0"2032
source = "registry+https://github.com/rust-lang/crates.io-index"2408
source = "registry+https://github.com/rust-lang/crates.io-index"2033
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"2409
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"2034
2410
2411
[[package]]2412
name = "rfd"2413
version = "0.15.4"2414
source = "registry+https://github.com/rust-lang/crates.io-index"2415
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"2416
dependencies = [2417
"ashpd",2418
"block2 0.6.2",2419
"dispatch2",2420
"js-sys",2421
"log",2422
"objc2 0.6.5",2423
"objc2-app-kit 0.3.2",2424
"objc2-core-foundation",2425
"objc2-foundation 0.3.2",2426
"pollster",2427
"raw-window-handle",2428
"urlencoding",2429
"wasm-bindgen",2430
"wasm-bindgen-futures",2431
"web-sys",2432
"windows-sys 0.59.0",2433
]2434
2035
[[package]]2435
[[package]]2036
name = "rustc-hash"2436
name = "rustc-hash"2037
version = "1.1.0"2437
version = "1.1.0"@@ -2155,12 +2555,42 @@ dependencies = [2155
"zmij",2555
"zmij",2156
]2556
]2157
2557
2558
[[package]]2559
name = "serde_repr"2560
version = "0.1.21"2561
source = "registry+https://github.com/rust-lang/crates.io-index"2562
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"2563
dependencies = [2564
"proc-macro2",2565
"quote",2566
"syn 3.0.9",2567
]2568
2569
[[package]]2570
name = "serde_spanned"2571
version = "1.1.3"2572
source = "registry+https://github.com/rust-lang/crates.io-index"2573
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"2574
dependencies = [2575
"serde_core",2576
]2577
2158
[[package]]2578
[[package]]2159
name = "shlex"2579
name = "shlex"2160
version = "2.0.1"2580
version = "2.0.1"2161
source = "registry+https://github.com/rust-lang/crates.io-index"2581
source = "registry+https://github.com/rust-lang/crates.io-index"2162
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"2582
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"2163
2583
2584
[[package]]2585
name = "signal-hook-registry"2586
version = "1.4.8"2587
source = "registry+https://github.com/rust-lang/crates.io-index"2588
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"2589
dependencies = [2590
"errno",2591
"libc",2592
]2593
2164
[[package]]2594
[[package]]2165
name = "simd-adler32"2595
name = "simd-adler32"2166
version = "0.3.10"2596
version = "0.3.10"@@ -2341,6 +2771,19 @@ dependencies = [2341
"syn 3.0.9",2771
"syn 3.0.9",2342
]2772
]2343
2773
2774
[[package]]2775
name = "tempfile"2776
version = "3.27.0"2777
source = "registry+https://github.com/rust-lang/crates.io-index"2778
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"2779
dependencies = [2780
"fastrand",2781
"getrandom 0.4.3",2782
"once_cell",2783
"rustix 1.1.6",2784
"windows-sys 0.61.2",2785
]2786
2344
[[package]]2787
[[package]]2345
name = "termcolor"2788
name = "termcolor"2346
version = "1.4.1"2789
version = "1.4.1"@@ -2418,6 +2861,21 @@ dependencies = [2418
"pin-project-lite",2861
"pin-project-lite",2419
]2862
]2420
2863
2864
[[package]]2865
name = "toml"2866
version = "1.2.1+spec-1.1.0"2867
source = "registry+https://github.com/rust-lang/crates.io-index"2868
checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"2869
dependencies = [2870
"indexmap",2871
"serde_core",2872
"serde_spanned",2873
"toml_datetime",2874
"toml_parser",2875
"toml_writer",2876
"winnow",2877
]2878
2421
[[package]]2879
[[package]]2422
name = "toml_datetime"2880
name = "toml_datetime"2423
version = "1.1.3+spec-1.1.0"2881
version = "1.1.3+spec-1.1.0"@@ -2448,6 +2906,12 @@ dependencies = [2448
"winnow",2906
"winnow",2449
]2907
]2450
2908
2909
[[package]]2910
name = "toml_writer"2911
version = "1.2.0+spec-1.1.0"2912
source = "registry+https://github.com/rust-lang/crates.io-index"2913
checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"2914
2451
[[package]]2915
[[package]]2452
name = "tracing"2916
name = "tracing"2453
version = "0.1.44"2917
version = "0.1.44"@@ -2456,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"2456
dependencies = [2920
dependencies = [2457
"log",2921
"log",2458
"pin-project-lite",2922
"pin-project-lite",2923
"tracing-attributes",2459
"tracing-core",2924
"tracing-core",2460
]2925
]2461
2926
2927
[[package]]2928
name = "tracing-attributes"2929
version = "0.1.31"2930
source = "registry+https://github.com/rust-lang/crates.io-index"2931
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"2932
dependencies = [2933
"proc-macro2",2934
"quote",2935
"syn 2.0.119",2936
]2937
2462
[[package]]2938
[[package]]2463
name = "tracing-core"2939
name = "tracing-core"2464
version = "0.1.36"2940
version = "0.1.36"2465
source = "registry+https://github.com/rust-lang/crates.io-index"2941
source = "registry+https://github.com/rust-lang/crates.io-index"2466
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"2942
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"2943
dependencies = [2944
"once_cell",2945
]2467
2946
2468
[[package]]2947
[[package]]2469
name = "ttf-parser"2948
name = "ttf-parser"@@ -2480,6 +2959,17 @@ dependencies = [2480
"rustc-hash 2.1.3",2959
"rustc-hash 2.1.3",2481
]2960
]2482
2961
2962
[[package]]2963
name = "uds_windows"2964
version = "1.2.1"2965
source = "registry+https://github.com/rust-lang/crates.io-index"2966
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"2967
dependencies = [2968
"memoffset",2969
"tempfile",2970
"windows-sys 0.61.2",2971
]2972
2483
[[package]]2973
[[package]]2484
name = "ufbx"2974
name = "ufbx"2485
version = "0.11.5"2975
version = "0.11.5"@@ -2523,6 +3013,7 @@ dependencies = [2523
"idna",3013
"idna",2524
"percent-encoding",3014
"percent-encoding",2525
"serde",3015
"serde",3016
"serde_derive",2526
]3017
]2527
3018
2528
[[package]]3019
[[package]]@@ -2546,6 +3037,17 @@ version = "1.0.0"2546
source = "registry+https://github.com/rust-lang/crates.io-index"3037
source = "registry+https://github.com/rust-lang/crates.io-index"2547
checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"3038
checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"2548
3039
3040
[[package]]3041
name = "uuid"3042
version = "1.29.0"3043
source = "registry+https://github.com/rust-lang/crates.io-index"3044
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"3045
dependencies = [3046
"js-sys",3047
"serde_core",3048
"wasm-bindgen",3049
]3050
2549
[[package]]3051
[[package]]2550
name = "version_check"3052
name = "version_check"2551
version = "0.9.5"3053
version = "0.9.5"@@ -3061,7 +3563,7 @@ dependencies = [3061
"android-activity",3563
"android-activity",3062
"atomic-waker",3564
"atomic-waker",3063
"bitflags 2.13.2",3565
"bitflags 2.13.2",3064
"block2",3566
"block2 0.5.1",3065
"bytemuck",3567
"bytemuck",3066
"calloop 0.13.0",3568
"calloop 0.13.0",3067
"cfg_aliases 0.2.2",3569
"cfg_aliases 0.2.2",@@ -3111,6 +3613,26 @@ dependencies = [3111
"memchr",3613
"memchr",3112
]3614
]3113
3615
3616
[[package]]3617
name = "winreg"3618
version = "0.55.0"3619
source = "registry+https://github.com/rust-lang/crates.io-index"3620
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"3621
dependencies = [3622
"cfg-if",3623
"windows-sys 0.59.0",3624
]3625
3626
[[package]]3627
name = "winresource"3628
version = "0.1.34"3629
source = "registry+https://github.com/rust-lang/crates.io-index"3630
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"3631
dependencies = [3632
"toml",3633
"version_check",3634
]3635
3114
[[package]]3636
[[package]]3115
name = "wit-bindgen"3637
name = "wit-bindgen"3116
version = "0.57.1"3638
version = "0.57.1"@@ -3209,6 +3731,76 @@ dependencies = [3209
"synstructure",3731
"synstructure",3210
]3732
]3211
3733
3734
[[package]]3735
name = "zbus"3736
version = "5.19.0"3737
source = "registry+https://github.com/rust-lang/crates.io-index"3738
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"3739
dependencies = [3740
"async-broadcast",3741
"async-executor",3742
"async-io",3743
"async-lock",3744
"async-process",3745
"async-recursion",3746
"async-task",3747
"async-trait",3748
"blocking",3749
"enumflags2",3750
"event-listener",3751
"futures-core",3752
"futures-lite",3753
"hex",3754
"libc",3755
"ordered-stream",3756
"rustix 1.1.6",3757
"serde",3758
"serde_repr",3759
"tracing",3760
"uds_windows",3761
"uuid",3762
"windows-sys 0.61.2",3763
"winnow",3764
"zbus_macros",3765
"zbus_names",3766
"zvariant",3767
]3768
3769
[[package]]3770
name = "zbus_macros"3771
version = "5.19.0"3772
source = "registry+https://github.com/rust-lang/crates.io-index"3773
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"3774
dependencies = [3775
"proc-macro-crate",3776
"proc-macro2",3777
"quote",3778
"syn 3.0.9",3779
"zbus_names",3780
"zvariant",3781
"zvariant_utils",3782
]3783
3784
[[package]]3785
name = "zbus_names"3786
version = "4.3.5"3787
source = "registry+https://github.com/rust-lang/crates.io-index"3788
checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"3789
dependencies = [3790
"serde",3791
"winnow",3792
"zvariant",3793
]3794
3795
[[package]]3796
name = "zcheapstr"3797
version = "1.2.0"3798
source = "registry+https://github.com/rust-lang/crates.io-index"3799
checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"3800
dependencies = [3801
"serde",3802
]3803
3212
[[package]]3804
[[package]]3213
name = "zerocopy"3805
name = "zerocopy"3214
version = "0.8.63"3806
version = "0.8.63"@@ -3309,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"3309
dependencies = [3901
dependencies = [3310
"zune-core",3902
"zune-core",3311
]3903
]3904
3905
[[package]]3906
name = "zvariant"3907
version = "5.15.1"3908
source = "registry+https://github.com/rust-lang/crates.io-index"3909
checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"3910
dependencies = [3911
"endi",3912
"enumflags2",3913
"serde",3914
"url",3915
"winnow",3916
"zcheapstr",3917
"zvariant_derive",3918
"zvariant_utils",3919
]3920
3921
[[package]]3922
name = "zvariant_derive"3923
version = "5.15.1"3924
source = "registry+https://github.com/rust-lang/crates.io-index"3925
checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"3926
dependencies = [3927
"proc-macro-crate",3928
"proc-macro2",3929
"quote",3930
"syn 3.0.9",3931
"zvariant_utils",3932
]3933
3934
[[package]]3935
name = "zvariant_utils"3936
version = "4.2.0"3937
source = "registry+https://github.com/rust-lang/crates.io-index"3938
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"3939
dependencies = [3940
"proc-macro2",3941
"quote",3942
"serde",3943
"syn 3.0.9",3944
"winnow",3945
]@@ -4,7 +4,7 @@ version = "0.1.0"
4
4
edition = "2021"
5
5
license = "MIT"
6
6
publish = false
7
description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
7
description = "Paperdoll Renderer with project files, model import and a live glTF preview."
8
8
9
9
[dependencies]
10
10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
@@ -12,7 +12,15 @@ egui_glow = "0.29.1"
12
12
glow = "0.14.2"
13
13
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
14
14
libloading = "0.8"
15
png = "0.18"
16
rfd = "0.15.4"
15
17
serde = { version = "1", features = ["derive"] }
16
18
serde_json = "1"
17
19
tobj = "4.0.3"
18
20
ufbx = "0.11.5"
21
22
[build-dependencies]
23
winresource = "0.1.34"
24
25
[target."cfg(windows)".dependencies]
26
winreg = "0.55"
@@ -4,7 +4,7 @@ version = "0.1.0"4
edition = "2021"4
edition = "2021"5
license = "MIT"5
license = "MIT"6
publish = false6
publish = false7
description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."7
description = "Paperdoll Renderer with project files, model import and a live glTF preview."8
8
9
[dependencies]9
[dependencies]10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }@@ -12,7 +12,15 @@ egui_glow = "0.29.1"12
glow = "0.14.2"12
glow = "0.14.2"13
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }13
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }14
libloading = "0.8"14
libloading = "0.8"15
png = "0.18"16
rfd = "0.15.4"15
serde = { version = "1", features = ["derive"] }17
serde = { version = "1", features = ["derive"] }16
serde_json = "1"18
serde_json = "1"17
tobj = "4.0.3"19
tobj = "4.0.3"18
ufbx = "0.11.5"20
ufbx = "0.11.5"21
22
[build-dependencies]23
winresource = "0.1.34"24
25
[target."cfg(windows)".dependencies]26
winreg = "0.55"@@ -1,40 +1,55 @@
1
1
# Paperdoll Renderer
2
2
3
Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.
3
Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
4
4
5
Rust 1.96.0 staat in `rust-toolchain.toml`. Voor het venster: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`.
5
## Starten
6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity.
6
8
7
9
```bash
8
10
cargo run
11
cargo run -- "/pad met spaties/character.ppd"
9
12
cargo run -- --preview-fixture
10
13
cargo run -- --status
11
cargo test --locked
12
14
```
13
15
14
`--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.
16
## Project en bodies
17
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
19
20
De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL.
21
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23
24
- Sleep om te orbiten, scroll om te zoomen.
25
- Kies een animatieclip en gebruik **Pauze / Speel**.
26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27
- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. **Live 3D** keert terug naar het model.
28
- Stel breedte, hoogte en fps in de Bodies-weergave in.
29
30

15
31
16
## Project
32
## Equipment en opslaan
17
33
18
**Nieuw** maakt een map met `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. **Open** leest een `.ppd`. **Opslaan** schrijft het bestand via een tijdelijk buurbestand.
34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**. De configuratie bewaart een unieke naam (kleine letters, cijfers, `-` en `_`) en een gekozen model. **Verwijder** verwijdert de definitie uit de projectlijst. **Opslaan** bewaart het project en equipment-configuraties in `presets/`.
19
35
20
In het bestand staan de projectnaam, de paden van het mannelijke en vrouwelijke model, de paden van equipment-presets, de uitvoermap, en breedte, hoogte en fps. Paden zijn relatief aan de projectmap. Een leeg geslachtsslot blijft leeg. **Lees man** en **Lees vrouw** tonen meshes, materialen, textures, joints en clips van glTF/GLB, FBX of OBJ+MTL.
36
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
21
37
22
## 3D-preview
38
## Openen met
23
39
24
Het middelste vlak tekent een geskind glTF/GLB-lichaam.
40
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).
41
- **Debian/Arch:** de pakketten installeren de `.ppd`-MIME-koppeling, desktopstarter en iconen. Kies de editor in **Openen met**.
42
- **Linux AppImage/losse binary:** bewaar het bestand op zijn vaste plek en voer `"/pad/Paperdoll.AppImage" --install-desktop` uit. Hiervoor zijn `shared-mime-info` en `desktop-file-utils` nodig. Daarna staat de editor in **Openen met**. Herhaal dit na verplaatsen van de AppImage.
25
43
26
- **Laad testlichamen** zet de man en de vrouw uit de fixture in de view.
27
- **3D van project** gebruikt de `.glb`- of `.gltf`-paden van man en vrouw.
28
- **Man** en **Vrouw** wisselen het lichaam. **walk** en **attack** wisselen de clip. **Pauze** / **Speel** zet de animatie stil of laat haar lopen.
29
- Sleep in het vlak om te draaien. Scroll om te zoomen.
30
- **Yaw −** en **Yaw +** stappen langs twaalf hoeken van 30°. **pitch -30**, **pitch 0** en **pitch 30** zetten de drie hoogtes. Yaw 0 kijkt op de voorkant; de testlichamen wijzen naar −Z.
44
Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.
31
45
32
46
## Tests
33
47
34
48
```bash
35
49
cargo fmt --check
50
cargo check --workspace --all-targets --locked
36
51
cargo clippy --workspace --all-targets --locked -- -D warnings
37
52
cargo test --workspace --locked
38
53
```
39
54
40
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-bestand van de man komt uit `scripts/convert_test_body_to_fbx.py` met Blender.
55
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
@@ -1,40 +1,55 @@1
# Paperdoll Renderer1
# Paperdoll Renderer2
2
3
Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.3
Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.4
4
5
Rust 1.96.0 staat in `rust-toolchain.toml`. Voor het venster: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`.5
## Starten6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity.6
8
7
```bash9
```bash8
cargo run10
cargo run11
cargo run -- "/pad met spaties/character.ppd"9
cargo run -- --preview-fixture12
cargo run -- --preview-fixture10
cargo run -- --status13
cargo run -- --status11
cargo test --locked12
```14
```13
15
14
`--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.16
## Project en bodies17
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.19
20
De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL.21
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:23
24
- Sleep om te orbiten, scroll om te zoomen.25
- Kies een animatieclip en gebruik **Pauze / Speel**.26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).27
- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. **Live 3D** keert terug naar het model.28
- Stel breedte, hoogte en fps in de Bodies-weergave in.29
30
15
31
16
## Project32
## Equipment en opslaan17
33
18
**Nieuw** maakt een map met `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. **Open** leest een `.ppd`. **Opslaan** schrijft het bestand via een tijdelijk buurbestand.34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**. De configuratie bewaart een unieke naam (kleine letters, cijfers, `-` en `_`) en een gekozen model. **Verwijder** verwijdert de definitie uit de projectlijst. **Opslaan** bewaart het project en equipment-configuraties in `presets/`.19
35
20
In het bestand staan de projectnaam, de paden van het mannelijke en vrouwelijke model, de paden van equipment-presets, de uitvoermap, en breedte, hoogte en fps. Paden zijn relatief aan de projectmap. Een leeg geslachtsslot blijft leeg. **Lees man** en **Lees vrouw** tonen meshes, materialen, textures, joints en clips van glTF/GLB, FBX of OBJ+MTL.36
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.21
37
22
## 3D-preview38
## Openen met23
39
24
Het middelste vlak tekent een geskind glTF/GLB-lichaam.40
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).41
- **Debian/Arch:** de pakketten installeren de `.ppd`-MIME-koppeling, desktopstarter en iconen. Kies de editor in **Openen met**.42
- **Linux AppImage/losse binary:** bewaar het bestand op zijn vaste plek en voer `"/pad/Paperdoll.AppImage" --install-desktop` uit. Hiervoor zijn `shared-mime-info` en `desktop-file-utils` nodig. Daarna staat de editor in **Openen met**. Herhaal dit na verplaatsen van de AppImage.25
43
26
- **Laad testlichamen** zet de man en de vrouw uit de fixture in de view.44
Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.27
- **3D van project** gebruikt de `.glb`- of `.gltf`-paden van man en vrouw.28
- **Man** en **Vrouw** wisselen het lichaam. **walk** en **attack** wisselen de clip. **Pauze** / **Speel** zet de animatie stil of laat haar lopen.29
- Sleep in het vlak om te draaien. Scroll om te zoomen.30
- **Yaw −** en **Yaw +** stappen langs twaalf hoeken van 30°. **pitch -30**, **pitch 0** en **pitch 30** zetten de drie hoogtes. Yaw 0 kijkt op de voorkant; de testlichamen wijzen naar −Z.31
45
32
## Tests46
## Tests33
47
34
```bash48
```bash35
cargo fmt --check49
cargo fmt --check50
cargo check --workspace --all-targets --locked36
cargo clippy --workspace --all-targets --locked -- -D warnings51
cargo clippy --workspace --all-targets --locked -- -D warnings37
cargo test --workspace --locked52
cargo test --workspace --locked38
```53
```39
54
40
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-bestand van de man komt uit `scripts/convert_test_body_to_fbx.py` met Blender.55
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.The contents of this binary file are not shown.
@@ -136,7 +136,7 @@ package:windows:
136
136
- export CARGO_HOME="$PWD/cargo-home"
137
137
- export PATH="$CARGO_HOME/bin:$PATH"
138
138
- apt-get update -qq
139
- apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip
139
- apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip python3
140
140
- rustup target add x86_64-pc-windows-gnu
141
141
- export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
142
142
- export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
@@ -136,7 +136,7 @@ package:windows:136
- export CARGO_HOME="$PWD/cargo-home"136
- export CARGO_HOME="$PWD/cargo-home"137
- export PATH="$CARGO_HOME/bin:$PATH"137
- export PATH="$CARGO_HOME/bin:$PATH"138
- apt-get update -qq138
- apt-get update -qq139
- apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip139
- apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip python3140
- rustup target add x86_64-pc-windows-gnu140
- rustup target add x86_64-pc-windows-gnu141
- export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc141
- export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc142
- export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar142
- export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar@@ -0,0 +1,13 @@
1
fn main() {
2
println!("cargo:rerun-if-changed=build.rs");
3
println!("cargo:rerun-if-changed=packaging/windows/paperdoll-renderer.ico");
4
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
5
if target_os != "windows" {
6
return;
7
}
8
let mut resources = winresource::WindowsResource::new();
9
resources.set_icon("packaging/windows/paperdoll-renderer.ico");
10
if let Err(err) = resources.compile() {
11
panic!("the Windows executable icon could not be embedded: {err}");
12
}
13
}
@@ -0,0 +1,13 @@1
fn main() {2
println!("cargo:rerun-if-changed=build.rs");3
println!("cargo:rerun-if-changed=packaging/windows/paperdoll-renderer.ico");4
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();5
if target_os != "windows" {6
return;7
}8
let mut resources = winresource::WindowsResource::new();9
resources.set_icon("packaging/windows/paperdoll-renderer.ico");10
if let Err(err) = resources.compile() {11
panic!("the Windows executable icon could not be embedded: {err}");12
}13
}@@ -159,7 +159,9 @@ Each feature starts on its own `feature/...` branch from current master. Impleme
159
159
160
160
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.
161
161
162
Live 3D preview, as built: the center of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
162
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
163
164
Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
163
165
164
166
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
165
167
@@ -171,6 +173,19 @@ Release publication uses documented BlueGit release/artifact API and job-scoped
171
173
172
174
Verified BlueGit behaviour, inspected 6 November 2026, is recorded in section 15. The configured tag pipeline calls that API. With the API as it exists today, publishing the same tag that started the pipeline is rejected. That limit is part of the design record, not a claim that a release was created.
173
175
176
## 11a. Tim’s desktop workflow requirements (6 November 2026)
177
178
These requirements are durable even where the implementation is staged. They supersede the earlier generic workspace layout.
179
180
- Launch without a project shows a small start window with **Project laden** and **Nieuw project**. Both invoke an OS-native picker: choose an existing `.ppd` file or a new project directory. Cancellation keeps the current project and edits unchanged. A new project prepares the CC0 male/female fixtures in `assets/body/`; existing assets are never silently overwritten.
181
- The first project view is **Bodies**, with male/female model slots and native asset-pick buttons. Navigation is **Bodies → body → Equipment → equipment configuration**. A body has the live orbit/zoom mesh preview, animation controls, a **Render** action and an **Equipment** action. Equipment list entries open their configuration; **Nieuw** creates a local definition. Keep body context when going back. Remove the old right-hand folder explorer entirely.
182
- Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.
183
- `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.
184
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
185
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor** to fill the preview box, then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
186
187
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
188
174
189
## 12. Incremental feature plan and acceptance gates
175
190
176
191
0. Foundation/design/CI: group and repo existence checks; private repo; single detailed design.md with verbatim appendix; minimal honest compileable Rust shell; locked dependencies/toolchain; genuine two-parent setup merge; tests and verified CI schema. No claim that rendering/import works. Test packaging scripts locally where possible without publishing.
@@ -159,7 +159,9 @@ Each feature starts on its own `feature/...` branch from current master. Impleme159
159
160
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.160
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.161
161
162
Live 3D preview, as built: the center of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.162
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.163
164
Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.163
165
164
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.166
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.165
167
@@ -171,6 +173,19 @@ Release publication uses documented BlueGit release/artifact API and job-scoped171
173
172
Verified BlueGit behaviour, inspected 6 November 2026, is recorded in section 15. The configured tag pipeline calls that API. With the API as it exists today, publishing the same tag that started the pipeline is rejected. That limit is part of the design record, not a claim that a release was created.174
Verified BlueGit behaviour, inspected 6 November 2026, is recorded in section 15. The configured tag pipeline calls that API. With the API as it exists today, publishing the same tag that started the pipeline is rejected. That limit is part of the design record, not a claim that a release was created.173
175
176
## 11a. Tim’s desktop workflow requirements (6 November 2026)177
178
These requirements are durable even where the implementation is staged. They supersede the earlier generic workspace layout.179
180
- Launch without a project shows a small start window with **Project laden** and **Nieuw project**. Both invoke an OS-native picker: choose an existing `.ppd` file or a new project directory. Cancellation keeps the current project and edits unchanged. A new project prepares the CC0 male/female fixtures in `assets/body/`; existing assets are never silently overwritten.181
- The first project view is **Bodies**, with male/female model slots and native asset-pick buttons. Navigation is **Bodies → body → Equipment → equipment configuration**. A body has the live orbit/zoom mesh preview, animation controls, a **Render** action and an **Equipment** action. Equipment list entries open their configuration; **Nieuw** creates a local definition. Keep body context when going back. Remove the old right-hand folder explorer entirely.182
- Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.183
- `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.184
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.185
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor** to fill the preview box, then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.186
187
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.188
174
## 12. Incremental feature plan and acceptance gates189
## 12. Incremental feature plan and acceptance gates175
190
176
0. Foundation/design/CI: group and repo existence checks; private repo; single detailed design.md with verbatim appendix; minimal honest compileable Rust shell; locked dependencies/toolchain; genuine two-parent setup merge; tests and verified CI schema. No claim that rendering/import works. Test packaging scripts locally where possible without publishing.191
0. Foundation/design/CI: group and repo existence checks; private repo; single detailed design.md with verbatim appendix; minimal honest compileable Rust shell; locked dependencies/toolchain; genuine two-parent setup merge; tests and verified CI schema. No claim that rendering/import works. Test packaging scripts locally where possible without publishing.The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -3,11 +3,11 @@
3
3
pkgname=paperdoll-renderer
4
4
pkgver=0.1.0
5
5
pkgrel=1
6
pkgdesc='Foundation shell for the Paperdoll Renderer. Import and render are not included.'
6
pkgdesc='Paperdoll Renderer with project files, model import and a live glTF preview.'
7
7
arch=('x86_64')
8
8
url='https://git.timfalken.com/golden-age/paperdoll-renderer'
9
9
license=('MIT')
10
depends=('gcc-libs' 'glibc' 'libxcb' 'libx11' 'libxkbcommon' 'libglvnd')
10
depends=('gcc-libs' 'glibc' 'libxcb' 'libx11' 'libxkbcommon' 'libxkbcommon-x11' 'libglvnd' 'shared-mime-info' 'desktop-file-utils' 'xdg-desktop-portal' 'zenity')
11
11
makedepends=('cargo' 'pkgconf')
12
12
options=('!debug' '!lto')
13
13
@@ -26,7 +26,17 @@ package() {
26
26
install -Dm755 "$REPO_ROOT/target/release/paperdoll-renderer" "$pkgdir/usr/bin/paperdoll-renderer"
27
27
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.desktop" \
28
28
"$pkgdir/usr/share/applications/paperdoll-renderer.desktop"
29
install -Dm644 "$REPO_ROOT/packaging/linux/icon-64.png" \
30
"$pkgdir/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"
29
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.metainfo.xml" \
30
"$pkgdir/usr/share/metainfo/paperdoll-renderer.metainfo.xml"
31
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.metainfo.xml" \
32
"$pkgdir/usr/share/metainfo/paperdoll-renderer.appdata.xml"
33
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-project.xml" \
34
"$pkgdir/usr/share/mime/packages/paperdoll-project.xml"
35
local size
36
for size in 16 22 24 32 48 64 128 256 512; do
37
install -Dm644 \
38
"$REPO_ROOT/packaging/linux/hicolor/${size}x${size}/apps/paperdoll-renderer.png" \
39
"$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/paperdoll-renderer.png"
40
done
31
41
install -Dm644 "$REPO_ROOT/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
32
42
}
@@ -3,11 +3,11 @@3
pkgname=paperdoll-renderer3
pkgname=paperdoll-renderer4
pkgver=0.1.04
pkgver=0.1.05
pkgrel=15
pkgrel=16
pkgdesc='Foundation shell for the Paperdoll Renderer. Import and render are not included.'6
pkgdesc='Paperdoll Renderer with project files, model import and a live glTF preview.'7
arch=('x86_64')7
arch=('x86_64')8
url='https://git.timfalken.com/golden-age/paperdoll-renderer'8
url='https://git.timfalken.com/golden-age/paperdoll-renderer'9
license=('MIT')9
license=('MIT')10
depends=('gcc-libs' 'glibc' 'libxcb' 'libx11' 'libxkbcommon' 'libglvnd')10
depends=('gcc-libs' 'glibc' 'libxcb' 'libx11' 'libxkbcommon' 'libxkbcommon-x11' 'libglvnd' 'shared-mime-info' 'desktop-file-utils' 'xdg-desktop-portal' 'zenity')11
makedepends=('cargo' 'pkgconf')11
makedepends=('cargo' 'pkgconf')12
options=('!debug' '!lto')12
options=('!debug' '!lto')13
13
@@ -26,7 +26,17 @@ package() {26
install -Dm755 "$REPO_ROOT/target/release/paperdoll-renderer" "$pkgdir/usr/bin/paperdoll-renderer"26
install -Dm755 "$REPO_ROOT/target/release/paperdoll-renderer" "$pkgdir/usr/bin/paperdoll-renderer"27
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.desktop" \27
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.desktop" \28
"$pkgdir/usr/share/applications/paperdoll-renderer.desktop"28
"$pkgdir/usr/share/applications/paperdoll-renderer.desktop"29
install -Dm644 "$REPO_ROOT/packaging/linux/icon-64.png" \29
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.metainfo.xml" \30
"$pkgdir/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"30
"$pkgdir/usr/share/metainfo/paperdoll-renderer.metainfo.xml"31
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-renderer.metainfo.xml" \32
"$pkgdir/usr/share/metainfo/paperdoll-renderer.appdata.xml"33
install -Dm644 "$REPO_ROOT/packaging/linux/paperdoll-project.xml" \34
"$pkgdir/usr/share/mime/packages/paperdoll-project.xml"35
local size36
for size in 16 22 24 32 48 64 128 256 512; do37
install -Dm644 \38
"$REPO_ROOT/packaging/linux/hicolor/${size}x${size}/apps/paperdoll-renderer.png" \39
"$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/paperdoll-renderer.png"40
done31
install -Dm644 "$REPO_ROOT/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"41
install -Dm644 "$REPO_ROOT/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"32
}42
}The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -0,0 +1,7 @@
1
<?xml version="1.0" encoding="UTF-8"?>
2
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
3
<mime-type type="application/x-paperdoll-project">
4
<comment>Paperdoll project</comment>
5
<glob pattern="*.ppd" weight="60"/>
6
</mime-type>
7
</mime-info>
@@ -0,0 +1,7 @@1
<?xml version="1.0" encoding="UTF-8"?>2
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">3
<mime-type type="application/x-paperdoll-project">4
<comment>Paperdoll project</comment>5
<glob pattern="*.ppd" weight="60"/>6
</mime-type>7
</mime-info>@@ -1,10 +1,11 @@
1
1
[Desktop Entry]
2
2
Type=Application
3
3
Name=Paperdoll Renderer
4
GenericName=Paperdoll foundation shell
5
Comment=Statusvenster van de Paperdoll Renderer-projectbasis. Import en render zijn niet gebouwd.
6
Exec=paperdoll-renderer
4
GenericName=Paperdoll editor
5
Comment=Project, modelimport en een live 3D-preview van een glTF-lichaam.
6
Exec=paperdoll-renderer %f
7
7
Icon=paperdoll-renderer
8
8
Terminal=false
9
9
Categories=Graphics;
10
10
StartupNotify=true
11
MimeType=application/x-paperdoll-project;
@@ -1,10 +1,11 @@1
[Desktop Entry]1
[Desktop Entry]2
Type=Application2
Type=Application3
Name=Paperdoll Renderer3
Name=Paperdoll Renderer4
GenericName=Paperdoll foundation shell4
GenericName=Paperdoll editor5
Comment=Statusvenster van de Paperdoll Renderer-projectbasis. Import en render zijn niet gebouwd.5
Comment=Project, modelimport en een live 3D-preview van een glTF-lichaam.6
Exec=paperdoll-renderer6
Exec=paperdoll-renderer %f7
Icon=paperdoll-renderer7
Icon=paperdoll-renderer8
Terminal=false8
Terminal=false9
Categories=Graphics;9
Categories=Graphics;10
StartupNotify=true10
StartupNotify=true11
MimeType=application/x-paperdoll-project;@@ -0,0 +1,16 @@
1
<?xml version="1.0" encoding="UTF-8"?>
2
<component type="desktop-application">
3
<id>paperdoll-renderer</id>
4
<name>Paperdoll Renderer</name>
5
<summary>Project, modelimport en live 3D-preview</summary>
6
<metadata_license>MIT</metadata_license>
7
<project_license>MIT</project_license>
8
<description>
9
<p>Bewaar een paperdoll-project, lees glTF, FBX en OBJ, en bekijk een geskind glTF-lichaam.</p>
10
</description>
11
<launchable type="desktop-id">paperdoll-renderer.desktop</launchable>
12
<icon type="stock">paperdoll-renderer</icon>
13
<categories>
14
<category>Graphics</category>
15
</categories>
16
</component>
@@ -0,0 +1,16 @@1
<?xml version="1.0" encoding="UTF-8"?>2
<component type="desktop-application">3
<id>paperdoll-renderer</id>4
<name>Paperdoll Renderer</name>5
<summary>Project, modelimport en live 3D-preview</summary>6
<metadata_license>MIT</metadata_license>7
<project_license>MIT</project_license>8
<description>9
<p>Bewaar een paperdoll-project, lees glTF, FBX en OBJ, en bekijk een geskind glTF-lichaam.</p>10
</description>11
<launchable type="desktop-id">paperdoll-renderer.desktop</launchable>12
<icon type="stock">paperdoll-renderer</icon>13
<categories>14
<category>Graphics</category>15
</categories>16
</component>The contents of this binary file are not shown.
@@ -65,6 +65,9 @@ if [[ "$(magic_prefix "$windows" 4)" == "7f454c46" ]]; then
65
65
echo "Windows zip starts with an ELF header" >&2
66
66
exit 1
67
67
fi
68
if command -v python3 >/dev/null 2>&1; then
69
python3 scripts/package/pe_has_icon.py "$windows"
70
fi
68
71
69
72
sha256sum "$appimage" "$deb" "$arch" "$windows" | tee dist/SHA256SUMS
70
73
echo "package artifacts verified"
@@ -65,6 +65,9 @@ if [[ "$(magic_prefix "$windows" 4)" == "7f454c46" ]]; then65
echo "Windows zip starts with an ELF header" >&265
echo "Windows zip starts with an ELF header" >&266
exit 166
exit 167
fi67
fi68
if command -v python3 >/dev/null 2>&1; then69
python3 scripts/package/pe_has_icon.py "$windows"70
fi68
71
69
sha256sum "$appimage" "$deb" "$arch" "$windows" | tee dist/SHA256SUMS72
sha256sum "$appimage" "$deb" "$arch" "$windows" | tee dist/SHA256SUMS70
echo "package artifacts verified"73
echo "package artifacts verified"@@ -1,5 +1,5 @@
1
1
#!/usr/bin/env bash
2
# Build a Linux x86_64 AppImage of the foundation shell.
2
# Build a Linux x86_64 AppImage.
3
3
# appimagetool 1.9.1 is pinned. The checksum is of that GitHub release asset.
4
4
set -euo pipefail
5
5
@@ -22,16 +22,21 @@ if [[ ! -f "$tool" ]] || [[ "$(sha256sum "$tool" | awk '{print $1}')" != "$expec
22
22
fi
23
23
24
24
stage="$(mktemp -d)"
25
chmod 755 "$stage"
25
26
trap 'rm -rf "$stage"' EXIT
26
27
mkdir -p \
27
28
"$stage/usr/bin" \
28
29
"$stage/usr/share/applications" \
29
"$stage/usr/share/icons/hicolor/64x64/apps"
30
"$stage/usr/share/metainfo"
30
31
install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"
31
32
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/paperdoll-renderer.desktop"
32
33
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"
33
install -m 644 "$root/packaging/linux/icon-64.png" "$stage/paperdoll-renderer.png"
34
install -m 644 "$root/packaging/linux/icon-64.png" "$stage/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"
34
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.metainfo.xml"
35
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.appdata.xml"
36
install -m 644 "$root/packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png" "$stage/paperdoll-renderer.png"
37
install -m 644 "$root/packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png" "$stage/.DirIcon"
38
install -Dm644 "$root/packaging/linux/paperdoll-project.xml" "$stage/usr/share/mime/packages/paperdoll-project.xml"
39
install_hicolor_icons "$root" "$stage/usr/share/icons/hicolor"
35
40
install -m 644 "$root/LICENSE" "$stage/LICENSE"
36
41
cat > "$stage/AppRun" << 'EOF'
37
42
#!/bin/sh
@@ -1,5 +1,5 @@1
#!/usr/bin/env bash1
#!/usr/bin/env bash2
# Build a Linux x86_64 AppImage of the foundation shell.2
# Build a Linux x86_64 AppImage.3
# appimagetool 1.9.1 is pinned. The checksum is of that GitHub release asset.3
# appimagetool 1.9.1 is pinned. The checksum is of that GitHub release asset.4
set -euo pipefail4
set -euo pipefail5
5
@@ -22,16 +22,21 @@ if [[ ! -f "$tool" ]] || [[ "$(sha256sum "$tool" | awk '{print $1}')" != "$expec22
fi22
fi23
23
24
stage="$(mktemp -d)"24
stage="$(mktemp -d)"25
chmod 755 "$stage"25
trap 'rm -rf "$stage"' EXIT26
trap 'rm -rf "$stage"' EXIT26
mkdir -p \27
mkdir -p \27
"$stage/usr/bin" \28
"$stage/usr/bin" \28
"$stage/usr/share/applications" \29
"$stage/usr/share/applications" \29
"$stage/usr/share/icons/hicolor/64x64/apps"30
"$stage/usr/share/metainfo"30
install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"31
install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"31
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/paperdoll-renderer.desktop"32
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/paperdoll-renderer.desktop"32
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"33
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"33
install -m 644 "$root/packaging/linux/icon-64.png" "$stage/paperdoll-renderer.png"34
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.metainfo.xml"34
install -m 644 "$root/packaging/linux/icon-64.png" "$stage/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"35
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.appdata.xml"36
install -m 644 "$root/packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png" "$stage/paperdoll-renderer.png"37
install -m 644 "$root/packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png" "$stage/.DirIcon"38
install -Dm644 "$root/packaging/linux/paperdoll-project.xml" "$stage/usr/share/mime/packages/paperdoll-project.xml"39
install_hicolor_icons "$root" "$stage/usr/share/icons/hicolor"35
install -m 644 "$root/LICENSE" "$stage/LICENSE"40
install -m 644 "$root/LICENSE" "$stage/LICENSE"36
cat > "$stage/AppRun" << 'EOF'41
cat > "$stage/AppRun" << 'EOF'37
#!/bin/sh42
#!/bin/sh@@ -7,3 +7,14 @@ build_linux_release() {
7
7
export PATH="$CARGO_HOME/bin:$PATH"
8
8
cargo build --release --locked --manifest-path "$root/Cargo.toml" --target-dir "$root/target"
9
9
}
10
11
install_hicolor_icons() {
12
local root="$1"
13
local dest="$2"
14
local size
15
for size in 16 22 24 32 48 64 128 256 512; do
16
install -Dm644 \
17
"$root/packaging/linux/hicolor/${size}x${size}/apps/paperdoll-renderer.png" \
18
"$dest/${size}x${size}/apps/paperdoll-renderer.png"
19
done
20
}
@@ -7,3 +7,14 @@ build_linux_release() {7
export PATH="$CARGO_HOME/bin:$PATH"7
export PATH="$CARGO_HOME/bin:$PATH"8
cargo build --release --locked --manifest-path "$root/Cargo.toml" --target-dir "$root/target"8
cargo build --release --locked --manifest-path "$root/Cargo.toml" --target-dir "$root/target"9
}9
}10
11
install_hicolor_icons() {12
local root="$1"13
local dest="$2"14
local size15
for size in 16 22 24 32 48 64 128 256 512; do16
install -Dm644 \17
"$root/packaging/linux/hicolor/${size}x${size}/apps/paperdoll-renderer.png" \18
"$dest/${size}x${size}/apps/paperdoll-renderer.png"19
done20
}@@ -1,5 +1,5 @@
1
1
#!/usr/bin/env bash
2
# Build a Debian/Ubuntu package of the foundation shell.
2
# Build a Debian/Ubuntu package.
3
3
set -euo pipefail
4
4
5
5
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -9,18 +9,22 @@ version="$(bash "$root/scripts/ci/package-version.sh")"
9
9
build_linux_release "$root"
10
10
11
11
stage="$(mktemp -d)"
12
chmod 755 "$stage"
12
13
trap 'rm -rf "$stage"' EXIT
13
14
mkdir -p \
14
15
"$stage/DEBIAN" \
15
16
"$stage/usr/bin" \
16
17
"$stage/usr/share/applications" \
17
"$stage/usr/share/icons/hicolor/64x64/apps" \
18
"$stage/usr/share/metainfo" \
18
19
"$stage/usr/share/doc/paperdoll-renderer" \
19
20
"$stage/usr/share/licenses/paperdoll-renderer"
20
21
21
22
install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"
22
23
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"
23
install -m 644 "$root/packaging/linux/icon-64.png" "$stage/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"
24
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.metainfo.xml"
25
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.appdata.xml"
26
install -Dm644 "$root/packaging/linux/paperdoll-project.xml" "$stage/usr/share/mime/packages/paperdoll-project.xml"
27
install_hicolor_icons "$root" "$stage/usr/share/icons/hicolor"
24
28
install -m 644 "$root/LICENSE" "$stage/usr/share/licenses/paperdoll-renderer/LICENSE"
25
29
install -m 644 "$root/LICENSE" "$stage/usr/share/doc/paperdoll-renderer/copyright"
26
30
@@ -33,12 +37,24 @@ Section: graphics
33
37
Priority: optional
34
38
Architecture: amd64
35
39
Maintainer: Tim Falken
36
Depends: libc6, libgcc-s1, libstdc++6, libxcb1, libx11-6, libxkbcommon0, libxkbcommon-x11-0, libgl1
37
Description: Paperdoll Renderer foundation shell
38
Status window for the Paperdoll Renderer project foundation.
39
Model import and paperdoll rendering are not included.
40
Depends: libc6, libgcc-s1, libstdc++6, libxcb1, libx11-6, libxkbcommon0, libxkbcommon-x11-0, libgl1, shared-mime-info, desktop-file-utils, xdg-desktop-portal, zenity
41
Description: Paperdoll Renderer
42
Project files, glTF/FBX/OBJ import, and a live skinned glTF preview.
40
43
EOF
41
44
45
cat > "$stage/DEBIAN/postinst" << 'POST'
46
#!/bin/sh
47
set -e
48
update-mime-database /usr/share/mime
49
update-desktop-database /usr/share/applications
50
POST
51
cat > "$stage/DEBIAN/postrm" << 'POST'
52
#!/bin/sh
53
set -e
54
if command -v update-mime-database >/dev/null; then update-mime-database /usr/share/mime; fi
55
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/share/applications; fi
56
POST
57
chmod 755 "$stage/DEBIAN/postinst" "$stage/DEBIAN/postrm"
42
58
mkdir -p "$root/dist"
43
59
dpkg-deb --root-owner-group --build "$stage" "$root/dist/paperdoll-renderer_${version}_amd64.deb"
44
60
cp -f "$root/dist/paperdoll-renderer_${version}_amd64.deb" "$root/dist/paperdoll-renderer_amd64.deb"
@@ -1,5 +1,5 @@1
#!/usr/bin/env bash1
#!/usr/bin/env bash2
# Build a Debian/Ubuntu package of the foundation shell.2
# Build a Debian/Ubuntu package.3
set -euo pipefail3
set -euo pipefail4
4
5
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"5
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"@@ -9,18 +9,22 @@ version="$(bash "$root/scripts/ci/package-version.sh")"9
build_linux_release "$root"9
build_linux_release "$root"10
10
11
stage="$(mktemp -d)"11
stage="$(mktemp -d)"12
chmod 755 "$stage"12
trap 'rm -rf "$stage"' EXIT13
trap 'rm -rf "$stage"' EXIT13
mkdir -p \14
mkdir -p \14
"$stage/DEBIAN" \15
"$stage/DEBIAN" \15
"$stage/usr/bin" \16
"$stage/usr/bin" \16
"$stage/usr/share/applications" \17
"$stage/usr/share/applications" \17
"$stage/usr/share/icons/hicolor/64x64/apps" \18
"$stage/usr/share/metainfo" \18
"$stage/usr/share/doc/paperdoll-renderer" \19
"$stage/usr/share/doc/paperdoll-renderer" \19
"$stage/usr/share/licenses/paperdoll-renderer"20
"$stage/usr/share/licenses/paperdoll-renderer"20
21
21
install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"22
install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"22
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"23
install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"23
install -m 644 "$root/packaging/linux/icon-64.png" "$stage/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"24
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.metainfo.xml"25
install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.appdata.xml"26
install -Dm644 "$root/packaging/linux/paperdoll-project.xml" "$stage/usr/share/mime/packages/paperdoll-project.xml"27
install_hicolor_icons "$root" "$stage/usr/share/icons/hicolor"24
install -m 644 "$root/LICENSE" "$stage/usr/share/licenses/paperdoll-renderer/LICENSE"28
install -m 644 "$root/LICENSE" "$stage/usr/share/licenses/paperdoll-renderer/LICENSE"25
install -m 644 "$root/LICENSE" "$stage/usr/share/doc/paperdoll-renderer/copyright"29
install -m 644 "$root/LICENSE" "$stage/usr/share/doc/paperdoll-renderer/copyright"26
30
@@ -33,12 +37,24 @@ Section: graphics33
Priority: optional37
Priority: optional34
Architecture: amd6438
Architecture: amd6435
Maintainer: Tim Falken39
Maintainer: Tim Falken36
Depends: libc6, libgcc-s1, libstdc++6, libxcb1, libx11-6, libxkbcommon0, libxkbcommon-x11-0, libgl140
Depends: libc6, libgcc-s1, libstdc++6, libxcb1, libx11-6, libxkbcommon0, libxkbcommon-x11-0, libgl1, shared-mime-info, desktop-file-utils, xdg-desktop-portal, zenity37
Description: Paperdoll Renderer foundation shell41
Description: Paperdoll Renderer38
Status window for the Paperdoll Renderer project foundation.42
Project files, glTF/FBX/OBJ import, and a live skinned glTF preview.39
Model import and paperdoll rendering are not included.40
EOF43
EOF41
44
45
cat > "$stage/DEBIAN/postinst" << 'POST'46
#!/bin/sh47
set -e48
update-mime-database /usr/share/mime49
update-desktop-database /usr/share/applications50
POST51
cat > "$stage/DEBIAN/postrm" << 'POST'52
#!/bin/sh53
set -e54
if command -v update-mime-database >/dev/null; then update-mime-database /usr/share/mime; fi55
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/share/applications; fi56
POST57
chmod 755 "$stage/DEBIAN/postinst" "$stage/DEBIAN/postrm"42
mkdir -p "$root/dist"58
mkdir -p "$root/dist"43
dpkg-deb --root-owner-group --build "$stage" "$root/dist/paperdoll-renderer_${version}_amd64.deb"59
dpkg-deb --root-owner-group --build "$stage" "$root/dist/paperdoll-renderer_${version}_amd64.deb"44
cp -f "$root/dist/paperdoll-renderer_${version}_amd64.deb" "$root/dist/paperdoll-renderer_amd64.deb"60
cp -f "$root/dist/paperdoll-renderer_${version}_amd64.deb" "$root/dist/paperdoll-renderer_amd64.deb"@@ -0,0 +1,79 @@
1
#!/usr/bin/env python3
2
"""Require RT_ICON and RT_GROUP_ICON inside a PE, not a sibling .ico file."""
3
4
import struct
5
import sys
6
import zipfile
7
8
RT_ICON = 3
9
RT_GROUP_ICON = 14
10
11
12
def pe_has_icon(data: bytes) -> None:
13
if data[:2] != b"MZ":
14
raise SystemExit("executable does not start with MZ")
15
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
16
if data[e_lfanew : e_lfanew + 4] != b"PE\0\0":
17
raise SystemExit("executable has no PE signature")
18
coff = e_lfanew + 4
19
nsections = struct.unpack_from("<H", data, coff + 2)[0]
20
optsize = struct.unpack_from("<H", data, coff + 16)[0]
21
opt = coff + 20
22
magic = struct.unpack_from("<H", data, opt)[0]
23
if magic == 0x20B:
24
dd_off = opt + 112
25
elif magic == 0x10B:
26
dd_off = opt + 96
27
else:
28
raise SystemExit(f"unknown PE magic {magic:#x}")
29
res_rva, res_size = struct.unpack_from("<II", data, dd_off + 8 * 2)
30
if res_rva == 0 or res_size == 0:
31
raise SystemExit("PE has no resource directory")
32
sections = []
33
sec = opt + optsize
34
for index in range(nsections):
35
off = sec + index * 40
36
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)
37
sections.append((va, rawptr, max(rawsize, vsize)))
38
39
def rva_to_off(rva: int) -> int:
40
for va, rawptr, span in sections:
41
if va <= rva < va + span:
42
return rawptr + (rva - va)
43
raise SystemExit(f"resource RVA {rva:#x} is outside the sections")
44
45
off = rva_to_off(res_rva)
46
nnames, nids = struct.unpack_from("<HH", data, off + 12)
47
found = set()
48
base = off + 16
49
for index in range(nnames + nids):
50
name_or_id, nextv = struct.unpack_from("<II", data, base + index * 8)
51
if name_or_id & 0x80000000:
52
continue
53
if nextv & 0x80000000 and name_or_id in (RT_ICON, RT_GROUP_ICON):
54
found.add(name_or_id)
55
missing = {RT_ICON, RT_GROUP_ICON} - found
56
if missing:
57
raise SystemExit(f"PE is missing icon resource types {sorted(missing)}")
58
59
60
def main() -> None:
61
path = sys.argv[1]
62
if path.endswith(".zip"):
63
with zipfile.ZipFile(path) as archive:
64
names = archive.namelist()
65
loose = [name for name in names if name.lower().endswith(".ico")]
66
if loose:
67
raise SystemExit(f"zip carries a loose icon file: {loose}")
68
exe_name = "paperdoll-renderer.exe"
69
if exe_name not in names:
70
raise SystemExit("zip has no paperdoll-renderer.exe")
71
pe_has_icon(archive.read(exe_name))
72
else:
73
with open(path, "rb") as handle:
74
pe_has_icon(handle.read())
75
print(f"embedded icon ok: {path}")
76
77
78
if __name__ == "__main__":
79
main()
@@ -0,0 +1,79 @@1
#!/usr/bin/env python32
"""Require RT_ICON and RT_GROUP_ICON inside a PE, not a sibling .ico file."""3
4
import struct5
import sys6
import zipfile7
8
RT_ICON = 39
RT_GROUP_ICON = 1410
11
12
def pe_has_icon(data: bytes) -> None:13
if data[:2] != b"MZ":14
raise SystemExit("executable does not start with MZ")15
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]16
if data[e_lfanew : e_lfanew + 4] != b"PE\0\0":17
raise SystemExit("executable has no PE signature")18
coff = e_lfanew + 419
nsections = struct.unpack_from("<H", data, coff + 2)[0]20
optsize = struct.unpack_from("<H", data, coff + 16)[0]21
opt = coff + 2022
magic = struct.unpack_from("<H", data, opt)[0]23
if magic == 0x20B:24
dd_off = opt + 11225
elif magic == 0x10B:26
dd_off = opt + 9627
else:28
raise SystemExit(f"unknown PE magic {magic:#x}")29
res_rva, res_size = struct.unpack_from("<II", data, dd_off + 8 * 2)30
if res_rva == 0 or res_size == 0:31
raise SystemExit("PE has no resource directory")32
sections = []33
sec = opt + optsize34
for index in range(nsections):35
off = sec + index * 4036
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)37
sections.append((va, rawptr, max(rawsize, vsize)))38
39
def rva_to_off(rva: int) -> int:40
for va, rawptr, span in sections:41
if va <= rva < va + span:42
return rawptr + (rva - va)43
raise SystemExit(f"resource RVA {rva:#x} is outside the sections")44
45
off = rva_to_off(res_rva)46
nnames, nids = struct.unpack_from("<HH", data, off + 12)47
found = set()48
base = off + 1649
for index in range(nnames + nids):50
name_or_id, nextv = struct.unpack_from("<II", data, base + index * 8)51
if name_or_id & 0x80000000:52
continue53
if nextv & 0x80000000 and name_or_id in (RT_ICON, RT_GROUP_ICON):54
found.add(name_or_id)55
missing = {RT_ICON, RT_GROUP_ICON} - found56
if missing:57
raise SystemExit(f"PE is missing icon resource types {sorted(missing)}")58
59
60
def main() -> None:61
path = sys.argv[1]62
if path.endswith(".zip"):63
with zipfile.ZipFile(path) as archive:64
names = archive.namelist()65
loose = [name for name in names if name.lower().endswith(".ico")]66
if loose:67
raise SystemExit(f"zip carries a loose icon file: {loose}")68
exe_name = "paperdoll-renderer.exe"69
if exe_name not in names:70
raise SystemExit("zip has no paperdoll-renderer.exe")71
pe_has_icon(archive.read(exe_name))72
else:73
with open(path, "rb") as handle:74
pe_has_icon(handle.read())75
print(f"embedded icon ok: {path}")76
77
78
if __name__ == "__main__":79
main()@@ -1,5 +1,5 @@
1
1
#!/usr/bin/env bash
2
# Cross-compile the foundation shell for Windows x86_64 with MinGW.
2
# Cross-compile for Windows x86_64 with MinGW.
3
3
# posprint uses this route and states that BlueGit has no Windows runner.
4
4
# This script packages the .exe in a zip. It does not run the Windows binary.
5
5
set -euo pipefail
@@ -31,7 +31,8 @@ stage="$(mktemp -d)"
31
31
trap 'rm -rf "$stage"' EXIT
32
32
install -m 644 "$exe" "$stage/paperdoll-renderer.exe"
33
33
install -m 644 "$root/LICENSE" "$stage/LICENSE.txt"
34
printf '%s\n' "Paperdoll Renderer ${version}" "Foundation shell. Import and render are not included." \
34
printf '%s\n' "Paperdoll Renderer ${version}" \
35
"Projectbestanden, modelimport en een live glTF-preview." \
35
36
> "$stage/README.txt"
36
37
37
38
mkdir -p "$root/dist"
@@ -44,4 +45,7 @@ rm -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \
44
45
)
45
46
cp -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \
46
47
"$root/dist/paperdoll-renderer-windows-x86_64.zip"
48
python3 "$root/scripts/package/pe_has_icon.py" "$exe"
49
python3 "$root/scripts/package/pe_has_icon.py" \
50
"$root/dist/paperdoll-renderer-windows-x86_64.zip"
47
51
echo "wrote dist/paperdoll-renderer-windows-x86_64.zip"
@@ -1,5 +1,5 @@1
#!/usr/bin/env bash1
#!/usr/bin/env bash2
# Cross-compile the foundation shell for Windows x86_64 with MinGW.2
# Cross-compile for Windows x86_64 with MinGW.3
# posprint uses this route and states that BlueGit has no Windows runner.3
# posprint uses this route and states that BlueGit has no Windows runner.4
# This script packages the .exe in a zip. It does not run the Windows binary.4
# This script packages the .exe in a zip. It does not run the Windows binary.5
set -euo pipefail5
set -euo pipefail@@ -31,7 +31,8 @@ stage="$(mktemp -d)"31
trap 'rm -rf "$stage"' EXIT31
trap 'rm -rf "$stage"' EXIT32
install -m 644 "$exe" "$stage/paperdoll-renderer.exe"32
install -m 644 "$exe" "$stage/paperdoll-renderer.exe"33
install -m 644 "$root/LICENSE" "$stage/LICENSE.txt"33
install -m 644 "$root/LICENSE" "$stage/LICENSE.txt"34
printf '%s\n' "Paperdoll Renderer ${version}" "Foundation shell. Import and render are not included." \34
printf '%s\n' "Paperdoll Renderer ${version}" \35
"Projectbestanden, modelimport en een live glTF-preview." \35
> "$stage/README.txt"36
> "$stage/README.txt"36
37
37
mkdir -p "$root/dist"38
mkdir -p "$root/dist"@@ -44,4 +45,7 @@ rm -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \44
)45
)45
cp -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \46
cp -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \46
"$root/dist/paperdoll-renderer-windows-x86_64.zip"47
"$root/dist/paperdoll-renderer-windows-x86_64.zip"48
python3 "$root/scripts/package/pe_has_icon.py" "$exe"49
python3 "$root/scripts/package/pe_has_icon.py" \50
"$root/dist/paperdoll-renderer-windows-x86_64.zip"47
echo "wrote dist/paperdoll-renderer-windows-x86_64.zip"51
echo "wrote dist/paperdoll-renderer-windows-x86_64.zip"@@ -0,0 +1,162 @@
1
//! One place starts file dialogs and collects the result.
2
//!
3
//! GTK dialogs block on the UI thread, so `poll` runs them there. A backend
4
//! that returns before the choice is ready reports [`DialogPoll::Pending`]
5
//! and the editor keeps asking.
6
7
use std::path::{Path, PathBuf};
8
9
#[derive(Debug, Clone, PartialEq, Eq)]
10
pub enum DialogPurpose {
11
NewProject,
12
LoadProject,
13
Body(crate::session::Sex),
14
EquipmentModel(usize),
15
}
16
17
#[derive(Debug, Clone, PartialEq, Eq)]
18
pub enum DialogPoll {
19
Idle,
20
Pending,
21
Ready {
22
purpose: DialogPurpose,
23
path: Option<PathBuf>,
24
},
25
}
26
27
pub trait Dialogs {
28
fn start(&mut self, purpose: DialogPurpose);
29
fn poll(&mut self) -> DialogPoll;
30
}
31
32
/// Remembers one answer per request. Tests use this instead of a window.
33
#[derive(Debug, Default)]
34
pub struct ScriptedDialogs {
35
queued: Option<DialogPurpose>,
36
answers: Vec<Option<PathBuf>>,
37
}
38
39
impl ScriptedDialogs {
40
pub fn push(&mut self, path: Option<PathBuf>) {
41
self.answers.push(path);
42
}
43
}
44
45
impl Dialogs for ScriptedDialogs {
46
fn start(&mut self, purpose: DialogPurpose) {
47
self.queued = Some(purpose);
48
}
49
50
fn poll(&mut self) -> DialogPoll {
51
let Some(purpose) = self.queued.take() else {
52
return DialogPoll::Idle;
53
};
54
let path = if self.answers.is_empty() {
55
None
56
} else {
57
self.answers.remove(0)
58
};
59
DialogPoll::Ready { purpose, path }
60
}
61
}
62
63
pub struct SystemDialogs {
64
queued: Option<DialogPurpose>,
65
pending: Option<std::sync::mpsc::Receiver<(DialogPurpose, Option<PathBuf>)>>,
66
}
67
68
impl SystemDialogs {
69
pub fn new() -> Self {
70
Self {
71
queued: None,
72
pending: None,
73
}
74
}
75
}
76
77
impl Default for SystemDialogs {
78
fn default() -> Self {
79
Self::new()
80
}
81
}
82
83
impl Dialogs for SystemDialogs {
84
fn start(&mut self, purpose: DialogPurpose) {
85
if self.pending.is_none() && self.queued.is_none() {
86
self.queued = Some(purpose);
87
}
88
}
89
90
fn poll(&mut self) -> DialogPoll {
91
if let Some(purpose) = self.queued.take() {
92
let (tx, rx) = std::sync::mpsc::channel();
93
std::thread::spawn(move || {
94
let path = native_pick(&purpose);
95
let _ = tx.send((purpose, path));
96
});
97
self.pending = Some(rx);
98
}
99
if let Some(rx) = &self.pending {
100
match rx.try_recv() {
101
Ok((purpose, path)) => {
102
self.pending = None;
103
DialogPoll::Ready { purpose, path }
104
}
105
Err(std::sync::mpsc::TryRecvError::Empty) => DialogPoll::Pending,
106
Err(_) => {
107
self.pending = None;
108
DialogPoll::Idle
109
}
110
}
111
} else {
112
DialogPoll::Idle
113
}
114
}
115
}
116
117
fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
118
let dialog = rfd::FileDialog::new();
119
match purpose {
120
DialogPurpose::NewProject => dialog.set_title("Nieuwe projectmap").pick_folder(),
121
DialogPurpose::LoadProject => dialog
122
.set_title("Project laden")
123
.add_filter("Paperdoll-project", &["ppd"])
124
.pick_file(),
125
DialogPurpose::Body(_) => dialog
126
.set_title("Lichaamsmodel")
127
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])
128
.pick_file(),
129
DialogPurpose::EquipmentModel(_) => dialog
130
.set_title("Equipmentmodel")
131
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])
132
.pick_file(),
133
}
134
}
135
136
pub fn load_candidate(path: &Path) -> PathBuf {
137
if path.is_dir() {
138
path.join(crate::project::MANIFEST_NAME)
139
} else {
140
path.to_path_buf()
141
}
142
}
143
144
#[cfg(test)]
145
mod tests {
146
use super::*;
147
148
#[test]
149
fn scripted_dialogs_return_the_queued_path_from_poll() {
150
let mut dialogs = ScriptedDialogs::default();
151
dialogs.push(Some(PathBuf::from("/tmp/project")));
152
assert!(matches!(dialogs.poll(), DialogPoll::Idle));
153
dialogs.start(DialogPurpose::NewProject);
154
match dialogs.poll() {
155
DialogPoll::Ready { purpose, path } => {
156
assert_eq!(purpose, DialogPurpose::NewProject);
157
assert_eq!(path, Some(PathBuf::from("/tmp/project")));
158
}
159
other => panic!("expected a ready dialog, got {other:?}"),
160
}
161
}
162
}
@@ -0,0 +1,162 @@1
//! One place starts file dialogs and collects the result.2
//!3
//! GTK dialogs block on the UI thread, so `poll` runs them there. A backend4
//! that returns before the choice is ready reports [`DialogPoll::Pending`]5
//! and the editor keeps asking.6
7
use std::path::{Path, PathBuf};8
9
#[derive(Debug, Clone, PartialEq, Eq)]10
pub enum DialogPurpose {11
NewProject,12
LoadProject,13
Body(crate::session::Sex),14
EquipmentModel(usize),15
}16
17
#[derive(Debug, Clone, PartialEq, Eq)]18
pub enum DialogPoll {19
Idle,20
Pending,21
Ready {22
purpose: DialogPurpose,23
path: Option<PathBuf>,24
},25
}26
27
pub trait Dialogs {28
fn start(&mut self, purpose: DialogPurpose);29
fn poll(&mut self) -> DialogPoll;30
}31
32
/// Remembers one answer per request. Tests use this instead of a window.33
#[derive(Debug, Default)]34
pub struct ScriptedDialogs {35
queued: Option<DialogPurpose>,36
answers: Vec<Option<PathBuf>>,37
}38
39
impl ScriptedDialogs {40
pub fn push(&mut self, path: Option<PathBuf>) {41
self.answers.push(path);42
}43
}44
45
impl Dialogs for ScriptedDialogs {46
fn start(&mut self, purpose: DialogPurpose) {47
self.queued = Some(purpose);48
}49
50
fn poll(&mut self) -> DialogPoll {51
let Some(purpose) = self.queued.take() else {52
return DialogPoll::Idle;53
};54
let path = if self.answers.is_empty() {55
None56
} else {57
self.answers.remove(0)58
};59
DialogPoll::Ready { purpose, path }60
}61
}62
63
pub struct SystemDialogs {64
queued: Option<DialogPurpose>,65
pending: Option<std::sync::mpsc::Receiver<(DialogPurpose, Option<PathBuf>)>>,66
}67
68
impl SystemDialogs {69
pub fn new() -> Self {70
Self {71
queued: None,72
pending: None,73
}74
}75
}76
77
impl Default for SystemDialogs {78
fn default() -> Self {79
Self::new()80
}81
}82
83
impl Dialogs for SystemDialogs {84
fn start(&mut self, purpose: DialogPurpose) {85
if self.pending.is_none() && self.queued.is_none() {86
self.queued = Some(purpose);87
}88
}89
90
fn poll(&mut self) -> DialogPoll {91
if let Some(purpose) = self.queued.take() {92
let (tx, rx) = std::sync::mpsc::channel();93
std::thread::spawn(move || {94
let path = native_pick(&purpose);95
let _ = tx.send((purpose, path));96
});97
self.pending = Some(rx);98
}99
if let Some(rx) = &self.pending {100
match rx.try_recv() {101
Ok((purpose, path)) => {102
self.pending = None;103
DialogPoll::Ready { purpose, path }104
}105
Err(std::sync::mpsc::TryRecvError::Empty) => DialogPoll::Pending,106
Err(_) => {107
self.pending = None;108
DialogPoll::Idle109
}110
}111
} else {112
DialogPoll::Idle113
}114
}115
}116
117
fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {118
let dialog = rfd::FileDialog::new();119
match purpose {120
DialogPurpose::NewProject => dialog.set_title("Nieuwe projectmap").pick_folder(),121
DialogPurpose::LoadProject => dialog122
.set_title("Project laden")123
.add_filter("Paperdoll-project", &["ppd"])124
.pick_file(),125
DialogPurpose::Body(_) => dialog126
.set_title("Lichaamsmodel")127
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])128
.pick_file(),129
DialogPurpose::EquipmentModel(_) => dialog130
.set_title("Equipmentmodel")131
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])132
.pick_file(),133
}134
}135
136
pub fn load_candidate(path: &Path) -> PathBuf {137
if path.is_dir() {138
path.join(crate::project::MANIFEST_NAME)139
} else {140
path.to_path_buf()141
}142
}143
144
#[cfg(test)]145
mod tests {146
use super::*;147
148
#[test]149
fn scripted_dialogs_return_the_queued_path_from_poll() {150
let mut dialogs = ScriptedDialogs::default();151
dialogs.push(Some(PathBuf::from("/tmp/project")));152
assert!(matches!(dialogs.poll(), DialogPoll::Idle));153
dialogs.start(DialogPurpose::NewProject);154
match dialogs.poll() {155
DialogPoll::Ready { purpose, path } => {156
assert_eq!(purpose, DialogPurpose::NewProject);157
assert_eq!(path, Some(PathBuf::from("/tmp/project")));158
}159
other => panic!("expected a ready dialog, got {other:?}"),160
}161
}162
}@@ -1,642 +1,798 @@
1
//! Project window. It edits a `.ppd` manifest and does not import or render.
1
//! Project window: start screen, body, equipment, and the render queue.
2
2
3
3
use std::path::PathBuf;
4
use std::sync::mpsc::Receiver;
4
5
5
6
use eframe::egui;
6
7
7
use crate::import::{import_project_model, ImportedModel, ModelFormat};
8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9
use crate::import::{import_project_model, ImportedModel};
8
10
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
9
11
use crate::preview_gl::Viewport;
10
use crate::project::{
11
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
12
ProjectError, FORMAT_VERSION, MANIFEST_NAME,
13
};
12
use crate::project::open_project;
13
use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
14
use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
14
15
15
16
pub struct EditorApp {
16
root_input: String,
17
open_input: String,
18
opened: Option<OpenedProject>,
19
name: String,
20
male: String,
21
female: String,
22
presets: Vec<String>,
23
preset_input: String,
24
output: String,
25
width: String,
26
height: String,
27
fps: String,
28
browser: String,
29
male_import: Option<Result<ImportedModel, String>>,
30
female_import: Option<Result<ImportedModel, String>>,
31
preview_controls: PreviewControls,
17
session: Session,
18
dialogs: Box<dyn Dialogs>,
19
controls: PreviewControls,
32
20
preview_male: Option<PreviewCharacter>,
33
21
preview_female: Option<PreviewCharacter>,
34
22
viewport: Viewport,
35
notice: String,
36
notice_ok: bool,
23
inventories: [Option<Result<ImportedModel, String>>; 2],
24
render_rx: Option<Receiver<RenderEvent>>,
25
render_ui: RenderUiState,
26
applied_shell: Option<[f32; 2]>,
27
width_text: String,
28
height_text: String,
29
fps_text: String,
30
allow_close: bool,
31
dialog_pending: bool,
37
32
}
38
33
39
34
impl EditorApp {
40
pub fn new() -> Self {
35
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
41
36
Self {
42
root_input: String::new(),
43
open_input: String::new(),
44
opened: None,
45
name: "Nieuw project".to_string(),
46
male: String::new(),
47
female: String::new(),
48
presets: Vec::new(),
49
preset_input: String::new(),
50
output: "renders".to_string(),
51
width: "128".to_string(),
52
height: "128".to_string(),
53
fps: "12".to_string(),
54
browser: String::new(),
55
male_import: None,
56
female_import: None,
57
preview_controls: PreviewControls::default(),
37
session: Session::new(),
38
dialogs,
39
controls: PreviewControls::default(),
58
40
preview_male: None,
59
41
preview_female: None,
60
42
viewport: Viewport::new(),
61
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
62
notice_ok: true,
43
inventories: [None, None],
44
render_rx: None,
45
render_ui: RenderUiState::idle(),
46
applied_shell: None,
47
width_text: "128".to_string(),
48
height_text: "128".to_string(),
49
fps_text: "12".to_string(),
50
allow_close: false,
51
dialog_pending: false,
63
52
}
64
53
}
65
54
66
fn load(&mut self, opened: OpenedProject, notice: &str) {
67
self.name = opened.document.name.clone();
68
self.male = opened.document.male_model.clone().unwrap_or_default();
69
self.female = opened.document.female_model.clone().unwrap_or_default();
70
self.presets = opened.document.equipment_presets.clone();
71
self.output = opened.document.output_directory.clone();
72
self.width = opened.document.render.target_width.to_string();
73
self.height = opened.document.render.target_height.to_string();
74
self.fps = opened.document.render.fps.to_string();
75
self.browser.clear();
76
self.male_import = None;
77
self.female_import = None;
78
self.opened = Some(opened.clone());
79
let male = self.male.clone();
80
let female = self.female.clone();
81
if !male.trim().is_empty() {
82
self.inspect(true);
83
}
84
if !female.trim().is_empty() {
85
self.inspect(false);
86
}
87
self.refresh_preview_from_project();
88
if male.trim().is_empty() && female.trim().is_empty() {
89
if opened.disk_issues.is_empty() {
90
self.ok(notice);
91
} else {
92
self.fail_text(opened.disk_issues.join("\n"));
55
pub fn open_path(&mut self, path: PathBuf) {
56
let candidate = std::env::current_dir()
57
.map_err(|e| e.to_string())
58
.and_then(|base| {
59
crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
60
});
61
let path = match candidate {
62
Ok(path) => path,
63
Err(error) => {
64
self.session.fail(error);
65
return;
66
}
67
};
68
match open_project(&path) {
69
Ok(opened) => {
70
self.session.install_loaded(opened);
71
self.reset_render();
72
self.sync_fields();
73
self.reload_bodies();
74
}
75
Err(error) => {
76
self.session.fail(error.to_string());
93
77
}
94
78
}
95
79
}
96
80
97
pub fn load_fixture_bodies(&mut self) {
98
let root =
99
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies");
100
match load_gltf_file(&root.join("test-body-male.glb")) {
101
Ok(character) => self.preview_male = Some(character),
102
Err(error) => self.fail_text(format!("testman: {error}")),
103
}
104
match load_gltf_file(&root.join("test-body-female.glb")) {
105
Ok(character) => self.preview_female = Some(character),
106
Err(error) => self.fail_text(format!("testvrouw: {error}")),
107
}
108
if self.preview_male.is_some() {
109
self.preview_controls.sex = PreviewSex::Male;
110
self.preview_controls.select_clip("walk");
111
self.ok("Testlichamen staan in de 3D-preview.");
112
}
113
}
114
115
fn refresh_preview_from_project(&mut self) {
116
let Some(root) = self.opened.as_ref().and_then(|opened| {
117
opened
118
.manifest_path
119
.parent()
120
.map(std::path::Path::to_path_buf)
121
}) else {
81
pub fn open_fixture_project(&mut self) {
82
let root = std::env::temp_dir().join(format!(
83
"paperdoll-renderer-fixture-{}-{}",
84
std::process::id(),
85
std::time::SystemTime::now()
86
.duration_since(std::time::UNIX_EPOCH)
87
.unwrap_or_default()
88
.as_nanos()
89
));
90
if let Err(error) = self.session.install_new_project(&root) {
91
self.session.fail(error.to_string());
122
92
return;
123
};
124
self.preview_male = load_preview_path(&root, &self.male);
125
self.preview_female = load_preview_path(&root, &self.female);
93
}
94
self.sync_fields();
95
self.reload_bodies();
96
self.session.show_body(Sex::Male);
97
self.controls.sex = PreviewSex::Male;
98
self.controls.select_clip("walk");
126
99
}
127
100
128
fn ok(&mut self, message: &str) {
129
self.notice = message.to_string();
130
self.notice_ok = true;
101
fn reset_render(&mut self) {
102
self.render_rx = None;
103
self.render_ui = RenderUiState::idle();
131
104
}
132
105
133
fn fail(&mut self, error: ProjectError) {
134
self.notice = error.to_string();
135
self.notice_ok = false;
106
fn sync_fields(&mut self) {
107
if let Some(opened) = &self.session.opened {
108
self.width_text = opened.document.render.target_width.to_string();
109
self.height_text = opened.document.render.target_height.to_string();
110
self.fps_text = opened.document.render.fps.to_string();
111
}
136
112
}
137
113
138
fn fail_text(&mut self, message: impl Into<String>) {
139
self.notice = message.into();
140
self.notice_ok = false;
114
fn reload_bodies(&mut self) {
115
self.preview_male = None;
116
self.preview_female = None;
117
self.inventories = [None, None];
118
let Some(root) = self.session.project_root() else {
119
return;
120
};
121
for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
122
if let Some(relative) = self.session.body_path(sex) {
123
let imported =
124
import_project_model(&root, relative).map_err(|error| error.to_string());
125
// Validate the source and its dependencies before the viewport reads it.
126
if imported.is_ok()
127
&& matches!(
128
std::path::Path::new(relative)
129
.extension()
130
.and_then(|e| e.to_str()),
131
Some("gltf" | "glb")
132
)
133
{
134
match load_gltf_file(&root.join(relative)) {
135
Ok(character) => match sex {
136
Sex::Male => self.preview_male = Some(character),
137
Sex::Female => self.preview_female = Some(character),
138
},
139
Err(error) => self.session.fail(format!("3D: {error}")),
140
}
141
}
142
self.inventories[index] = Some(imported);
143
}
144
}
141
145
}
142
146
143
fn draft(&self) -> Result<ProjectDraft, String> {
144
let opened = self
145
.opened
146
.as_ref()
147
.ok_or_else(|| "open of maak eerst een project".to_string())?;
148
Ok(ProjectDraft {
149
id: opened.document.id.clone(),
150
name: self.name.clone(),
151
male_model: self.male.clone(),
152
female_model: self.female.clone(),
153
equipment_presets: self.presets.clone(),
154
output_directory: self.output.clone(),
155
target_width: parse_u32("breedte", &self.width)?,
156
target_height: parse_u32("hoogte", &self.height)?,
157
fps: parse_u32("fps", &self.fps)?,
158
})
147
fn character(&self) -> Option<&PreviewCharacter> {
148
match self.controls.sex {
149
PreviewSex::Male => self.preview_male.as_ref(),
150
PreviewSex::Female => self.preview_female.as_ref(),
151
}
159
152
}
160
153
161
fn create(&mut self) {
162
let root = PathBuf::from(self.root_input.trim());
163
if self.root_input.trim().is_empty() {
164
self.fail_text("vul de projectmap in");
165
return;
166
}
167
match create_project(&root, "Nieuw project") {
168
Ok(opened) => self.load(
169
opened,
170
"Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
171
),
172
Err(error) => self.fail(error),
154
fn poll_dialogs(&mut self) {
155
let poll = self.dialogs.poll();
156
self.dialog_pending = matches!(poll, DialogPoll::Pending);
157
if let DialogPoll::Ready { purpose, path } = poll {
158
let Some(path) = path else {
159
return;
160
};
161
match purpose {
162
DialogPurpose::NewProject => {
163
if let Err(error) = self.session.install_new_project(&path) {
164
self.session.fail(error.to_string());
165
} else {
166
self.reset_render();
167
self.sync_fields();
168
self.reload_bodies();
169
}
170
}
171
DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
172
Ok(opened) => {
173
self.session.install_loaded(opened);
174
self.reset_render();
175
self.sync_fields();
176
self.reload_bodies();
177
}
178
Err(error) => self.session.fail(error.to_string()),
179
},
180
DialogPurpose::Body(sex) => self.take_model(sex, &path),
181
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
182
}
173
183
}
174
184
}
175
185
176
fn open(&mut self) {
177
let path = PathBuf::from(self.open_input.trim());
178
if self.open_input.trim().is_empty() {
179
self.fail_text("vul het pad naar een .ppd-bestand in");
186
fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
187
let Some(root) = self.session.project_root() else {
180
188
return;
181
}
182
match open_project(&path) {
183
Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
184
Err(error) => self.fail(error),
189
};
190
match adopt_file(&root, path, "assets/body") {
191
Ok(relative) => {
192
self.session.set_body_path(sex, relative.clone());
193
self.reload_bodies();
194
self.reset_render();
195
let index = if sex == Sex::Male { 0 } else { 1 };
196
if let Some(Err(error)) = &self.inventories[index] {
197
self.session.fail(format!("{relative}: {error}"));
198
} else {
199
self.session.notice = format!("{relative} staat in het project.");
200
self.session.notice_ok = true;
201
}
202
}
203
Err(error) => self.session.fail(error),
185
204
}
186
205
}
187
206
188
fn save(&mut self) {
189
let Some(current) = self.opened.clone() else {
190
self.fail_text("open of maak eerst een project");
207
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208
let Some(root) = self.session.project_root() else {
191
209
return;
192
210
};
193
let draft = match self.draft() {
194
Ok(draft) => draft,
195
Err(message) => {
196
self.fail_text(message);
197
return;
198
}
199
};
200
let document = match assemble(draft) {
201
Ok(document) => document,
202
Err(error) => {
203
self.fail(error);
204
return;
211
match adopt_file(&root, path, "assets/equipment") {
212
Ok(relative) => {
213
self.session.set_equipment_model(index, relative.clone());
214
self.session.notice = format!("{relative} hoort bij deze equipment.");
215
self.session.notice_ok = true;
205
216
}
206
};
207
let candidate = OpenedProject {
208
manifest_path: current.manifest_path,
209
document,
210
disk_issues: Vec::new(),
211
};
212
match save_project(&candidate) {
213
Ok(()) => {
214
let path = candidate.manifest_path.display().to_string();
215
self.opened = Some(candidate);
216
self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
217
}
218
Err(error) => self.fail(error),
217
Err(error) => self.session.fail(error),
219
218
}
220
219
}
221
220
222
fn add_preset(&mut self, relative: String) {
223
if relative.trim().is_empty() {
224
return;
221
fn begin(&mut self, intent: Intent) {
222
if self.session.gate(intent) {
223
self.run_intent(intent);
225
224
}
226
self.presets.push(relative);
227
if let Err(error) = self
228
.draft()
229
.and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
230
{
231
self.presets.pop();
232
self.fail_text(error);
233
} else {
234
self.preset_input.clear();
235
self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");
225
}
226
227
fn run_intent(&mut self, intent: Intent) {
228
match intent {
229
Intent::Quit => {}
230
Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
231
Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
236
232
}
237
233
}
238
234
239
fn assign_file(&mut self, relative: &str) {
240
if relative == MANIFEST_NAME {
241
self.fail_text("character.ppd is het projectbestand, geen model of preset");
242
return;
235
fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
236
match self.session.resolve(choice) {
237
AfterChoice::Stay => {}
238
AfterChoice::SaveThen(intent) => match self.session.save() {
239
Ok(()) => self.finish_intent(intent, ctx),
240
Err(error) => self.session.fail(error.to_string()),
241
},
242
AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
243
243
}
244
let lower = relative.to_ascii_lowercase();
245
if lower.contains("/preset") || lower.starts_with("presets/") {
246
self.add_preset(relative.to_string());
247
} else if self.male.trim().is_empty() {
248
self.male = relative.to_string();
249
self.inspect(true);
250
} else if self.female.trim().is_empty() {
251
self.female = relative.to_string();
252
self.inspect(false);
244
}
245
246
fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
247
if intent == Intent::Quit {
248
self.allow_close = true;
249
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
253
250
} else {
254
self.add_preset(relative.to_string());
251
self.run_intent(intent);
252
}
253
}
254
255
fn save(&mut self) {
256
if let Err(error) = self.session.save() {
257
self.session.fail(error.to_string());
255
258
}
256
259
}
257
260
258
fn inspect(&mut self, male: bool) {
259
let Some(root) = self.opened.as_ref().and_then(|opened| {
260
opened
261
.manifest_path
262
.parent()
263
.map(std::path::Path::to_path_buf)
264
}) else {
265
self.fail_text("open of maak eerst een project");
261
fn apply_render_fields(&mut self) {
262
let width = self.width_text.parse::<u32>().unwrap_or(0);
263
let height = self.height_text.parse::<u32>().unwrap_or(0);
264
let fps = self.fps_text.parse::<u32>().unwrap_or(0);
265
self.session.set_render(width, height, fps);
266
}
267
268
fn start_render(&mut self, preview: [u32; 2]) {
269
if let Some(opened) = &self.session.opened {
270
if let Err(error) = opened.document.validate_shape() {
271
self.session.fail(error.to_string());
272
return;
273
}
274
}
275
let Some(character) = self.character().cloned() else {
276
self.session
277
.fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
266
278
return;
267
279
};
268
let relative = if male {
269
self.male.trim()
270
} else {
271
self.female.trim()
280
let Some(root) = self.session.project_root() else {
281
return;
272
282
};
273
if relative.is_empty() {
274
if male {
275
self.male_import = None;
276
} else {
277
self.female_import = None;
278
}
279
self.fail_text("dit slot heeft nog geen modelpad");
283
let Some(opened) = &self.session.opened else {
284
return;
285
};
286
if let Err(error) =
287
crate::project::prepare_output_dir(&root, &opened.document.output_directory)
288
{
289
self.session.fail(error.to_string());
280
290
return;
281
291
}
282
match import_project_model(&root, relative) {
283
Ok(model) => {
284
let message = format!(
285
"{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
286
model.meshes.len(),
287
model.joints.len(),
288
model.clips.len()
289
);
290
if male {
291
self.male_import = Some(Ok(model));
292
} else {
293
self.female_import = Some(Ok(model));
292
let sex = match self.controls.sex {
293
PreviewSex::Male => "male",
294
PreviewSex::Female => "female",
295
};
296
let output = root
297
.join(&opened.document.output_directory)
298
.join(format!("body-{sex}-still.png"));
299
let job = RenderJob {
300
character,
301
controls: self.controls.clone(),
302
target_width: opened.document.render.target_width.max(1),
303
target_height: opened.document.render.target_height.max(1),
304
preview_width: preview[0].max(1),
305
preview_height: preview[1].max(1),
306
output,
307
};
308
let thread = spawn(job);
309
self.render_rx = Some(thread.rx);
310
self.render_ui = RenderUiState::idle();
311
self.render_ui.running = true;
312
let _ = thread.handle;
313
}
314
315
fn drain_render(&mut self) {
316
let Some(rx) = &self.render_rx else {
317
return;
318
};
319
loop {
320
let event = match rx.try_recv() {
321
Ok(event) => event,
322
Err(std::sync::mpsc::TryRecvError::Empty) => break,
323
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
324
let message = "De renderworker stopte zonder eindresultaat.".to_string();
325
self.session.fail(message.clone());
326
self.render_ui.apply(RenderEvent::Failed(message));
327
self.render_rx = None;
328
break;
294
329
}
295
self.ok(&message);
330
};
331
let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
332
if let RenderEvent::Finished { saved: Some(path) } = &event {
333
self.session.notice = format!("Standbeeld opgeslagen: {path}");
334
self.session.notice_ok = true;
296
335
}
297
Err(error) => {
298
let text = error.to_string();
299
if male {
300
self.male_import = Some(Err(text.clone()));
301
} else {
302
self.female_import = Some(Err(text.clone()));
303
}
304
self.fail_text(text);
336
if let RenderEvent::Failed(message) = &event {
337
self.session.fail(message.clone());
338
}
339
self.render_ui.apply(event);
340
if finished {
341
self.render_rx = None;
342
break;
305
343
}
306
344
}
307
345
}
308
346
}
309
347
348
impl Default for EditorApp {
349
fn default() -> Self {
350
Self::new(Box::new(SystemDialogs::new()))
351
}
352
}
353
310
354
impl eframe::App for EditorApp {
311
355
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
312
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
313
ui.horizontal_wrapped(|ui| {
314
if ui.button("Nieuw").clicked() {
315
self.create();
316
}
317
if ui.button("Open").clicked() {
318
self.open();
319
}
320
if ui.button("Opslaan").clicked() {
321
self.save();
322
}
323
if ui.button("Sluiten").clicked() {
324
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
325
}
326
ui.label(format!("format_version {FORMAT_VERSION}"));
327
});
328
});
356
let shell = self.session.shell_size();
357
if self.applied_shell != Some(shell) {
358
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
359
shell[0], shell[1],
360
)));
361
let min = if shell == crate::session::START_WINDOW {
362
egui::vec2(420.0, 220.0)
363
} else {
364
egui::vec2(900.0, 560.0)
365
};
366
ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
367
self.applied_shell = Some(shell);
368
}
369
if ctx.input(|input| input.viewport().close_requested())
370
&& !self.allow_close
371
&& self.session.confirm.is_none()
372
{
373
if !self.session.gate(Intent::Quit) {
374
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
375
}
376
} else if self.session.confirm.is_some()
377
&& ctx.input(|input| input.viewport().close_requested())
378
{
379
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
380
}
381
self.poll_dialogs();
382
self.drain_render();
383
if self.render_ui.running {
384
ctx.request_repaint();
385
}
329
386
egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
330
let color = if self.notice_ok {
331
egui::Color32::from_rgb(20, 90, 40)
387
let color = if self.session.notice_ok {
388
egui::Color32::LIGHT_GREEN
332
389
} else {
333
egui::Color32::from_rgb(150, 30, 30)
390
egui::Color32::LIGHT_RED
334
391
};
335
ui.colored_label(color, &self.notice);
392
ui.colored_label(color, &self.session.notice);
336
393
});
337
egui::SidePanel::left("form")
338
.resizable(true)
339
.default_width(380.0)
340
.show(ctx, |ui| {
341
egui::ScrollArea::vertical().show(ui, |ui| self.show_form(ui));
342
});
343
egui::SidePanel::right("browser")
344
.min_width(220.0)
394
match self.session.screen {
395
Screen::Start => self.show_start(ctx),
396
Screen::Bodies => self.show_bodies(ctx),
397
Screen::Body(_) => self.show_body(ctx),
398
Screen::EquipmentList => self.show_equipment_list(ctx),
399
Screen::Equipment(_) => self.show_equipment(ctx),
400
}
401
self.show_confirm(ctx);
402
if self.dialog_pending {
403
ctx.request_repaint_after(std::time::Duration::from_millis(50));
404
}
405
}
406
}
407
408
impl EditorApp {
409
fn show_confirm(&mut self, ctx: &egui::Context) {
410
let Some(_intent) = self.session.confirm else {
411
return;
412
};
413
let mut choice = None;
414
egui::Window::new("Wijzigingen")
415
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
416
.collapsible(false)
417
.resizable(false)
345
418
.show(ctx, |ui| {
346
self.show_browser(ui);
419
ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
420
ui.horizontal(|ui| {
421
if ui.button("Opslaan").clicked() {
422
choice = Some(crate::session::Choice::Save);
423
}
424
if ui.button("Niet opslaan").clicked() {
425
choice = Some(crate::session::Choice::Discard);
426
}
427
if ui.button("Annuleren").clicked() {
428
choice = Some(crate::session::Choice::Cancel);
429
}
430
});
347
431
});
432
if let Some(choice) = choice {
433
self.apply_choice(choice, ctx);
434
}
435
}
436
437
fn show_start(&mut self, ctx: &egui::Context) {
348
438
egui::CentralPanel::default().show(ctx, |ui| {
349
self.show_preview(ui);
439
if self.session.confirm.is_some() || self.dialog_pending {
440
ui.disable();
441
}
442
ui.heading("Paperdoll Renderer");
443
ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
444
ui.add_space(12.0);
445
if ui.button("Project laden").clicked() {
446
self.begin(Intent::PickLoad);
447
}
448
if ui.button("Nieuw project").clicked() {
449
self.begin(Intent::PickNew);
450
}
350
451
});
351
452
}
352
}
353
453
354
impl EditorApp {
355
fn show_preview(&mut self, ui: &mut egui::Ui) {
356
ui.horizontal_wrapped(|ui| {
357
if ui.button("Laad testlichamen").clicked() {
358
self.load_fixture_bodies();
454
fn show_bodies(&mut self, ctx: &egui::Context) {
455
self.toolbar(ctx, true);
456
egui::CentralPanel::default().show(ctx, |ui| {
457
if self.session.confirm.is_some() || self.dialog_pending {
458
ui.disable();
359
459
}
360
if ui.button("3D van project").clicked() {
361
self.refresh_preview_from_project();
362
self.ok("De 3D-preview gebruikt de .glb- of .gltf-paden van man en vrouw.");
460
ui.heading("Body");
461
if let Some(opened) = &mut self.session.opened {
462
ui.horizontal(|ui| {
463
ui.label("Naam");
464
ui.text_edit_singleline(&mut opened.document.name);
465
});
363
466
}
467
self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
468
self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
469
ui.add_space(8.0);
470
ui.horizontal(|ui| {
471
ui.label("Breedte");
472
ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
473
ui.label("Hoogte");
474
ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
475
ui.label("fps");
476
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
477
});
478
self.apply_render_fields();
364
479
});
365
let character = match self.preview_controls.sex {
366
PreviewSex::Male => self.preview_male.as_ref(),
367
PreviewSex::Female => self.preview_female.as_ref(),
368
};
369
self.viewport
370
.show(ui, &mut self.preview_controls, character);
371
480
}
372
}
373
481
374
impl EditorApp {
375
fn show_form(&mut self, ui: &mut egui::Ui) {
376
ui.heading("Paperdoll Renderer");
377
ui.label(
378
"Links het projectbestand. In het midden draait de live 3D-preview van een glTF/GLB. Lees man of Lees vrouw toont het importoverzicht.",
379
);
380
ui.add_space(8.0);
381
ui.horizontal(|ui| {
382
ui.label("Projectmap");
383
ui.add(
384
egui::TextEdit::singleline(&mut self.root_input)
385
.desired_width(360.0)
386
.hint_text("/pad/naar/nieuwe-map"),
387
);
388
});
482
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
389
483
ui.horizontal(|ui| {
390
ui.label(".ppd openen");
391
ui.add(
392
egui::TextEdit::singleline(&mut self.open_input)
393
.desired_width(360.0)
394
.hint_text("/pad/naar/character.ppd"),
395
);
396
});
397
ui.separator();
398
ui.horizontal(|ui| {
399
ui.label("Naam");
400
ui.text_edit_singleline(&mut self.name);
401
});
402
ui.horizontal(|ui| {
403
ui.label("Man");
404
let male_edit = ui.add(
405
egui::TextEdit::singleline(&mut self.male)
406
.desired_width(360.0)
407
.hint_text("assets/body/male.glb"),
408
);
409
if male_edit.changed() {
410
self.male_import = None;
411
}
412
if ui.button("Wis man").clicked() {
413
self.male.clear();
414
self.male_import = None;
415
}
416
if ui.button("Lees man").clicked() {
417
self.inspect(true);
484
ui.label(title);
485
let path = self
486
.session
487
.body_path(sex)
488
.unwrap_or("geen model")
489
.to_string();
490
ui.label(path);
491
if ui.button(pick).clicked() {
492
self.dialogs.start(DialogPurpose::Body(sex));
418
493
}
419
});
420
show_import(ui, "Import man", self.male_import.as_ref());
421
ui.horizontal(|ui| {
422
ui.label("Vrouw");
423
let female_edit = ui.add(
424
egui::TextEdit::singleline(&mut self.female)
425
.desired_width(360.0)
426
.hint_text("assets/body/female.glb"),
427
);
428
if female_edit.changed() {
429
self.female_import = None;
430
}
431
if ui.button("Wis vrouw").clicked() {
432
self.female.clear();
433
self.female_import = None;
434
}
435
if ui.button("Lees vrouw").clicked() {
436
self.inspect(false);
494
if ui.button(open).clicked() {
495
self.controls.sex = match sex {
496
Sex::Male => PreviewSex::Male,
497
Sex::Female => PreviewSex::Female,
498
};
499
self.session.show_body(sex);
437
500
}
438
501
});
439
show_import(ui, "Import vrouw", self.female_import.as_ref());
440
ui.label("Equipment-presets, relatief aan de projectmap");
441
let mut remove = None;
442
for (index, preset) in self.presets.iter().enumerate() {
443
ui.horizontal(|ui| {
444
ui.label(preset);
445
if ui.button("Verwijder").clicked() {
446
remove = Some(index);
502
let index = if sex == Sex::Male { 0 } else { 1 };
503
if let Some(inventory) = &self.inventories[index] {
504
ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
505
Ok(model) => {
506
for mesh in &model.meshes {
507
ui.label(format!(
508
"{}: {} vertices, {} driehoeken",
509
mesh.name, mesh.vertex_count, mesh.triangle_count
510
));
511
}
512
for material in &model.materials {
513
ui.label(format!("materiaal {}", material.name));
514
}
515
for texture in &model.textures {
516
ui.label(format!(
517
"texture {}: {}×{}",
518
texture.source, texture.width, texture.height
519
));
520
}
521
ui.label(format!("{} joints", model.joints.len()));
522
for clip in &model.clips {
523
ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
524
}
525
for notice in &model.notices {
526
ui.label(notice);
527
}
528
}
529
Err(message) => {
530
ui.colored_label(egui::Color32::LIGHT_RED, message);
447
531
}
448
532
});
449
533
}
450
if let Some(index) = remove {
451
self.presets.remove(index);
452
}
453
ui.horizontal(|ui| {
454
ui.add(
455
egui::TextEdit::singleline(&mut self.preset_input)
456
.desired_width(280.0)
457
.hint_text("presets/zwaard.bin"),
458
);
459
if ui.button("Voeg preset toe").clicked() {
460
let value = self.preset_input.trim().to_string();
461
self.add_preset(value);
462
}
463
});
464
ui.horizontal(|ui| {
465
ui.label("Uitvoer");
466
ui.text_edit_singleline(&mut self.output);
467
});
468
ui.horizontal(|ui| {
469
ui.label("Breedte");
470
ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
471
ui.label("Hoogte");
472
ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
473
ui.label("fps");
474
ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
475
});
476
ui.add_space(8.0);
477
ui.label(
478
"Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.",
479
);
480
534
}
481
535
482
fn show_browser(&mut self, ui: &mut egui::Ui) {
483
ui.heading("Bestanden");
484
let Some(opened) = self.opened.clone() else {
485
ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
486
return;
536
fn show_body(&mut self, ctx: &egui::Context) {
537
self.toolbar(ctx, true);
538
let sex = match self.session.screen {
539
Screen::Body(sex) => sex,
540
_ => Sex::Male,
487
541
};
488
let root = opened
489
.manifest_path
490
.parent()
491
.map(PathBuf::from)
492
.unwrap_or_default();
493
ui.label(if self.browser.is_empty() {
494
"projectmap".to_string()
495
} else {
496
self.browser.clone()
497
});
498
if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
499
self.browser = parent_relative(&self.browser);
500
}
501
if ui.button("Deze map als uitvoer").clicked() {
502
if self.browser.is_empty() {
503
self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
504
} else {
505
self.output = self.browser.clone();
506
self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");
542
self.controls.sex = match sex {
543
Sex::Male => PreviewSex::Male,
544
Sex::Female => PreviewSex::Female,
545
};
546
egui::CentralPanel::default().show(ctx, |ui| {
547
if self.session.confirm.is_some() || self.dialog_pending {
548
ui.disable();
507
549
}
508
}
509
let entries = match list_dir(&root, &self.browser) {
510
Ok(entries) => entries,
511
Err(error) => {
512
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
513
return;
550
ui.horizontal_wrapped(|ui| {
551
if ui.button("Terug").clicked() {
552
self.session.show_bodies();
553
}
554
if ui.button("Equipment").clicked() {
555
self.session.show_equipment_list();
556
}
557
if ui
558
.add_enabled(!self.render_ui.running, egui::Button::new("Render"))
559
.clicked()
560
{
561
self.start_render([480, 640]);
562
}
563
if ui.button("Live 3D").clicked() {
564
self.render_ui.preview = None;
565
}
566
});
567
if self.render_ui.progress.total > 0 {
568
let fraction = self.render_ui.progress.completed as f32
569
/ self.render_ui.progress.total.max(1) as f32;
570
ui.add(egui::ProgressBar::new(fraction).text(format!(
571
"{} / {} {}",
572
self.render_ui.progress.completed,
573
self.render_ui.progress.total,
574
self.render_ui.progress.label
575
)));
514
576
}
515
};
516
let mut enter = None;
517
let mut choose = None;
518
for entry in &entries {
519
let label = if entry.is_dir {
520
format!("{}/", entry.name)
577
if let Some(image) = &self.render_ui.preview {
578
self.paint_pass(ui, image);
521
579
} else {
522
entry.name.clone()
523
};
524
if ui.button(label).clicked() {
525
if entry.is_dir {
526
enter = Some(entry.relative.clone());
580
let character = match self.controls.sex {
581
PreviewSex::Male => self.preview_male.as_ref(),
582
PreviewSex::Female => self.preview_female.as_ref(),
583
};
584
if character.is_some() {
585
self.viewport.show(ui, &mut self.controls, character);
527
586
} else {
528
choose = Some(entry.relative.clone());
587
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
529
588
}
530
589
}
531
}
532
if let Some(relative) = enter {
533
self.browser = relative;
534
}
535
if let Some(relative) = choose {
536
self.assign_file(&relative);
537
}
590
});
538
591
}
539
}
540
592
541
fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
542
let Some(report) = report else {
543
return;
544
};
545
ui.group(|ui| {
546
ui.strong(title);
547
match report {
548
Ok(model) => {
549
let format = match model.format {
550
ModelFormat::Gltf => "glTF/GLB",
551
ModelFormat::Obj => "OBJ",
552
ModelFormat::Fbx => "FBX",
553
};
554
ui.label(format);
555
for mesh in &model.meshes {
556
ui.label(format!(
557
"mesh {}: {} vertices, {} driehoeken{}",
558
mesh.name,
559
mesh.vertex_count,
560
mesh.triangle_count,
561
if mesh.has_skin { ", skin" } else { "" }
562
));
593
fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
594
let color = egui::ColorImage::from_rgba_unmultiplied(
595
[image.width as usize, image.height as usize],
596
&image.rgba,
597
);
598
let texture = ui.ctx().load_texture(
599
"render-pass",
600
color,
601
egui::TextureOptions {
602
magnification: egui::TextureFilter::Nearest,
603
minification: egui::TextureFilter::Nearest,
604
wrap_mode: egui::TextureWrapMode::ClampToEdge,
605
..Default::default()
606
},
607
);
608
let available = ui.available_size();
609
ui.image((texture.id(), available));
610
}
611
612
fn show_equipment_list(&mut self, ctx: &egui::Context) {
613
self.toolbar(ctx, true);
614
egui::CentralPanel::default().show(ctx, |ui| {
615
if self.session.confirm.is_some() || self.dialog_pending {
616
ui.disable();
617
}
618
ui.heading(format!(
619
"Bodies → {} → Equipment",
620
match self.session.active_body {
621
Some(Sex::Male) => "Man",
622
Some(Sex::Female) => "Vrouw",
623
None => "Body",
563
624
}
564
for material in &model.materials {
565
ui.label(format!(
566
"materiaal {}{}",
567
material.name,
568
material
569
.base_color_texture
570
.as_ref()
571
.map(|name| format!(", texture {name}"))
572
.unwrap_or_default()
573
));
625
));
626
if ui.button("Terug naar body").clicked() {
627
self.session.back();
628
}
629
let mut open = None;
630
for (index, item) in self.session.equipment.iter().enumerate() {
631
let label = match &item.model {
632
Some(model) => format!("{} {model}", item.slug),
633
None => item.slug.clone(),
634
};
635
if ui.button(label).clicked() {
636
open = Some(index);
574
637
}
575
for texture in &model.textures {
576
ui.label(format!(
577
"texture {} ({}x{})",
578
texture.source, texture.width, texture.height
579
));
638
}
639
if ui.button("Nieuw").clicked() {
640
self.session.add_equipment();
641
}
642
if let Some(index) = open {
643
self.session.show_equipment(index);
644
}
645
});
646
}
647
648
fn show_equipment(&mut self, ctx: &egui::Context) {
649
self.toolbar(ctx, true);
650
let Screen::Equipment(index) = self.session.screen else {
651
return;
652
};
653
egui::CentralPanel::default().show(ctx, |ui| {
654
if self.session.confirm.is_some() || self.dialog_pending {
655
ui.disable();
656
}
657
ui.heading("Equipment");
658
if ui.button("Terug").clicked() {
659
self.session.show_equipment_list();
660
}
661
if let Some(item) = self.session.equipment.get_mut(index) {
662
ui.horizontal(|ui| {
663
ui.label("Naam");
664
ui.text_edit_singleline(&mut item.slug);
665
});
666
let model = item
667
.model
668
.clone()
669
.unwrap_or_else(|| "geen model".to_string());
670
ui.label(model);
671
}
672
if ui.button("Kies model").clicked() {
673
self.dialogs.start(DialogPurpose::EquipmentModel(index));
674
}
675
if ui.button("Verwijder").clicked() {
676
self.session.delete_equipment(index);
677
}
678
});
679
}
680
681
fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
682
if !project {
683
return;
684
}
685
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
686
if self.session.confirm.is_some() || self.dialog_pending {
687
ui.disable();
688
}
689
ui.horizontal(|ui| {
690
if ui.button("Body").clicked() {
691
self.session.show_bodies();
580
692
}
581
if model.joints.is_empty() {
582
ui.label("geen skeleton");
583
} else {
584
ui.label(format!("{} joints", model.joints.len()));
585
for joint in model.joints.iter().take(8) {
586
match &joint.parent {
587
Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
588
None => ui.label(format!("{} (wortel)", joint.name)),
589
};
590
}
693
if ui.button("Opslaan").clicked() {
694
self.save();
591
695
}
592
if model.clips.is_empty() {
593
ui.label("geen animation clips");
594
} else {
595
for clip in &model.clips {
596
ui.label(format!(
597
"clip {} ({:.2} s, {} kanalen)",
598
clip.name, clip.duration_seconds, clip.channel_count
599
));
600
}
696
if ui
697
.add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
698
.clicked()
699
{
700
self.begin(Intent::PickNew);
601
701
}
602
for notice in &model.notices {
603
ui.label(notice);
702
if ui
703
.add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
704
.clicked()
705
{
706
self.begin(Intent::PickLoad);
604
707
}
605
}
606
Err(message) => {
607
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
608
}
609
}
610
});
708
});
709
});
710
}
611
711
}
612
712
613
fn load_preview_path(root: &std::path::Path, relative: &str) -> Option<PreviewCharacter> {
614
let relative = relative.trim();
615
let lower = relative.to_ascii_lowercase();
616
if !(lower.ends_with(".glb") || lower.ends_with(".gltf")) {
617
return None;
713
#[cfg(test)]
714
mod tests {
715
use super::*;
716
use crate::dialogs::ScriptedDialogs;
717
use crate::session::Choice;
718
719
fn root() -> PathBuf {
720
std::env::temp_dir().join(format!(
721
"paperdoll-editor-{}",
722
std::time::SystemTime::now()
723
.duration_since(std::time::UNIX_EPOCH)
724
.unwrap()
725
.as_nanos()
726
))
618
727
}
619
load_gltf_file(&root.join(relative)).ok()
620
}
621
728
622
fn parent_relative(relative: &str) -> String {
623
match relative.rfind('/') {
624
Some(index) => relative[..index].to_string(),
625
None => String::new(),
729
#[test]
730
fn picker_creates_project_and_cancelled_switch_preserves_edits() {
731
let root = root();
732
let mut dialogs = ScriptedDialogs::default();
733
dialogs.push(Some(root.clone()));
734
dialogs.push(None);
735
let mut app = EditorApp::new(Box::new(dialogs));
736
assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
737
app.begin(Intent::PickNew);
738
app.poll_dialogs();
739
assert_eq!(app.session.screen, Screen::Bodies);
740
assert!(app.preview_male.is_some());
741
assert!(app.preview_female.is_some());
742
app.session.set_name("Keep my edit".into());
743
app.begin(Intent::PickLoad);
744
assert_eq!(app.session.confirm, Some(Intent::PickLoad));
745
app.apply_choice(Choice::Discard, &egui::Context::default());
746
app.poll_dialogs(); // OS picker cancel; no actual project switch happened
747
assert_eq!(
748
app.session.opened.as_ref().unwrap().document.name,
749
"Keep my edit"
750
);
751
assert!(app.session.is_dirty());
752
let _ = std::fs::remove_dir_all(root);
626
753
}
627
}
628
754
629
fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
630
let text = text.trim();
631
if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
632
return Err(format!("{label} moet een geheel getal zijn"));
755
#[test]
756
fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
757
let root = root();
758
let mut app = EditorApp::default();
759
app.session.install_new_project(&root).unwrap();
760
app.session.set_render(0, 128, 12);
761
app.begin(Intent::Quit);
762
app.apply_choice(Choice::Save, &egui::Context::default());
763
assert!(!app.allow_close);
764
assert_eq!(app.session.confirm, Some(Intent::Quit));
765
assert!(app.session.is_dirty());
766
app.open_path(root.join("missing.ppd"));
767
assert!(app.session.opened.is_some());
768
assert_eq!(app.session.screen, Screen::Bodies);
769
let _ = std::fs::remove_dir_all(root);
633
770
}
634
text.parse::<u32>()
635
.map_err(|_| format!("{label} is te groot"))
636
}
637
771
638
impl Default for EditorApp {
639
fn default() -> Self {
640
Self::new()
772
#[test]
773
fn open_with_project_path_loads_bodies_and_models() {
774
let root = root().join("project with spaces");
775
let opened = crate::session::create_seeded_project(&root).unwrap();
776
let mut app = EditorApp::default();
777
app.open_path(opened.manifest_path.clone());
778
assert_eq!(app.session.screen, Screen::Bodies);
779
assert!(app.preview_male.is_some());
780
assert_eq!(
781
app.session.opened.unwrap().manifest_path,
782
opened.manifest_path
783
);
784
let _ = std::fs::remove_dir_all(root.parent().unwrap());
785
}
786
787
#[test]
788
fn renderer_disconnect_is_reported() {
789
let mut app = EditorApp::default();
790
let (tx, rx) = std::sync::mpsc::channel();
791
app.render_rx = Some(rx);
792
app.render_ui.running = true;
793
drop(tx);
794
app.drain_render();
795
assert!(!app.render_ui.running);
796
assert!(app.render_ui.error.is_some());
641
797
}
642
798
}
@@ -1,642 +1,798 @@1
//! Project window. It edits a `.ppd` manifest and does not import or render.1
//! Project window: start screen, body, equipment, and the render queue.2
2
3
use std::path::PathBuf;3
use std::path::PathBuf;4
use std::sync::mpsc::Receiver;4
5
5
use eframe::egui;6
use eframe::egui;6
7
7
use crate::import::{import_project_model, ImportedModel, ModelFormat};8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};9
use crate::import::{import_project_model, ImportedModel};8
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};10
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};9
use crate::preview_gl::Viewport;11
use crate::preview_gl::Viewport;10
use crate::project::{12
use crate::project::open_project;11
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,13
use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};12
ProjectError, FORMAT_VERSION, MANIFEST_NAME,14
use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};13
};14
15
15
pub struct EditorApp {16
pub struct EditorApp {16
root_input: String,17
session: Session,17
open_input: String,18
dialogs: Box<dyn Dialogs>,18
opened: Option<OpenedProject>,19
controls: PreviewControls,19
name: String,20
male: String,21
female: String,22
presets: Vec<String>,23
preset_input: String,24
output: String,25
width: String,26
height: String,27
fps: String,28
browser: String,29
male_import: Option<Result<ImportedModel, String>>,30
female_import: Option<Result<ImportedModel, String>>,31
preview_controls: PreviewControls,32
preview_male: Option<PreviewCharacter>,20
preview_male: Option<PreviewCharacter>,33
preview_female: Option<PreviewCharacter>,21
preview_female: Option<PreviewCharacter>,34
viewport: Viewport,22
viewport: Viewport,35
notice: String,23
inventories: [Option<Result<ImportedModel, String>>; 2],36
notice_ok: bool,24
render_rx: Option<Receiver<RenderEvent>>,25
render_ui: RenderUiState,26
applied_shell: Option<[f32; 2]>,27
width_text: String,28
height_text: String,29
fps_text: String,30
allow_close: bool,31
dialog_pending: bool,37
}32
}38
33
39
impl EditorApp {34
impl EditorApp {40
pub fn new() -> Self {35
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {41
Self {36
Self {42
root_input: String::new(),37
session: Session::new(),43
open_input: String::new(),38
dialogs,44
opened: None,39
controls: PreviewControls::default(),45
name: "Nieuw project".to_string(),46
male: String::new(),47
female: String::new(),48
presets: Vec::new(),49
preset_input: String::new(),50
output: "renders".to_string(),51
width: "128".to_string(),52
height: "128".to_string(),53
fps: "12".to_string(),54
browser: String::new(),55
male_import: None,56
female_import: None,57
preview_controls: PreviewControls::default(),58
preview_male: None,40
preview_male: None,59
preview_female: None,41
preview_female: None,60
viewport: Viewport::new(),42
viewport: Viewport::new(),61
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),43
inventories: [None, None],62
notice_ok: true,44
render_rx: None,45
render_ui: RenderUiState::idle(),46
applied_shell: None,47
width_text: "128".to_string(),48
height_text: "128".to_string(),49
fps_text: "12".to_string(),50
allow_close: false,51
dialog_pending: false,63
}52
}64
}53
}65
54
66
fn load(&mut self, opened: OpenedProject, notice: &str) {55
pub fn open_path(&mut self, path: PathBuf) {67
self.name = opened.document.name.clone();56
let candidate = std::env::current_dir()68
self.male = opened.document.male_model.clone().unwrap_or_default();57
.map_err(|e| e.to_string())69
self.female = opened.document.female_model.clone().unwrap_or_default();58
.and_then(|base| {70
self.presets = opened.document.equipment_presets.clone();59
crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())71
self.output = opened.document.output_directory.clone();60
});72
self.width = opened.document.render.target_width.to_string();61
let path = match candidate {73
self.height = opened.document.render.target_height.to_string();62
Ok(path) => path,74
self.fps = opened.document.render.fps.to_string();63
Err(error) => {75
self.browser.clear();64
self.session.fail(error);76
self.male_import = None;65
return;77
self.female_import = None;66
}78
self.opened = Some(opened.clone());67
};79
let male = self.male.clone();68
match open_project(&path) {80
let female = self.female.clone();69
Ok(opened) => {81
if !male.trim().is_empty() {70
self.session.install_loaded(opened);82
self.inspect(true);71
self.reset_render();83
}72
self.sync_fields();84
if !female.trim().is_empty() {73
self.reload_bodies();85
self.inspect(false);74
}86
}75
Err(error) => {87
self.refresh_preview_from_project();76
self.session.fail(error.to_string());88
if male.trim().is_empty() && female.trim().is_empty() {89
if opened.disk_issues.is_empty() {90
self.ok(notice);91
} else {92
self.fail_text(opened.disk_issues.join("\n"));93
}77
}94
}78
}95
}79
}96
80
97
pub fn load_fixture_bodies(&mut self) {81
pub fn open_fixture_project(&mut self) {98
let root =82
let root = std::env::temp_dir().join(format!(99
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies");83
"paperdoll-renderer-fixture-{}-{}",100
match load_gltf_file(&root.join("test-body-male.glb")) {84
std::process::id(),101
Ok(character) => self.preview_male = Some(character),85
std::time::SystemTime::now()102
Err(error) => self.fail_text(format!("testman: {error}")),86
.duration_since(std::time::UNIX_EPOCH)103
}87
.unwrap_or_default()104
match load_gltf_file(&root.join("test-body-female.glb")) {88
.as_nanos()105
Ok(character) => self.preview_female = Some(character),89
));106
Err(error) => self.fail_text(format!("testvrouw: {error}")),90
if let Err(error) = self.session.install_new_project(&root) {107
}91
self.session.fail(error.to_string());108
if self.preview_male.is_some() {109
self.preview_controls.sex = PreviewSex::Male;110
self.preview_controls.select_clip("walk");111
self.ok("Testlichamen staan in de 3D-preview.");112
}113
}114
115
fn refresh_preview_from_project(&mut self) {116
let Some(root) = self.opened.as_ref().and_then(|opened| {117
opened118
.manifest_path119
.parent()120
.map(std::path::Path::to_path_buf)121
}) else {122
return;92
return;123
};93
}124
self.preview_male = load_preview_path(&root, &self.male);94
self.sync_fields();125
self.preview_female = load_preview_path(&root, &self.female);95
self.reload_bodies();96
self.session.show_body(Sex::Male);97
self.controls.sex = PreviewSex::Male;98
self.controls.select_clip("walk");126
}99
}127
100
128
fn ok(&mut self, message: &str) {101
fn reset_render(&mut self) {129
self.notice = message.to_string();102
self.render_rx = None;130
self.notice_ok = true;103
self.render_ui = RenderUiState::idle();131
}104
}132
105
133
fn fail(&mut self, error: ProjectError) {106
fn sync_fields(&mut self) {134
self.notice = error.to_string();107
if let Some(opened) = &self.session.opened {135
self.notice_ok = false;108
self.width_text = opened.document.render.target_width.to_string();109
self.height_text = opened.document.render.target_height.to_string();110
self.fps_text = opened.document.render.fps.to_string();111
}136
}112
}137
113
138
fn fail_text(&mut self, message: impl Into<String>) {114
fn reload_bodies(&mut self) {139
self.notice = message.into();115
self.preview_male = None;140
self.notice_ok = false;116
self.preview_female = None;117
self.inventories = [None, None];118
let Some(root) = self.session.project_root() else {119
return;120
};121
for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {122
if let Some(relative) = self.session.body_path(sex) {123
let imported =124
import_project_model(&root, relative).map_err(|error| error.to_string());125
// Validate the source and its dependencies before the viewport reads it.126
if imported.is_ok()127
&& matches!(128
std::path::Path::new(relative)129
.extension()130
.and_then(|e| e.to_str()),131
Some("gltf" | "glb")132
)133
{134
match load_gltf_file(&root.join(relative)) {135
Ok(character) => match sex {136
Sex::Male => self.preview_male = Some(character),137
Sex::Female => self.preview_female = Some(character),138
},139
Err(error) => self.session.fail(format!("3D: {error}")),140
}141
}142
self.inventories[index] = Some(imported);143
}144
}141
}145
}142
146
143
fn draft(&self) -> Result<ProjectDraft, String> {147
fn character(&self) -> Option<&PreviewCharacter> {144
let opened = self148
match self.controls.sex {145
.opened149
PreviewSex::Male => self.preview_male.as_ref(),146
.as_ref()150
PreviewSex::Female => self.preview_female.as_ref(),147
.ok_or_else(|| "open of maak eerst een project".to_string())?;151
}148
Ok(ProjectDraft {149
id: opened.document.id.clone(),150
name: self.name.clone(),151
male_model: self.male.clone(),152
female_model: self.female.clone(),153
equipment_presets: self.presets.clone(),154
output_directory: self.output.clone(),155
target_width: parse_u32("breedte", &self.width)?,156
target_height: parse_u32("hoogte", &self.height)?,157
fps: parse_u32("fps", &self.fps)?,158
})159
}152
}160
153
161
fn create(&mut self) {154
fn poll_dialogs(&mut self) {162
let root = PathBuf::from(self.root_input.trim());155
let poll = self.dialogs.poll();163
if self.root_input.trim().is_empty() {156
self.dialog_pending = matches!(poll, DialogPoll::Pending);164
self.fail_text("vul de projectmap in");157
if let DialogPoll::Ready { purpose, path } = poll {165
return;158
let Some(path) = path else {166
}159
return;167
match create_project(&root, "Nieuw project") {160
};168
Ok(opened) => self.load(161
match purpose {169
opened,162
DialogPurpose::NewProject => {170
"Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",163
if let Err(error) = self.session.install_new_project(&path) {171
),164
self.session.fail(error.to_string());172
Err(error) => self.fail(error),165
} else {166
self.reset_render();167
self.sync_fields();168
self.reload_bodies();169
}170
}171
DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {172
Ok(opened) => {173
self.session.install_loaded(opened);174
self.reset_render();175
self.sync_fields();176
self.reload_bodies();177
}178
Err(error) => self.session.fail(error.to_string()),179
},180
DialogPurpose::Body(sex) => self.take_model(sex, &path),181
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),182
}173
}183
}174
}184
}175
185
176
fn open(&mut self) {186
fn take_model(&mut self, sex: Sex, path: &std::path::Path) {177
let path = PathBuf::from(self.open_input.trim());187
let Some(root) = self.session.project_root() else {178
if self.open_input.trim().is_empty() {179
self.fail_text("vul het pad naar een .ppd-bestand in");180
return;188
return;181
}189
};182
match open_project(&path) {190
match adopt_file(&root, path, "assets/body") {183
Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),191
Ok(relative) => {184
Err(error) => self.fail(error),192
self.session.set_body_path(sex, relative.clone());193
self.reload_bodies();194
self.reset_render();195
let index = if sex == Sex::Male { 0 } else { 1 };196
if let Some(Err(error)) = &self.inventories[index] {197
self.session.fail(format!("{relative}: {error}"));198
} else {199
self.session.notice = format!("{relative} staat in het project.");200
self.session.notice_ok = true;201
}202
}203
Err(error) => self.session.fail(error),185
}204
}186
}205
}187
206
188
fn save(&mut self) {207
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {189
let Some(current) = self.opened.clone() else {208
let Some(root) = self.session.project_root() else {190
self.fail_text("open of maak eerst een project");191
return;209
return;192
};210
};193
let draft = match self.draft() {211
match adopt_file(&root, path, "assets/equipment") {194
Ok(draft) => draft,212
Ok(relative) => {195
Err(message) => {213
self.session.set_equipment_model(index, relative.clone());196
self.fail_text(message);214
self.session.notice = format!("{relative} hoort bij deze equipment.");197
return;215
self.session.notice_ok = true;198
}199
};200
let document = match assemble(draft) {201
Ok(document) => document,202
Err(error) => {203
self.fail(error);204
return;205
}216
}206
};217
Err(error) => self.session.fail(error),207
let candidate = OpenedProject {208
manifest_path: current.manifest_path,209
document,210
disk_issues: Vec::new(),211
};212
match save_project(&candidate) {213
Ok(()) => {214
let path = candidate.manifest_path.display().to_string();215
self.opened = Some(candidate);216
self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));217
}218
Err(error) => self.fail(error),219
}218
}220
}219
}221
220
222
fn add_preset(&mut self, relative: String) {221
fn begin(&mut self, intent: Intent) {223
if relative.trim().is_empty() {222
if self.session.gate(intent) {224
return;223
self.run_intent(intent);225
}224
}226
self.presets.push(relative);225
}227
if let Err(error) = self226
228
.draft()227
fn run_intent(&mut self, intent: Intent) {229
.and_then(|draft| assemble(draft).map_err(|error| error.to_string()))228
match intent {230
{229
Intent::Quit => {}231
self.presets.pop();230
Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),232
self.fail_text(error);231
Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),233
} else {234
self.preset_input.clear();235
self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");236
}232
}237
}233
}238
234
239
fn assign_file(&mut self, relative: &str) {235
fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {240
if relative == MANIFEST_NAME {236
match self.session.resolve(choice) {241
self.fail_text("character.ppd is het projectbestand, geen model of preset");237
AfterChoice::Stay => {}242
return;238
AfterChoice::SaveThen(intent) => match self.session.save() {239
Ok(()) => self.finish_intent(intent, ctx),240
Err(error) => self.session.fail(error.to_string()),241
},242
AfterChoice::Go(intent) => self.finish_intent(intent, ctx),243
}243
}244
let lower = relative.to_ascii_lowercase();244
}245
if lower.contains("/preset") || lower.starts_with("presets/") {245
246
self.add_preset(relative.to_string());246
fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {247
} else if self.male.trim().is_empty() {247
if intent == Intent::Quit {248
self.male = relative.to_string();248
self.allow_close = true;249
self.inspect(true);249
ctx.send_viewport_cmd(egui::ViewportCommand::Close);250
} else if self.female.trim().is_empty() {251
self.female = relative.to_string();252
self.inspect(false);253
} else {250
} else {254
self.add_preset(relative.to_string());251
self.run_intent(intent);252
}253
}254
255
fn save(&mut self) {256
if let Err(error) = self.session.save() {257
self.session.fail(error.to_string());255
}258
}256
}259
}257
260
258
fn inspect(&mut self, male: bool) {261
fn apply_render_fields(&mut self) {259
let Some(root) = self.opened.as_ref().and_then(|opened| {262
let width = self.width_text.parse::<u32>().unwrap_or(0);260
opened263
let height = self.height_text.parse::<u32>().unwrap_or(0);261
.manifest_path264
let fps = self.fps_text.parse::<u32>().unwrap_or(0);262
.parent()265
self.session.set_render(width, height, fps);263
.map(std::path::Path::to_path_buf)266
}264
}) else {267
265
self.fail_text("open of maak eerst een project");268
fn start_render(&mut self, preview: [u32; 2]) {269
if let Some(opened) = &self.session.opened {270
if let Err(error) = opened.document.validate_shape() {271
self.session.fail(error.to_string());272
return;273
}274
}275
let Some(character) = self.character().cloned() else {276
self.session277
.fail("dit lichaam heeft geen glTF dat de preview kan tekenen");266
return;278
return;267
};279
};268
let relative = if male {280
let Some(root) = self.session.project_root() else {269
self.male.trim()281
return;270
} else {271
self.female.trim()272
};282
};273
if relative.is_empty() {283
let Some(opened) = &self.session.opened else {274
if male {284
return;275
self.male_import = None;285
};276
} else {286
if let Err(error) =277
self.female_import = None;287
crate::project::prepare_output_dir(&root, &opened.document.output_directory)278
}288
{279
self.fail_text("dit slot heeft nog geen modelpad");289
self.session.fail(error.to_string());280
return;290
return;281
}291
}282
match import_project_model(&root, relative) {292
let sex = match self.controls.sex {283
Ok(model) => {293
PreviewSex::Male => "male",284
let message = format!(294
PreviewSex::Female => "female",285
"{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",295
};286
model.meshes.len(),296
let output = root287
model.joints.len(),297
.join(&opened.document.output_directory)288
model.clips.len()298
.join(format!("body-{sex}-still.png"));289
);299
let job = RenderJob {290
if male {300
character,291
self.male_import = Some(Ok(model));301
controls: self.controls.clone(),292
} else {302
target_width: opened.document.render.target_width.max(1),293
self.female_import = Some(Ok(model));303
target_height: opened.document.render.target_height.max(1),304
preview_width: preview[0].max(1),305
preview_height: preview[1].max(1),306
output,307
};308
let thread = spawn(job);309
self.render_rx = Some(thread.rx);310
self.render_ui = RenderUiState::idle();311
self.render_ui.running = true;312
let _ = thread.handle;313
}314
315
fn drain_render(&mut self) {316
let Some(rx) = &self.render_rx else {317
return;318
};319
loop {320
let event = match rx.try_recv() {321
Ok(event) => event,322
Err(std::sync::mpsc::TryRecvError::Empty) => break,323
Err(std::sync::mpsc::TryRecvError::Disconnected) => {324
let message = "De renderworker stopte zonder eindresultaat.".to_string();325
self.session.fail(message.clone());326
self.render_ui.apply(RenderEvent::Failed(message));327
self.render_rx = None;328
break;294
}329
}295
self.ok(&message);330
};331
let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));332
if let RenderEvent::Finished { saved: Some(path) } = &event {333
self.session.notice = format!("Standbeeld opgeslagen: {path}");334
self.session.notice_ok = true;296
}335
}297
Err(error) => {336
if let RenderEvent::Failed(message) = &event {298
let text = error.to_string();337
self.session.fail(message.clone());299
if male {338
}300
self.male_import = Some(Err(text.clone()));339
self.render_ui.apply(event);301
} else {340
if finished {302
self.female_import = Some(Err(text.clone()));341
self.render_rx = None;303
}342
break;304
self.fail_text(text);305
}343
}306
}344
}307
}345
}308
}346
}309
347
348
impl Default for EditorApp {349
fn default() -> Self {350
Self::new(Box::new(SystemDialogs::new()))351
}352
}353
310
impl eframe::App for EditorApp {354
impl eframe::App for EditorApp {311
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {355
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {312
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {356
let shell = self.session.shell_size();313
ui.horizontal_wrapped(|ui| {357
if self.applied_shell != Some(shell) {314
if ui.button("Nieuw").clicked() {358
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(315
self.create();359
shell[0], shell[1],316
}360
)));317
if ui.button("Open").clicked() {361
let min = if shell == crate::session::START_WINDOW {318
self.open();362
egui::vec2(420.0, 220.0)319
}363
} else {320
if ui.button("Opslaan").clicked() {364
egui::vec2(900.0, 560.0)321
self.save();365
};322
}366
ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));323
if ui.button("Sluiten").clicked() {367
self.applied_shell = Some(shell);324
ctx.send_viewport_cmd(egui::ViewportCommand::Close);368
}325
}369
if ctx.input(|input| input.viewport().close_requested())326
ui.label(format!("format_version {FORMAT_VERSION}"));370
&& !self.allow_close327
});371
&& self.session.confirm.is_none()328
});372
{373
if !self.session.gate(Intent::Quit) {374
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);375
}376
} else if self.session.confirm.is_some()377
&& ctx.input(|input| input.viewport().close_requested())378
{379
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);380
}381
self.poll_dialogs();382
self.drain_render();383
if self.render_ui.running {384
ctx.request_repaint();385
}329
egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {386
egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {330
let color = if self.notice_ok {387
let color = if self.session.notice_ok {331
egui::Color32::from_rgb(20, 90, 40)388
egui::Color32::LIGHT_GREEN332
} else {389
} else {333
egui::Color32::from_rgb(150, 30, 30)390
egui::Color32::LIGHT_RED334
};391
};335
ui.colored_label(color, &self.notice);392
ui.colored_label(color, &self.session.notice);336
});393
});337
egui::SidePanel::left("form")394
match self.session.screen {338
.resizable(true)395
Screen::Start => self.show_start(ctx),339
.default_width(380.0)396
Screen::Bodies => self.show_bodies(ctx),340
.show(ctx, |ui| {397
Screen::Body(_) => self.show_body(ctx),341
egui::ScrollArea::vertical().show(ui, |ui| self.show_form(ui));398
Screen::EquipmentList => self.show_equipment_list(ctx),342
});399
Screen::Equipment(_) => self.show_equipment(ctx),343
egui::SidePanel::right("browser")400
}344
.min_width(220.0)401
self.show_confirm(ctx);402
if self.dialog_pending {403
ctx.request_repaint_after(std::time::Duration::from_millis(50));404
}405
}406
}407
408
impl EditorApp {409
fn show_confirm(&mut self, ctx: &egui::Context) {410
let Some(_intent) = self.session.confirm else {411
return;412
};413
let mut choice = None;414
egui::Window::new("Wijzigingen")415
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)416
.collapsible(false)417
.resizable(false)345
.show(ctx, |ui| {418
.show(ctx, |ui| {346
self.show_browser(ui);419
ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");420
ui.horizontal(|ui| {421
if ui.button("Opslaan").clicked() {422
choice = Some(crate::session::Choice::Save);423
}424
if ui.button("Niet opslaan").clicked() {425
choice = Some(crate::session::Choice::Discard);426
}427
if ui.button("Annuleren").clicked() {428
choice = Some(crate::session::Choice::Cancel);429
}430
});347
});431
});432
if let Some(choice) = choice {433
self.apply_choice(choice, ctx);434
}435
}436
437
fn show_start(&mut self, ctx: &egui::Context) {348
egui::CentralPanel::default().show(ctx, |ui| {438
egui::CentralPanel::default().show(ctx, |ui| {349
self.show_preview(ui);439
if self.session.confirm.is_some() || self.dialog_pending {440
ui.disable();441
}442
ui.heading("Paperdoll Renderer");443
ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");444
ui.add_space(12.0);445
if ui.button("Project laden").clicked() {446
self.begin(Intent::PickLoad);447
}448
if ui.button("Nieuw project").clicked() {449
self.begin(Intent::PickNew);450
}350
});451
});351
}452
}352
}353
453
354
impl EditorApp {454
fn show_bodies(&mut self, ctx: &egui::Context) {355
fn show_preview(&mut self, ui: &mut egui::Ui) {455
self.toolbar(ctx, true);356
ui.horizontal_wrapped(|ui| {456
egui::CentralPanel::default().show(ctx, |ui| {357
if ui.button("Laad testlichamen").clicked() {457
if self.session.confirm.is_some() || self.dialog_pending {358
self.load_fixture_bodies();458
ui.disable();359
}459
}360
if ui.button("3D van project").clicked() {460
ui.heading("Body");361
self.refresh_preview_from_project();461
if let Some(opened) = &mut self.session.opened {362
self.ok("De 3D-preview gebruikt de .glb- of .gltf-paden van man en vrouw.");462
ui.horizontal(|ui| {463
ui.label("Naam");464
ui.text_edit_singleline(&mut opened.document.name);465
});363
}466
}467
self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");468
self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");469
ui.add_space(8.0);470
ui.horizontal(|ui| {471
ui.label("Breedte");472
ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));473
ui.label("Hoogte");474
ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));475
ui.label("fps");476
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));477
});478
self.apply_render_fields();364
});479
});365
let character = match self.preview_controls.sex {366
PreviewSex::Male => self.preview_male.as_ref(),367
PreviewSex::Female => self.preview_female.as_ref(),368
};369
self.viewport370
.show(ui, &mut self.preview_controls, character);371
}480
}372
}373
481
374
impl EditorApp {482
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {375
fn show_form(&mut self, ui: &mut egui::Ui) {376
ui.heading("Paperdoll Renderer");377
ui.label(378
"Links het projectbestand. In het midden draait de live 3D-preview van een glTF/GLB. Lees man of Lees vrouw toont het importoverzicht.",379
);380
ui.add_space(8.0);381
ui.horizontal(|ui| {382
ui.label("Projectmap");383
ui.add(384
egui::TextEdit::singleline(&mut self.root_input)385
.desired_width(360.0)386
.hint_text("/pad/naar/nieuwe-map"),387
);388
});389
ui.horizontal(|ui| {483
ui.horizontal(|ui| {390
ui.label(".ppd openen");484
ui.label(title);391
ui.add(485
let path = self392
egui::TextEdit::singleline(&mut self.open_input)486
.session393
.desired_width(360.0)487
.body_path(sex)394
.hint_text("/pad/naar/character.ppd"),488
.unwrap_or("geen model")395
);489
.to_string();396
});490
ui.label(path);397
ui.separator();491
if ui.button(pick).clicked() {398
ui.horizontal(|ui| {492
self.dialogs.start(DialogPurpose::Body(sex));399
ui.label("Naam");400
ui.text_edit_singleline(&mut self.name);401
});402
ui.horizontal(|ui| {403
ui.label("Man");404
let male_edit = ui.add(405
egui::TextEdit::singleline(&mut self.male)406
.desired_width(360.0)407
.hint_text("assets/body/male.glb"),408
);409
if male_edit.changed() {410
self.male_import = None;411
}412
if ui.button("Wis man").clicked() {413
self.male.clear();414
self.male_import = None;415
}416
if ui.button("Lees man").clicked() {417
self.inspect(true);418
}493
}419
});494
if ui.button(open).clicked() {420
show_import(ui, "Import man", self.male_import.as_ref());495
self.controls.sex = match sex {421
ui.horizontal(|ui| {496
Sex::Male => PreviewSex::Male,422
ui.label("Vrouw");497
Sex::Female => PreviewSex::Female,423
let female_edit = ui.add(498
};424
egui::TextEdit::singleline(&mut self.female)499
self.session.show_body(sex);425
.desired_width(360.0)426
.hint_text("assets/body/female.glb"),427
);428
if female_edit.changed() {429
self.female_import = None;430
}431
if ui.button("Wis vrouw").clicked() {432
self.female.clear();433
self.female_import = None;434
}435
if ui.button("Lees vrouw").clicked() {436
self.inspect(false);437
}500
}438
});501
});439
show_import(ui, "Import vrouw", self.female_import.as_ref());502
let index = if sex == Sex::Male { 0 } else { 1 };440
ui.label("Equipment-presets, relatief aan de projectmap");503
if let Some(inventory) = &self.inventories[index] {441
let mut remove = None;504
ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {442
for (index, preset) in self.presets.iter().enumerate() {505
Ok(model) => {443
ui.horizontal(|ui| {506
for mesh in &model.meshes {444
ui.label(preset);507
ui.label(format!(445
if ui.button("Verwijder").clicked() {508
"{}: {} vertices, {} driehoeken",446
remove = Some(index);509
mesh.name, mesh.vertex_count, mesh.triangle_count510
));511
}512
for material in &model.materials {513
ui.label(format!("materiaal {}", material.name));514
}515
for texture in &model.textures {516
ui.label(format!(517
"texture {}: {}×{}",518
texture.source, texture.width, texture.height519
));520
}521
ui.label(format!("{} joints", model.joints.len()));522
for clip in &model.clips {523
ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));524
}525
for notice in &model.notices {526
ui.label(notice);527
}528
}529
Err(message) => {530
ui.colored_label(egui::Color32::LIGHT_RED, message);447
}531
}448
});532
});449
}533
}450
if let Some(index) = remove {451
self.presets.remove(index);452
}453
ui.horizontal(|ui| {454
ui.add(455
egui::TextEdit::singleline(&mut self.preset_input)456
.desired_width(280.0)457
.hint_text("presets/zwaard.bin"),458
);459
if ui.button("Voeg preset toe").clicked() {460
let value = self.preset_input.trim().to_string();461
self.add_preset(value);462
}463
});464
ui.horizontal(|ui| {465
ui.label("Uitvoer");466
ui.text_edit_singleline(&mut self.output);467
});468
ui.horizontal(|ui| {469
ui.label("Breedte");470
ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));471
ui.label("Hoogte");472
ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));473
ui.label("fps");474
ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));475
});476
ui.add_space(8.0);477
ui.label(478
"Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.",479
);480
}534
}481
535
482
fn show_browser(&mut self, ui: &mut egui::Ui) {536
fn show_body(&mut self, ctx: &egui::Context) {483
ui.heading("Bestanden");537
self.toolbar(ctx, true);484
let Some(opened) = self.opened.clone() else {538
let sex = match self.session.screen {485
ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");539
Screen::Body(sex) => sex,486
return;540
_ => Sex::Male,487
};541
};488
let root = opened542
self.controls.sex = match sex {489
.manifest_path543
Sex::Male => PreviewSex::Male,490
.parent()544
Sex::Female => PreviewSex::Female,491
.map(PathBuf::from)545
};492
.unwrap_or_default();546
egui::CentralPanel::default().show(ctx, |ui| {493
ui.label(if self.browser.is_empty() {547
if self.session.confirm.is_some() || self.dialog_pending {494
"projectmap".to_string()548
ui.disable();495
} else {496
self.browser.clone()497
});498
if !self.browser.is_empty() && ui.button("Omhoog").clicked() {499
self.browser = parent_relative(&self.browser);500
}501
if ui.button("Deze map als uitvoer").clicked() {502
if self.browser.is_empty() {503
self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");504
} else {505
self.output = self.browser.clone();506
self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");507
}549
}508
}550
ui.horizontal_wrapped(|ui| {509
let entries = match list_dir(&root, &self.browser) {551
if ui.button("Terug").clicked() {510
Ok(entries) => entries,552
self.session.show_bodies();511
Err(error) => {553
}512
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());554
if ui.button("Equipment").clicked() {513
return;555
self.session.show_equipment_list();556
}557
if ui558
.add_enabled(!self.render_ui.running, egui::Button::new("Render"))559
.clicked()560
{561
self.start_render([480, 640]);562
}563
if ui.button("Live 3D").clicked() {564
self.render_ui.preview = None;565
}566
});567
if self.render_ui.progress.total > 0 {568
let fraction = self.render_ui.progress.completed as f32569
/ self.render_ui.progress.total.max(1) as f32;570
ui.add(egui::ProgressBar::new(fraction).text(format!(571
"{} / {} {}",572
self.render_ui.progress.completed,573
self.render_ui.progress.total,574
self.render_ui.progress.label575
)));514
}576
}515
};577
if let Some(image) = &self.render_ui.preview {516
let mut enter = None;578
self.paint_pass(ui, image);517
let mut choose = None;518
for entry in &entries {519
let label = if entry.is_dir {520
format!("{}/", entry.name)521
} else {579
} else {522
entry.name.clone()580
let character = match self.controls.sex {523
};581
PreviewSex::Male => self.preview_male.as_ref(),524
if ui.button(label).clicked() {582
PreviewSex::Female => self.preview_female.as_ref(),525
if entry.is_dir {583
};526
enter = Some(entry.relative.clone());584
if character.is_some() {585
self.viewport.show(ui, &mut self.controls, character);527
} else {586
} else {528
choose = Some(entry.relative.clone());587
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");529
}588
}530
}589
}531
}590
});532
if let Some(relative) = enter {533
self.browser = relative;534
}535
if let Some(relative) = choose {536
self.assign_file(&relative);537
}538
}591
}539
}540
592
541
fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {593
fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {542
let Some(report) = report else {594
let color = egui::ColorImage::from_rgba_unmultiplied(543
return;595
[image.width as usize, image.height as usize],544
};596
&image.rgba,545
ui.group(|ui| {597
);546
ui.strong(title);598
let texture = ui.ctx().load_texture(547
match report {599
"render-pass",548
Ok(model) => {600
color,549
let format = match model.format {601
egui::TextureOptions {550
ModelFormat::Gltf => "glTF/GLB",602
magnification: egui::TextureFilter::Nearest,551
ModelFormat::Obj => "OBJ",603
minification: egui::TextureFilter::Nearest,552
ModelFormat::Fbx => "FBX",604
wrap_mode: egui::TextureWrapMode::ClampToEdge,553
};605
..Default::default()554
ui.label(format);606
},555
for mesh in &model.meshes {607
);556
ui.label(format!(608
let available = ui.available_size();557
"mesh {}: {} vertices, {} driehoeken{}",609
ui.image((texture.id(), available));558
mesh.name,610
}559
mesh.vertex_count,611
560
mesh.triangle_count,612
fn show_equipment_list(&mut self, ctx: &egui::Context) {561
if mesh.has_skin { ", skin" } else { "" }613
self.toolbar(ctx, true);562
));614
egui::CentralPanel::default().show(ctx, |ui| {615
if self.session.confirm.is_some() || self.dialog_pending {616
ui.disable();617
}618
ui.heading(format!(619
"Bodies → {} → Equipment",620
match self.session.active_body {621
Some(Sex::Male) => "Man",622
Some(Sex::Female) => "Vrouw",623
None => "Body",563
}624
}564
for material in &model.materials {625
));565
ui.label(format!(626
if ui.button("Terug naar body").clicked() {566
"materiaal {}{}",627
self.session.back();567
material.name,628
}568
material629
let mut open = None;569
.base_color_texture630
for (index, item) in self.session.equipment.iter().enumerate() {570
.as_ref()631
let label = match &item.model {571
.map(|name| format!(", texture {name}"))632
Some(model) => format!("{} {model}", item.slug),572
.unwrap_or_default()633
None => item.slug.clone(),573
));634
};635
if ui.button(label).clicked() {636
open = Some(index);574
}637
}575
for texture in &model.textures {638
}576
ui.label(format!(639
if ui.button("Nieuw").clicked() {577
"texture {} ({}x{})",640
self.session.add_equipment();578
texture.source, texture.width, texture.height641
}579
));642
if let Some(index) = open {643
self.session.show_equipment(index);644
}645
});646
}647
648
fn show_equipment(&mut self, ctx: &egui::Context) {649
self.toolbar(ctx, true);650
let Screen::Equipment(index) = self.session.screen else {651
return;652
};653
egui::CentralPanel::default().show(ctx, |ui| {654
if self.session.confirm.is_some() || self.dialog_pending {655
ui.disable();656
}657
ui.heading("Equipment");658
if ui.button("Terug").clicked() {659
self.session.show_equipment_list();660
}661
if let Some(item) = self.session.equipment.get_mut(index) {662
ui.horizontal(|ui| {663
ui.label("Naam");664
ui.text_edit_singleline(&mut item.slug);665
});666
let model = item667
.model668
.clone()669
.unwrap_or_else(|| "geen model".to_string());670
ui.label(model);671
}672
if ui.button("Kies model").clicked() {673
self.dialogs.start(DialogPurpose::EquipmentModel(index));674
}675
if ui.button("Verwijder").clicked() {676
self.session.delete_equipment(index);677
}678
});679
}680
681
fn toolbar(&mut self, ctx: &egui::Context, project: bool) {682
if !project {683
return;684
}685
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {686
if self.session.confirm.is_some() || self.dialog_pending {687
ui.disable();688
}689
ui.horizontal(|ui| {690
if ui.button("Body").clicked() {691
self.session.show_bodies();580
}692
}581
if model.joints.is_empty() {693
if ui.button("Opslaan").clicked() {582
ui.label("geen skeleton");694
self.save();583
} else {584
ui.label(format!("{} joints", model.joints.len()));585
for joint in model.joints.iter().take(8) {586
match &joint.parent {587
Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),588
None => ui.label(format!("{} (wortel)", joint.name)),589
};590
}591
}695
}592
if model.clips.is_empty() {696
if ui593
ui.label("geen animation clips");697
.add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))594
} else {698
.clicked()595
for clip in &model.clips {699
{596
ui.label(format!(700
self.begin(Intent::PickNew);597
"clip {} ({:.2} s, {} kanalen)",598
clip.name, clip.duration_seconds, clip.channel_count599
));600
}601
}701
}602
for notice in &model.notices {702
if ui603
ui.label(notice);703
.add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))704
.clicked()705
{706
self.begin(Intent::PickLoad);604
}707
}605
}708
});606
Err(message) => {709
});607
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);710
}608
}609
}610
});611
}711
}612
712
613
fn load_preview_path(root: &std::path::Path, relative: &str) -> Option<PreviewCharacter> {713
#[cfg(test)]614
let relative = relative.trim();714
mod tests {615
let lower = relative.to_ascii_lowercase();715
use super::*;616
if !(lower.ends_with(".glb") || lower.ends_with(".gltf")) {716
use crate::dialogs::ScriptedDialogs;617
return None;717
use crate::session::Choice;718
719
fn root() -> PathBuf {720
std::env::temp_dir().join(format!(721
"paperdoll-editor-{}",722
std::time::SystemTime::now()723
.duration_since(std::time::UNIX_EPOCH)724
.unwrap()725
.as_nanos()726
))618
}727
}619
load_gltf_file(&root.join(relative)).ok()620
}621
728
622
fn parent_relative(relative: &str) -> String {729
#[test]623
match relative.rfind('/') {730
fn picker_creates_project_and_cancelled_switch_preserves_edits() {624
Some(index) => relative[..index].to_string(),731
let root = root();625
None => String::new(),732
let mut dialogs = ScriptedDialogs::default();733
dialogs.push(Some(root.clone()));734
dialogs.push(None);735
let mut app = EditorApp::new(Box::new(dialogs));736
assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);737
app.begin(Intent::PickNew);738
app.poll_dialogs();739
assert_eq!(app.session.screen, Screen::Bodies);740
assert!(app.preview_male.is_some());741
assert!(app.preview_female.is_some());742
app.session.set_name("Keep my edit".into());743
app.begin(Intent::PickLoad);744
assert_eq!(app.session.confirm, Some(Intent::PickLoad));745
app.apply_choice(Choice::Discard, &egui::Context::default());746
app.poll_dialogs(); // OS picker cancel; no actual project switch happened747
assert_eq!(748
app.session.opened.as_ref().unwrap().document.name,749
"Keep my edit"750
);751
assert!(app.session.is_dirty());752
let _ = std::fs::remove_dir_all(root);626
}753
}627
}628
754
629
fn parse_u32(label: &str, text: &str) -> Result<u32, String> {755
#[test]630
let text = text.trim();756
fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {631
if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {757
let root = root();632
return Err(format!("{label} moet een geheel getal zijn"));758
let mut app = EditorApp::default();759
app.session.install_new_project(&root).unwrap();760
app.session.set_render(0, 128, 12);761
app.begin(Intent::Quit);762
app.apply_choice(Choice::Save, &egui::Context::default());763
assert!(!app.allow_close);764
assert_eq!(app.session.confirm, Some(Intent::Quit));765
assert!(app.session.is_dirty());766
app.open_path(root.join("missing.ppd"));767
assert!(app.session.opened.is_some());768
assert_eq!(app.session.screen, Screen::Bodies);769
let _ = std::fs::remove_dir_all(root);633
}770
}634
text.parse::<u32>()635
.map_err(|_| format!("{label} is te groot"))636
}637
771
638
impl Default for EditorApp {772
#[test]639
fn default() -> Self {773
fn open_with_project_path_loads_bodies_and_models() {640
Self::new()774
let root = root().join("project with spaces");775
let opened = crate::session::create_seeded_project(&root).unwrap();776
let mut app = EditorApp::default();777
app.open_path(opened.manifest_path.clone());778
assert_eq!(app.session.screen, Screen::Bodies);779
assert!(app.preview_male.is_some());780
assert_eq!(781
app.session.opened.unwrap().manifest_path,782
opened.manifest_path783
);784
let _ = std::fs::remove_dir_all(root.parent().unwrap());785
}786
787
#[test]788
fn renderer_disconnect_is_reported() {789
let mut app = EditorApp::default();790
let (tx, rx) = std::sync::mpsc::channel();791
app.render_rx = Some(rx);792
app.render_ui.running = true;793
drop(tx);794
app.drain_render();795
assert!(!app.render_ui.running);796
assert!(app.render_ui.error.is_some());641
}797
}642
}798
}@@ -0,0 +1,209 @@
1
//! Open a `.ppd` from the command line or the file manager.
2
//!
3
//! The manifest name stays `character.ppd`. Windows records the open command
4
//! for the current user the first time the program starts. Linux packages
5
//! install the shared MIME XML and a desktop file with `%f`.
6
7
use std::path::{Component, Path, PathBuf};
8
9
use crate::project::MANIFEST_NAME;
10
11
pub const MIME_TYPE: &str = "application/x-paperdoll-project";
12
pub const PROG_ID: &str = "Paperdoll.Project";
13
14
pub fn file_open_command(exe: &Path) -> String {
15
format!("\"{}\" \"%1\"", exe.display())
16
}
17
18
pub fn resolve_project_argument(base: &Path, arg: &str) -> Result<PathBuf, String> {
19
if arg.is_empty() || arg.chars().any(|ch| ch == '\0') {
20
return Err("het projectpad is leeg of bevat een leeg teken".to_string());
21
}
22
let joined = if Path::new(arg).is_absolute() {
23
PathBuf::from(arg)
24
} else {
25
base.join(arg)
26
};
27
let full = normalize(&joined);
28
let candidate = if full.is_dir() {
29
full.join(MANIFEST_NAME)
30
} else {
31
full
32
};
33
if candidate.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
34
return Err("open een .ppd-bestand of een projectmap met character.ppd".to_string());
35
}
36
if !candidate.is_file() {
37
return Err(format!("geen projectbestand op {}", candidate.display()));
38
}
39
Ok(candidate)
40
}
41
42
fn normalize(path: &Path) -> PathBuf {
43
let mut out = PathBuf::new();
44
for component in path.components() {
45
match component {
46
Component::CurDir => {}
47
Component::ParentDir => {
48
out.pop();
49
}
50
other => out.push(other.as_os_str()),
51
}
52
}
53
out
54
}
55
56
/// Writes the per-user open-with command. A missing write is reported and
57
/// does not stop the window.
58
#[cfg(windows)]
59
pub fn ensure_user_file_association(exe: &Path) -> Result<(), String> {
60
use winreg::enums::*;
61
use winreg::RegKey;
62
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
63
let (extension, _) = hkcu
64
.create_subkey("Software\\Classes\\.ppd")
65
.map_err(|err| err.to_string())?;
66
let (open_with, _) = extension
67
.create_subkey("OpenWithProgids")
68
.map_err(|err| err.to_string())?;
69
open_with
70
.set_value(PROG_ID, &String::new())
71
.map_err(|err| err.to_string())?;
72
extension
73
.set_value("Content Type", &MIME_TYPE)
74
.map_err(|err| err.to_string())?;
75
let (prog, _) = hkcu
76
.create_subkey(format!("Software\\Classes\\{PROG_ID}"))
77
.map_err(|err| err.to_string())?;
78
prog.set_value("", &"Paperdoll-project")
79
.map_err(|err| err.to_string())?;
80
let (icon, _) = prog
81
.create_subkey("DefaultIcon")
82
.map_err(|err| err.to_string())?;
83
icon.set_value("", &format!("\"{}\",0", exe.display()))
84
.map_err(|err| err.to_string())?;
85
let (command, _) = hkcu
86
.create_subkey(format!(
87
"Software\\Classes\\{PROG_ID}\\shell\\open\\command"
88
))
89
.map_err(|err| err.to_string())?;
90
command
91
.set_value("", &file_open_command(exe))
92
.map_err(|err| err.to_string())?;
93
Ok(())
94
}
95
96
/// Register an AppImage or standalone Linux binary for the current user.
97
/// Do not change the user's default application for an existing MIME type.
98
#[cfg(target_os = "linux")]
99
pub fn install_linux_integration(exe: &Path) -> Result<(), String> {
100
let data = std::env::var_os("XDG_DATA_HOME")
101
.map(PathBuf::from)
102
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
103
.ok_or("HOME of XDG_DATA_HOME ontbreekt")?;
104
write_linux_integration(&data, exe)?;
105
for (command, path) in [
106
("update-mime-database", data.join("mime")),
107
("update-desktop-database", data.join("applications")),
108
] {
109
let status = std::process::Command::new(command)
110
.arg(path)
111
.status()
112
.map_err(|e| format!("{command}: {e}"))?;
113
if !status.success() {
114
return Err(format!("{command} is mislukt"));
115
}
116
}
117
Ok(())
118
}
119
120
pub fn linux_desktop_entry(exe: &Path) -> Result<String, String> {
121
let path = exe.to_str().ok_or("het uitvoerbare pad moet UTF-8 zijn")?;
122
if !exe.is_absolute() || path.chars().any(|ch| ch.is_control()) {
123
return Err("kies een absoluut pad zonder stuurtekens".into());
124
}
125
// Desktop Exec quoting has an extra escape layer for reserved characters.
126
let escaped = path
127
.replace('\\', "\\\\\\\\")
128
.replace('"', "\\\\\\\"")
129
.replace('`', "\\\\`")
130
.replace('$', "\\\\$")
131
.replace('%', "%%");
132
Ok(format!("[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n"))
133
}
134
135
pub fn write_linux_integration(data: &Path, exe: &Path) -> Result<(), String> {
136
let desktop = linux_desktop_entry(exe)?;
137
let files: [(&str, &[u8]); 3] = [
138
(
139
"applications/paperdoll-renderer.desktop",
140
desktop.as_bytes(),
141
),
142
(
143
"mime/packages/paperdoll-project.xml",
144
include_bytes!("../packaging/linux/paperdoll-project.xml"),
145
),
146
(
147
"icons/hicolor/128x128/apps/paperdoll-renderer.png",
148
include_bytes!("../packaging/linux/hicolor/128x128/apps/paperdoll-renderer.png"),
149
),
150
];
151
for (relative, bytes) in files {
152
let path = data.join(relative);
153
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
154
std::fs::write(path, bytes).map_err(|e| e.to_string())?;
155
}
156
Ok(())
157
}
158
159
#[cfg(test)]
160
mod tests {
161
use super::*;
162
use std::fs;
163
164
#[test]
165
#[cfg(unix)]
166
fn desktop_entry_keeps_spaces_and_does_not_set_a_default() {
167
let entry =
168
linux_desktop_entry(Path::new("/home/test/My Tools/Paperdoll.AppImage")).unwrap();
169
assert!(entry.contains("Exec=\"/home/test/My Tools/Paperdoll.AppImage\" %f"));
170
assert!(entry.contains("MimeType=application/x-paperdoll-project;"));
171
assert!(linux_desktop_entry(Path::new("/bad\npath")).is_err());
172
assert!(linux_desktop_entry(Path::new("relative")).is_err());
173
let quoted = linux_desktop_entry(Path::new("/test/100% ready.AppImage")).unwrap();
174
assert!(quoted.contains("100%% ready"));
175
}
176
177
#[test]
178
fn open_command_quotes_the_executable_and_the_file() {
179
let command = file_open_command(Path::new(r"C:\Program Files\paperdoll-renderer.exe"));
180
assert!(command.contains(r"C:\Program Files\paperdoll-renderer.exe"));
181
assert!(command.ends_with("\"%1\""));
182
assert!(command.starts_with('"'));
183
}
184
185
#[test]
186
fn project_argument_accepts_spaces_relative_and_absolute_ppd() {
187
let root = std::env::temp_dir().join(format!("paperdoll open {}", std::process::id()));
188
let _ = fs::remove_dir_all(&root);
189
fs::create_dir_all(root.join("sub")).unwrap();
190
let manifest = root.join(MANIFEST_NAME);
191
fs::write(&manifest, b"{}").unwrap();
192
let relative = resolve_project_argument(
193
root.parent().unwrap(),
194
root.file_name().unwrap().to_str().unwrap(),
195
)
196
.unwrap();
197
assert_eq!(relative, manifest);
198
let direct = resolve_project_argument(&root, "character.ppd").unwrap();
199
assert_eq!(direct, manifest);
200
let absolute =
201
resolve_project_argument(Path::new("/"), manifest.to_str().unwrap()).unwrap();
202
assert_eq!(absolute, manifest);
203
let missing = resolve_project_argument(&root, "nope.ppd");
204
assert!(missing.is_err());
205
let model = resolve_project_argument(&root, "body.glb");
206
assert!(model.is_err());
207
let _ = fs::remove_dir_all(&root);
208
}
209
}
@@ -0,0 +1,209 @@1
//! Open a `.ppd` from the command line or the file manager.2
//!3
//! The manifest name stays `character.ppd`. Windows records the open command4
//! for the current user the first time the program starts. Linux packages5
//! install the shared MIME XML and a desktop file with `%f`.6
7
use std::path::{Component, Path, PathBuf};8
9
use crate::project::MANIFEST_NAME;10
11
pub const MIME_TYPE: &str = "application/x-paperdoll-project";12
pub const PROG_ID: &str = "Paperdoll.Project";13
14
pub fn file_open_command(exe: &Path) -> String {15
format!("\"{}\" \"%1\"", exe.display())16
}17
18
pub fn resolve_project_argument(base: &Path, arg: &str) -> Result<PathBuf, String> {19
if arg.is_empty() || arg.chars().any(|ch| ch == '\0') {20
return Err("het projectpad is leeg of bevat een leeg teken".to_string());21
}22
let joined = if Path::new(arg).is_absolute() {23
PathBuf::from(arg)24
} else {25
base.join(arg)26
};27
let full = normalize(&joined);28
let candidate = if full.is_dir() {29
full.join(MANIFEST_NAME)30
} else {31
full32
};33
if candidate.extension().and_then(|ext| ext.to_str()) != Some("ppd") {34
return Err("open een .ppd-bestand of een projectmap met character.ppd".to_string());35
}36
if !candidate.is_file() {37
return Err(format!("geen projectbestand op {}", candidate.display()));38
}39
Ok(candidate)40
}41
42
fn normalize(path: &Path) -> PathBuf {43
let mut out = PathBuf::new();44
for component in path.components() {45
match component {46
Component::CurDir => {}47
Component::ParentDir => {48
out.pop();49
}50
other => out.push(other.as_os_str()),51
}52
}53
out54
}55
56
/// Writes the per-user open-with command. A missing write is reported and57
/// does not stop the window.58
#[cfg(windows)]59
pub fn ensure_user_file_association(exe: &Path) -> Result<(), String> {60
use winreg::enums::*;61
use winreg::RegKey;62
let hkcu = RegKey::predef(HKEY_CURRENT_USER);63
let (extension, _) = hkcu64
.create_subkey("Software\\Classes\\.ppd")65
.map_err(|err| err.to_string())?;66
let (open_with, _) = extension67
.create_subkey("OpenWithProgids")68
.map_err(|err| err.to_string())?;69
open_with70
.set_value(PROG_ID, &String::new())71
.map_err(|err| err.to_string())?;72
extension73
.set_value("Content Type", &MIME_TYPE)74
.map_err(|err| err.to_string())?;75
let (prog, _) = hkcu76
.create_subkey(format!("Software\\Classes\\{PROG_ID}"))77
.map_err(|err| err.to_string())?;78
prog.set_value("", &"Paperdoll-project")79
.map_err(|err| err.to_string())?;80
let (icon, _) = prog81
.create_subkey("DefaultIcon")82
.map_err(|err| err.to_string())?;83
icon.set_value("", &format!("\"{}\",0", exe.display()))84
.map_err(|err| err.to_string())?;85
let (command, _) = hkcu86
.create_subkey(format!(87
"Software\\Classes\\{PROG_ID}\\shell\\open\\command"88
))89
.map_err(|err| err.to_string())?;90
command91
.set_value("", &file_open_command(exe))92
.map_err(|err| err.to_string())?;93
Ok(())94
}95
96
/// Register an AppImage or standalone Linux binary for the current user.97
/// Do not change the user's default application for an existing MIME type.98
#[cfg(target_os = "linux")]99
pub fn install_linux_integration(exe: &Path) -> Result<(), String> {100
let data = std::env::var_os("XDG_DATA_HOME")101
.map(PathBuf::from)102
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))103
.ok_or("HOME of XDG_DATA_HOME ontbreekt")?;104
write_linux_integration(&data, exe)?;105
for (command, path) in [106
("update-mime-database", data.join("mime")),107
("update-desktop-database", data.join("applications")),108
] {109
let status = std::process::Command::new(command)110
.arg(path)111
.status()112
.map_err(|e| format!("{command}: {e}"))?;113
if !status.success() {114
return Err(format!("{command} is mislukt"));115
}116
}117
Ok(())118
}119
120
pub fn linux_desktop_entry(exe: &Path) -> Result<String, String> {121
let path = exe.to_str().ok_or("het uitvoerbare pad moet UTF-8 zijn")?;122
if !exe.is_absolute() || path.chars().any(|ch| ch.is_control()) {123
return Err("kies een absoluut pad zonder stuurtekens".into());124
}125
// Desktop Exec quoting has an extra escape layer for reserved characters.126
let escaped = path127
.replace('\\', "\\\\\\\\")128
.replace('"', "\\\\\\\"")129
.replace('`', "\\\\`")130
.replace('$', "\\\\$")131
.replace('%', "%%");132
Ok(format!("[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n"))133
}134
135
pub fn write_linux_integration(data: &Path, exe: &Path) -> Result<(), String> {136
let desktop = linux_desktop_entry(exe)?;137
let files: [(&str, &[u8]); 3] = [138
(139
"applications/paperdoll-renderer.desktop",140
desktop.as_bytes(),141
),142
(143
"mime/packages/paperdoll-project.xml",144
include_bytes!("../packaging/linux/paperdoll-project.xml"),145
),146
(147
"icons/hicolor/128x128/apps/paperdoll-renderer.png",148
include_bytes!("../packaging/linux/hicolor/128x128/apps/paperdoll-renderer.png"),149
),150
];151
for (relative, bytes) in files {152
let path = data.join(relative);153
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;154
std::fs::write(path, bytes).map_err(|e| e.to_string())?;155
}156
Ok(())157
}158
159
#[cfg(test)]160
mod tests {161
use super::*;162
use std::fs;163
164
#[test]165
#[cfg(unix)]166
fn desktop_entry_keeps_spaces_and_does_not_set_a_default() {167
let entry =168
linux_desktop_entry(Path::new("/home/test/My Tools/Paperdoll.AppImage")).unwrap();169
assert!(entry.contains("Exec=\"/home/test/My Tools/Paperdoll.AppImage\" %f"));170
assert!(entry.contains("MimeType=application/x-paperdoll-project;"));171
assert!(linux_desktop_entry(Path::new("/bad\npath")).is_err());172
assert!(linux_desktop_entry(Path::new("relative")).is_err());173
let quoted = linux_desktop_entry(Path::new("/test/100% ready.AppImage")).unwrap();174
assert!(quoted.contains("100%% ready"));175
}176
177
#[test]178
fn open_command_quotes_the_executable_and_the_file() {179
let command = file_open_command(Path::new(r"C:\Program Files\paperdoll-renderer.exe"));180
assert!(command.contains(r"C:\Program Files\paperdoll-renderer.exe"));181
assert!(command.ends_with("\"%1\""));182
assert!(command.starts_with('"'));183
}184
185
#[test]186
fn project_argument_accepts_spaces_relative_and_absolute_ppd() {187
let root = std::env::temp_dir().join(format!("paperdoll open {}", std::process::id()));188
let _ = fs::remove_dir_all(&root);189
fs::create_dir_all(root.join("sub")).unwrap();190
let manifest = root.join(MANIFEST_NAME);191
fs::write(&manifest, b"{}").unwrap();192
let relative = resolve_project_argument(193
root.parent().unwrap(),194
root.file_name().unwrap().to_str().unwrap(),195
)196
.unwrap();197
assert_eq!(relative, manifest);198
let direct = resolve_project_argument(&root, "character.ppd").unwrap();199
assert_eq!(direct, manifest);200
let absolute =201
resolve_project_argument(Path::new("/"), manifest.to_str().unwrap()).unwrap();202
assert_eq!(absolute, manifest);203
let missing = resolve_project_argument(&root, "nope.ppd");204
assert!(missing.is_err());205
let model = resolve_project_argument(&root, "body.glb");206
assert!(model.is_err());207
let _ = fs::remove_dir_all(&root);208
}209
}@@ -24,9 +24,11 @@ pub enum Availability {
24
24
}
25
25
26
26
/// What the process should do with the command line it received.
27
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
#[derive(Debug, Clone, PartialEq, Eq)]
28
28
pub enum CliRequest {
29
29
OpenWindow,
30
InstallDesktop,
31
OpenProject(String),
30
32
PrintStatus,
31
33
PrintHelp,
32
34
PreviewFixtures,
@@ -37,6 +39,7 @@ pub enum CliRequest {
37
39
#[derive(Debug, Clone, PartialEq, Eq)]
38
40
pub enum CliError {
39
41
UnknownArgument(String),
42
Usage(String),
40
43
}
41
44
42
45
const CAPABILITIES: [Capability; 8] = [
@@ -108,14 +111,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
108
111
}
109
112
110
113
pub fn dutch_summary() -> &'static str {
111
"Deze build leest glTF/GLB, FBX en OBJ+MTL, en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
114
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
112
115
}
113
116
114
117
/// Stable status text shared by `--status` and the window.
115
118
pub fn report() -> String {
116
119
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
117
120
lines.push("name: Paperdoll Renderer".to_string());
118
lines.push("phase: live-3d-preview".to_string());
121
lines.push("phase: project-editor".to_string());
119
122
for capability in CAPABILITIES {
120
123
lines.push(format!(
121
124
"{}: {}",
@@ -139,7 +142,7 @@ pub const LINUX_WINDOW_LIBRARIES: &[&str] = &[
139
142
140
143
pub fn missing_library_message(missing: &[&str]) -> String {
141
144
format!(
142
"het statusvenster kan niet openen; deze bibliotheken ontbreken: {}. Import en render zijn niet gebouwd.",
145
"het venster kan niet openen; deze bibliotheken ontbreken: {}.",
143
146
missing.join(", ")
144
147
)
145
148
}
@@ -159,42 +162,70 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
159
162
160
163
pub fn help_text() -> &'static str {
161
164
"\
162
Paperdoll Renderer — modelimport
165
Paperdoll Renderer
163
166
164
167
Gebruik:
165
paperdoll-renderer open het projectvenster
166
paperdoll-renderer --status print de status en sluit
167
paperdoll-renderer --help toon deze tekst
168
169
paperdoll-renderer --preview-fixture open de 3D-preview met de testlichamen
168
paperdoll-renderer startscherm
169
paperdoll-renderer pad/naar/character.ppd open dat project
170
paperdoll-renderer --install-desktop registreer Openen met op Linux
171
paperdoll-renderer --status print de status en sluit
172
paperdoll-renderer --help toon deze tekst
173
paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam
170
174
171
Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."
175
--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd."
172
176
}
173
177
174
178
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
175
179
where
176
180
I: IntoIterator<Item = &'a str>,
177
181
{
178
let mut request = CliRequest::OpenWindow;
182
let args: Vec<&str> = args.into_iter().collect();
183
if args == ["--install-desktop"] {
184
return Ok(CliRequest::InstallDesktop);
185
}
186
let mut help = false;
187
let mut status = false;
188
let mut preview = false;
189
let mut project = None;
179
190
for arg in args {
180
191
match arg {
181
"--help" | "-h" => request = CliRequest::PrintHelp,
182
"--preview-fixture" => {
183
if request != CliRequest::PrintHelp {
184
request = CliRequest::PreviewFixtures;
185
}
186
}
187
"--status" => {
188
if request != CliRequest::PrintHelp {
189
request = CliRequest::PrintStatus;
190
}
192
"--help" | "-h" => help = true,
193
"--status" => status = true,
194
"--preview-fixture" => preview = true,
195
other if other.starts_with('-') => {
196
return Err(CliError::UnknownArgument(other.to_string()));
191
197
}
192
198
other => {
193
return Err(CliError::UnknownArgument(other.to_string()));
199
if project.is_some() {
200
return Err(CliError::Usage("geef één projectpad".to_string()));
201
}
202
project = Some(other.to_string());
194
203
}
195
204
}
196
205
}
197
Ok(request)
206
if help {
207
return Ok(CliRequest::PrintHelp);
208
}
209
if status && (preview || project.is_some()) {
210
return Err(CliError::Usage(
211
"--status staat alleen en neemt geen projectpad aan".to_string(),
212
));
213
}
214
if status {
215
return Ok(CliRequest::PrintStatus);
216
}
217
if preview && project.is_some() {
218
return Err(CliError::Usage(
219
"--preview-fixture opent het voorbeeld en neemt geen projectpad aan".to_string(),
220
));
221
}
222
if preview {
223
return Ok(CliRequest::PreviewFixtures);
224
}
225
if let Some(path) = project {
226
return Ok(CliRequest::OpenProject(path));
227
}
228
Ok(CliRequest::OpenWindow)
198
229
}
199
230
200
231
#[cfg(test)]
@@ -233,7 +264,7 @@ mod tests {
233
264
#[test]
234
265
fn status_report_matches_the_import_build() {
235
266
let report = report();
236
assert!(report.starts_with("name: Paperdoll Renderer\nphase: live-3d-preview\n"));
267
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
237
268
for key in [
238
269
"project_shell",
239
270
"obj_mtl_textures",
@@ -269,9 +300,15 @@ mod tests {
269
300
assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));
270
301
assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));
271
302
assert_eq!(
272
parse_cli(["tests/fixtures/bodies/test-body-male.glb"]),
273
Err(CliError::UnknownArgument(
274
"tests/fixtures/bodies/test-body-male.glb".to_string()
303
parse_cli(["mijn project/character.ppd"]),
304
Ok(CliRequest::OpenProject(
305
"mijn project/character.ppd".to_string()
306
))
307
);
308
assert_eq!(
309
parse_cli(["--status", "character.ppd"]),
310
Err(CliError::Usage(
311
"--status staat alleen en neemt geen projectpad aan".to_string()
275
312
))
276
313
);
277
314
assert_eq!(
@@ -289,8 +326,8 @@ mod tests {
289
326
fn missing_library_message_names_the_library_and_does_not_claim_import() {
290
327
let message = missing_library_message(&["libxkbcommon-x11.so.0"]);
291
328
assert!(message.contains("libxkbcommon-x11.so.0"));
292
assert!(message.contains("niet gebouwd"));
293
assert!(!message.to_lowercase().contains("import werkt"));
329
assert!(!message.to_lowercase().contains("import"));
330
assert!(!message.to_lowercase().contains("niet gebouwd"));
294
331
assert!(!LINUX_WINDOW_LIBRARIES.is_empty());
295
332
}
296
333
}
@@ -24,9 +24,11 @@ pub enum Availability {24
}24
}25
25
26
/// What the process should do with the command line it received.26
/// What the process should do with the command line it received.27
#[derive(Debug, Clone, Copy, PartialEq, Eq)]27
#[derive(Debug, Clone, PartialEq, Eq)]28
pub enum CliRequest {28
pub enum CliRequest {29
OpenWindow,29
OpenWindow,30
InstallDesktop,31
OpenProject(String),30
PrintStatus,32
PrintStatus,31
PrintHelp,33
PrintHelp,32
PreviewFixtures,34
PreviewFixtures,@@ -37,6 +39,7 @@ pub enum CliRequest {37
#[derive(Debug, Clone, PartialEq, Eq)]39
#[derive(Debug, Clone, PartialEq, Eq)]38
pub enum CliError {40
pub enum CliError {39
UnknownArgument(String),41
UnknownArgument(String),42
Usage(String),40
}43
}41
44
42
const CAPABILITIES: [Capability; 8] = [45
const CAPABILITIES: [Capability; 8] = [@@ -108,14 +111,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {108
}111
}109
112
110
pub fn dutch_summary() -> &'static str {113
pub fn dutch_summary() -> &'static str {111
"Deze build leest glTF/GLB, FBX en OBJ+MTL, en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."114
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."112
}115
}113
116
114
/// Stable status text shared by `--status` and the window.117
/// Stable status text shared by `--status` and the window.115
pub fn report() -> String {118
pub fn report() -> String {116
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());119
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());117
lines.push("name: Paperdoll Renderer".to_string());120
lines.push("name: Paperdoll Renderer".to_string());118
lines.push("phase: live-3d-preview".to_string());121
lines.push("phase: project-editor".to_string());119
for capability in CAPABILITIES {122
for capability in CAPABILITIES {120
lines.push(format!(123
lines.push(format!(121
"{}: {}",124
"{}: {}",@@ -139,7 +142,7 @@ pub const LINUX_WINDOW_LIBRARIES: &[&str] = &[139
142
140
pub fn missing_library_message(missing: &[&str]) -> String {143
pub fn missing_library_message(missing: &[&str]) -> String {141
format!(144
format!(142
"het statusvenster kan niet openen; deze bibliotheken ontbreken: {}. Import en render zijn niet gebouwd.",145
"het venster kan niet openen; deze bibliotheken ontbreken: {}.",143
missing.join(", ")146
missing.join(", ")144
)147
)145
}148
}@@ -159,42 +162,70 @@ pub fn missing_window_libraries() -> Vec<&'static str> {159
162
160
pub fn help_text() -> &'static str {163
pub fn help_text() -> &'static str {161
"\164
"\162
Paperdoll Renderer — modelimport165
Paperdoll Renderer163
166
164
Gebruik:167
Gebruik:165
paperdoll-renderer open het projectvenster168
paperdoll-renderer startscherm166
paperdoll-renderer --status print de status en sluit169
paperdoll-renderer pad/naar/character.ppd open dat project167
paperdoll-renderer --help toon deze tekst170
paperdoll-renderer --install-desktop registreer Openen met op Linux168
171
paperdoll-renderer --status print de status en sluit169
paperdoll-renderer --preview-fixture open de 3D-preview met de testlichamen172
paperdoll-renderer --help toon deze tekst173
paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam170
174
171
Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."175
--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd."172
}176
}173
177
174
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>178
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>175
where179
where176
I: IntoIterator<Item = &'a str>,180
I: IntoIterator<Item = &'a str>,177
{181
{178
let mut request = CliRequest::OpenWindow;182
let args: Vec<&str> = args.into_iter().collect();183
if args == ["--install-desktop"] {184
return Ok(CliRequest::InstallDesktop);185
}186
let mut help = false;187
let mut status = false;188
let mut preview = false;189
let mut project = None;179
for arg in args {190
for arg in args {180
match arg {191
match arg {181
"--help" | "-h" => request = CliRequest::PrintHelp,192
"--help" | "-h" => help = true,182
"--preview-fixture" => {193
"--status" => status = true,183
if request != CliRequest::PrintHelp {194
"--preview-fixture" => preview = true,184
request = CliRequest::PreviewFixtures;195
other if other.starts_with('-') => {185
}196
return Err(CliError::UnknownArgument(other.to_string()));186
}187
"--status" => {188
if request != CliRequest::PrintHelp {189
request = CliRequest::PrintStatus;190
}191
}197
}192
other => {198
other => {193
return Err(CliError::UnknownArgument(other.to_string()));199
if project.is_some() {200
return Err(CliError::Usage("geef één projectpad".to_string()));201
}202
project = Some(other.to_string());194
}203
}195
}204
}196
}205
}197
Ok(request)206
if help {207
return Ok(CliRequest::PrintHelp);208
}209
if status && (preview || project.is_some()) {210
return Err(CliError::Usage(211
"--status staat alleen en neemt geen projectpad aan".to_string(),212
));213
}214
if status {215
return Ok(CliRequest::PrintStatus);216
}217
if preview && project.is_some() {218
return Err(CliError::Usage(219
"--preview-fixture opent het voorbeeld en neemt geen projectpad aan".to_string(),220
));221
}222
if preview {223
return Ok(CliRequest::PreviewFixtures);224
}225
if let Some(path) = project {226
return Ok(CliRequest::OpenProject(path));227
}228
Ok(CliRequest::OpenWindow)198
}229
}199
230
200
#[cfg(test)]231
#[cfg(test)]@@ -233,7 +264,7 @@ mod tests {233
#[test]264
#[test]234
fn status_report_matches_the_import_build() {265
fn status_report_matches_the_import_build() {235
let report = report();266
let report = report();236
assert!(report.starts_with("name: Paperdoll Renderer\nphase: live-3d-preview\n"));267
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));237
for key in [268
for key in [238
"project_shell",269
"project_shell",239
"obj_mtl_textures",270
"obj_mtl_textures",@@ -269,9 +300,15 @@ mod tests {269
assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));300
assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));270
assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));301
assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));271
assert_eq!(302
assert_eq!(272
parse_cli(["tests/fixtures/bodies/test-body-male.glb"]),303
parse_cli(["mijn project/character.ppd"]),273
Err(CliError::UnknownArgument(304
Ok(CliRequest::OpenProject(274
"tests/fixtures/bodies/test-body-male.glb".to_string()305
"mijn project/character.ppd".to_string()306
))307
);308
assert_eq!(309
parse_cli(["--status", "character.ppd"]),310
Err(CliError::Usage(311
"--status staat alleen en neemt geen projectpad aan".to_string()275
))312
))276
);313
);277
assert_eq!(314
assert_eq!(@@ -289,8 +326,8 @@ mod tests {289
fn missing_library_message_names_the_library_and_does_not_claim_import() {326
fn missing_library_message_names_the_library_and_does_not_claim_import() {290
let message = missing_library_message(&["libxkbcommon-x11.so.0"]);327
let message = missing_library_message(&["libxkbcommon-x11.so.0"]);291
assert!(message.contains("libxkbcommon-x11.so.0"));328
assert!(message.contains("libxkbcommon-x11.so.0"));292
assert!(message.contains("niet gebouwd"));329
assert!(!message.to_lowercase().contains("import"));293
assert!(!message.to_lowercase().contains("import werkt"));330
assert!(!message.to_lowercase().contains("niet gebouwd"));294
assert!(!LINUX_WINDOW_LIBRARIES.is_empty());331
assert!(!LINUX_WINDOW_LIBRARIES.is_empty());295
}332
}296
}333
}@@ -0,0 +1,248 @@
1
//! Application icon supplied by Tim Falken.
2
//!
3
//! `SOURCE_PNG` is his original file. The window and the package bitmaps are
4
//! scaled from it; the drawing is not replaced.
5
6
use std::io::Cursor;
7
8
/// Tim Falken's original icon, unchanged.
9
pub const SOURCE_PNG: &[u8] = include_bytes!("../assets/icons/paperdoll-renderer.png");
10
11
/// Freedesktop hicolor sizes installed by the Linux packages.
12
pub const HICOLOR_SIZES: [u32; 9] = [16, 22, 24, 32, 48, 64, 128, 256, 512];
13
14
/// Square RGBA icon for the eframe window.
15
///
16
/// 128 is a multiple of 4. A 256 image plus the X11 width and height words
17
/// exceeds the classic 64 KiB request limit, so the property would be empty.
18
pub fn window_icon_rgba() -> (u32, u32, Vec<u8>) {
19
const SIZE: u32 = 128;
20
(SIZE, SIZE, hicolor_rgba(SIZE))
21
}
22
23
/// Square RGBA raster at `size`, scaled from Tim's PNG.
24
pub fn hicolor_rgba(size: u32) -> Vec<u8> {
25
let (width, height, pixels) = decode_rgba(SOURCE_PNG).expect("application icon png");
26
let (side, square) = pad_square(&pixels, width, height);
27
resize_premultiplied(&square, side, side, size, size)
28
}
29
30
pub fn decode_rgba(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
31
let mut decoder = png::Decoder::new(Cursor::new(bytes));
32
decoder.set_transformations(png::Transformations::EXPAND);
33
let mut reader = decoder.read_info().map_err(|err| err.to_string())?;
34
let mut buffer = vec![0; reader.output_buffer_size().ok_or("png has no frame")?];
35
let info = reader
36
.next_frame(&mut buffer)
37
.map_err(|err| err.to_string())?;
38
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
39
return Err(format!(
40
"icon must decode to 8-bit rgba, got {:?} {:?}",
41
info.color_type, info.bit_depth
42
));
43
}
44
buffer.truncate(info.buffer_size());
45
if buffer.len() != (info.width as usize) * (info.height as usize) * 4 {
46
return Err("icon frame size does not match rgba pixels".into());
47
}
48
Ok((info.width, info.height, buffer))
49
}
50
51
fn pad_square(pixels: &[u8], width: u32, height: u32) -> (u32, Vec<u8>) {
52
let side = width.max(height);
53
let mut canvas = vec![0; (side as usize) * (side as usize) * 4];
54
let ox = (side - width) / 2;
55
let oy = (side - height) / 2;
56
for y in 0..height {
57
for x in 0..width {
58
let src = ((y * width + x) * 4) as usize;
59
let dst = (((y + oy) * side + (x + ox)) * 4) as usize;
60
canvas[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]);
61
}
62
}
63
(side, canvas)
64
}
65
66
/// Lanczos-3 resize of straight RGBA. Channels are resampled premultiplied
67
/// so the soft edge of the source stays the same drawing.
68
fn resize_premultiplied(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
69
let mut premul = vec![0.0; src.len()];
70
for pixel in src.chunks_exact(4).zip(premul.chunks_exact_mut(4)) {
71
let (rgba, out) = pixel;
72
let alpha = rgba[3] as f32 / 255.0;
73
out[0] = rgba[0] as f32 / 255.0 * alpha;
74
out[1] = rgba[1] as f32 / 255.0 * alpha;
75
out[2] = rgba[2] as f32 / 255.0 * alpha;
76
out[3] = alpha;
77
}
78
let horizontal = resample_x(&premul, sw, sh, dw);
79
let vertical = resample_y(&horizontal, dw, sh, dh);
80
let mut out = vec![0; (dw as usize) * (dh as usize) * 4];
81
for (pixel, dst) in vertical.chunks_exact(4).zip(out.chunks_exact_mut(4)) {
82
let alpha = pixel[3];
83
if alpha <= 1.0 / 512.0 {
84
continue;
85
}
86
dst[0] = to_byte(pixel[0] / alpha);
87
dst[1] = to_byte(pixel[1] / alpha);
88
dst[2] = to_byte(pixel[2] / alpha);
89
dst[3] = to_byte(alpha);
90
}
91
out
92
}
93
94
fn to_byte(value: f32) -> u8 {
95
(value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
96
}
97
98
fn resample_x(src: &[f32], sw: u32, sh: u32, dw: u32) -> Vec<f32> {
99
let (filter_scale, support) = filter_scale(dw, sw);
100
let mut out = vec![0.0; (dw as usize) * (sh as usize) * 4];
101
for y in 0..sh {
102
for x in 0..dw {
103
let center = (x as f32 + 0.5) * (sw as f32) / (dw as f32) - 0.5;
104
let (acc, weight) = accumulate(center, support, filter_scale, sw, |index| {
105
let base = ((y * sw + index) * 4) as usize;
106
[src[base], src[base + 1], src[base + 2], src[base + 3]]
107
});
108
write_sample(&mut out, ((y * dw + x) * 4) as usize, acc, weight);
109
}
110
}
111
out
112
}
113
114
fn resample_y(src: &[f32], sw: u32, sh: u32, dh: u32) -> Vec<f32> {
115
let (filter_scale, support) = filter_scale(dh, sh);
116
let mut out = vec![0.0; (sw as usize) * (dh as usize) * 4];
117
for y in 0..dh {
118
let center = (y as f32 + 0.5) * (sh as f32) / (dh as f32) - 0.5;
119
for x in 0..sw {
120
let (acc, weight) = accumulate(center, support, filter_scale, sh, |index| {
121
let base = ((index * sw + x) * 4) as usize;
122
[src[base], src[base + 1], src[base + 2], src[base + 3]]
123
});
124
write_sample(&mut out, ((y * sw + x) * 4) as usize, acc, weight);
125
}
126
}
127
out
128
}
129
130
fn filter_scale(dst: u32, src: u32) -> (f32, f32) {
131
let scale = dst as f32 / src as f32;
132
if scale < 1.0 {
133
(scale, 3.0 / scale)
134
} else {
135
(1.0, 3.0)
136
}
137
}
138
139
fn accumulate(
140
center: f32,
141
support: f32,
142
filter_scale: f32,
143
limit: u32,
144
sample: impl Fn(u32) -> [f32; 4],
145
) -> ([f32; 4], f32) {
146
let left = (center - support).floor() as i32;
147
let right = (center + support).ceil() as i32;
148
let mut acc = [0.0; 4];
149
let mut weight = 0.0;
150
for index in left..=right {
151
let distance = (index as f32 - center).abs();
152
let kernel = lanczos3(distance * filter_scale);
153
if kernel == 0.0 {
154
continue;
155
}
156
let clamped = index.clamp(0, limit as i32 - 1) as u32;
157
let pixel = sample(clamped);
158
for channel in 0..4 {
159
acc[channel] += pixel[channel] * kernel;
160
}
161
weight += kernel;
162
}
163
(acc, weight)
164
}
165
166
fn write_sample(out: &mut [f32], base: usize, acc: [f32; 4], weight: f32) {
167
if weight.abs() <= f32::EPSILON {
168
return;
169
}
170
for channel in 0..4 {
171
out[base + channel] = acc[channel] / weight;
172
}
173
}
174
175
fn lanczos3(x: f32) -> f32 {
176
let x = x.abs();
177
if x < 1.0e-6 {
178
return 1.0;
179
}
180
if x >= 3.0 {
181
return 0.0;
182
}
183
let pi_x = std::f32::consts::PI * x;
184
(pi_x.sin() / pi_x) * ((pi_x / 3.0).sin() / (pi_x / 3.0))
185
}
186
187
#[cfg(test)]
188
mod tests {
189
use super::{
190
decode_rgba, hicolor_rgba, pad_square, resize_premultiplied, window_icon_rgba, SOURCE_PNG,
191
};
192
193
#[test]
194
fn source_png_is_the_57_by_60_original() {
195
let (width, height, pixels) = decode_rgba(SOURCE_PNG).unwrap();
196
assert_eq!((width, height), (57, 60));
197
assert_eq!(pixels.len(), 57 * 60 * 4);
198
assert_eq!(SOURCE_PNG.len(), 3050);
199
}
200
201
#[test]
202
fn pad_keeps_every_source_pixel() {
203
let (width, height, pixels) = decode_rgba(SOURCE_PNG).unwrap();
204
let (side, canvas) = pad_square(&pixels, width, height);
205
assert_eq!(side, 60);
206
let ox = (side - width) / 2;
207
for y in 0..height {
208
for x in 0..width {
209
let src = ((y * width + x) * 4) as usize;
210
let dst = ((y * side + x + ox) * 4) as usize;
211
assert_eq!(&canvas[dst..dst + 4], &pixels[src..src + 4]);
212
}
213
}
214
}
215
216
#[test]
217
fn flat_opaque_color_stays_flat() {
218
let src = vec![
219
10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255,
220
];
221
let out = resize_premultiplied(&src, 2, 2, 5, 4);
222
for pixel in out.chunks_exact(4) {
223
assert!((pixel[0] as i32 - 10).abs() <= 1, "{pixel:?}");
224
assert!((pixel[1] as i32 - 20).abs() <= 1, "{pixel:?}");
225
assert!((pixel[2] as i32 - 30).abs() <= 1, "{pixel:?}");
226
assert_eq!(pixel[3], 255);
227
}
228
}
229
230
#[test]
231
fn clear_pixels_stay_clear() {
232
let out = resize_premultiplied(&[0; 16], 2, 2, 8, 8);
233
assert!(out.iter().all(|byte| *byte == 0));
234
}
235
236
#[test]
237
fn window_size_is_square_and_still_a_light_figure() {
238
let pixels = hicolor_rgba(64);
239
assert_eq!(pixels.len(), 64 * 64 * 4);
240
let center = (32 * 64 + 32) * 4;
241
assert!(pixels[center + 3] > 200);
242
assert!(pixels[center] > 200);
243
let corner = pixels[3];
244
assert_eq!(corner, 0);
245
let (width, height, window) = window_icon_rgba();
246
assert_eq!((width, height, window.len()), (128, 128, 128 * 128 * 4));
247
}
248
}
@@ -0,0 +1,248 @@1
//! Application icon supplied by Tim Falken.2
//!3
//! `SOURCE_PNG` is his original file. The window and the package bitmaps are4
//! scaled from it; the drawing is not replaced.5
6
use std::io::Cursor;7
8
/// Tim Falken's original icon, unchanged.9
pub const SOURCE_PNG: &[u8] = include_bytes!("../assets/icons/paperdoll-renderer.png");10
11
/// Freedesktop hicolor sizes installed by the Linux packages.12
pub const HICOLOR_SIZES: [u32; 9] = [16, 22, 24, 32, 48, 64, 128, 256, 512];13
14
/// Square RGBA icon for the eframe window.15
///16
/// 128 is a multiple of 4. A 256 image plus the X11 width and height words17
/// exceeds the classic 64 KiB request limit, so the property would be empty.18
pub fn window_icon_rgba() -> (u32, u32, Vec<u8>) {19
const SIZE: u32 = 128;20
(SIZE, SIZE, hicolor_rgba(SIZE))21
}22
23
/// Square RGBA raster at `size`, scaled from Tim's PNG.24
pub fn hicolor_rgba(size: u32) -> Vec<u8> {25
let (width, height, pixels) = decode_rgba(SOURCE_PNG).expect("application icon png");26
let (side, square) = pad_square(&pixels, width, height);27
resize_premultiplied(&square, side, side, size, size)28
}29
30
pub fn decode_rgba(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {31
let mut decoder = png::Decoder::new(Cursor::new(bytes));32
decoder.set_transformations(png::Transformations::EXPAND);33
let mut reader = decoder.read_info().map_err(|err| err.to_string())?;34
let mut buffer = vec![0; reader.output_buffer_size().ok_or("png has no frame")?];35
let info = reader36
.next_frame(&mut buffer)37
.map_err(|err| err.to_string())?;38
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {39
return Err(format!(40
"icon must decode to 8-bit rgba, got {:?} {:?}",41
info.color_type, info.bit_depth42
));43
}44
buffer.truncate(info.buffer_size());45
if buffer.len() != (info.width as usize) * (info.height as usize) * 4 {46
return Err("icon frame size does not match rgba pixels".into());47
}48
Ok((info.width, info.height, buffer))49
}50
51
fn pad_square(pixels: &[u8], width: u32, height: u32) -> (u32, Vec<u8>) {52
let side = width.max(height);53
let mut canvas = vec![0; (side as usize) * (side as usize) * 4];54
let ox = (side - width) / 2;55
let oy = (side - height) / 2;56
for y in 0..height {57
for x in 0..width {58
let src = ((y * width + x) * 4) as usize;59
let dst = (((y + oy) * side + (x + ox)) * 4) as usize;60
canvas[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]);61
}62
}63
(side, canvas)64
}65
66
/// Lanczos-3 resize of straight RGBA. Channels are resampled premultiplied67
/// so the soft edge of the source stays the same drawing.68
fn resize_premultiplied(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {69
let mut premul = vec![0.0; src.len()];70
for pixel in src.chunks_exact(4).zip(premul.chunks_exact_mut(4)) {71
let (rgba, out) = pixel;72
let alpha = rgba[3] as f32 / 255.0;73
out[0] = rgba[0] as f32 / 255.0 * alpha;74
out[1] = rgba[1] as f32 / 255.0 * alpha;75
out[2] = rgba[2] as f32 / 255.0 * alpha;76
out[3] = alpha;77
}78
let horizontal = resample_x(&premul, sw, sh, dw);79
let vertical = resample_y(&horizontal, dw, sh, dh);80
let mut out = vec![0; (dw as usize) * (dh as usize) * 4];81
for (pixel, dst) in vertical.chunks_exact(4).zip(out.chunks_exact_mut(4)) {82
let alpha = pixel[3];83
if alpha <= 1.0 / 512.0 {84
continue;85
}86
dst[0] = to_byte(pixel[0] / alpha);87
dst[1] = to_byte(pixel[1] / alpha);88
dst[2] = to_byte(pixel[2] / alpha);89
dst[3] = to_byte(alpha);90
}91
out92
}93
94
fn to_byte(value: f32) -> u8 {95
(value.clamp(0.0, 1.0) * 255.0 + 0.5) as u896
}97
98
fn resample_x(src: &[f32], sw: u32, sh: u32, dw: u32) -> Vec<f32> {99
let (filter_scale, support) = filter_scale(dw, sw);100
let mut out = vec![0.0; (dw as usize) * (sh as usize) * 4];101
for y in 0..sh {102
for x in 0..dw {103
let center = (x as f32 + 0.5) * (sw as f32) / (dw as f32) - 0.5;104
let (acc, weight) = accumulate(center, support, filter_scale, sw, |index| {105
let base = ((y * sw + index) * 4) as usize;106
[src[base], src[base + 1], src[base + 2], src[base + 3]]107
});108
write_sample(&mut out, ((y * dw + x) * 4) as usize, acc, weight);109
}110
}111
out112
}113
114
fn resample_y(src: &[f32], sw: u32, sh: u32, dh: u32) -> Vec<f32> {115
let (filter_scale, support) = filter_scale(dh, sh);116
let mut out = vec![0.0; (sw as usize) * (dh as usize) * 4];117
for y in 0..dh {118
let center = (y as f32 + 0.5) * (sh as f32) / (dh as f32) - 0.5;119
for x in 0..sw {120
let (acc, weight) = accumulate(center, support, filter_scale, sh, |index| {121
let base = ((index * sw + x) * 4) as usize;122
[src[base], src[base + 1], src[base + 2], src[base + 3]]123
});124
write_sample(&mut out, ((y * sw + x) * 4) as usize, acc, weight);125
}126
}127
out128
}129
130
fn filter_scale(dst: u32, src: u32) -> (f32, f32) {131
let scale = dst as f32 / src as f32;132
if scale < 1.0 {133
(scale, 3.0 / scale)134
} else {135
(1.0, 3.0)136
}137
}138
139
fn accumulate(140
center: f32,141
support: f32,142
filter_scale: f32,143
limit: u32,144
sample: impl Fn(u32) -> [f32; 4],145
) -> ([f32; 4], f32) {146
let left = (center - support).floor() as i32;147
let right = (center + support).ceil() as i32;148
let mut acc = [0.0; 4];149
let mut weight = 0.0;150
for index in left..=right {151
let distance = (index as f32 - center).abs();152
let kernel = lanczos3(distance * filter_scale);153
if kernel == 0.0 {154
continue;155
}156
let clamped = index.clamp(0, limit as i32 - 1) as u32;157
let pixel = sample(clamped);158
for channel in 0..4 {159
acc[channel] += pixel[channel] * kernel;160
}161
weight += kernel;162
}163
(acc, weight)164
}165
166
fn write_sample(out: &mut [f32], base: usize, acc: [f32; 4], weight: f32) {167
if weight.abs() <= f32::EPSILON {168
return;169
}170
for channel in 0..4 {171
out[base + channel] = acc[channel] / weight;172
}173
}174
175
fn lanczos3(x: f32) -> f32 {176
let x = x.abs();177
if x < 1.0e-6 {178
return 1.0;179
}180
if x >= 3.0 {181
return 0.0;182
}183
let pi_x = std::f32::consts::PI * x;184
(pi_x.sin() / pi_x) * ((pi_x / 3.0).sin() / (pi_x / 3.0))185
}186
187
#[cfg(test)]188
mod tests {189
use super::{190
decode_rgba, hicolor_rgba, pad_square, resize_premultiplied, window_icon_rgba, SOURCE_PNG,191
};192
193
#[test]194
fn source_png_is_the_57_by_60_original() {195
let (width, height, pixels) = decode_rgba(SOURCE_PNG).unwrap();196
assert_eq!((width, height), (57, 60));197
assert_eq!(pixels.len(), 57 * 60 * 4);198
assert_eq!(SOURCE_PNG.len(), 3050);199
}200
201
#[test]202
fn pad_keeps_every_source_pixel() {203
let (width, height, pixels) = decode_rgba(SOURCE_PNG).unwrap();204
let (side, canvas) = pad_square(&pixels, width, height);205
assert_eq!(side, 60);206
let ox = (side - width) / 2;207
for y in 0..height {208
for x in 0..width {209
let src = ((y * width + x) * 4) as usize;210
let dst = ((y * side + x + ox) * 4) as usize;211
assert_eq!(&canvas[dst..dst + 4], &pixels[src..src + 4]);212
}213
}214
}215
216
#[test]217
fn flat_opaque_color_stays_flat() {218
let src = vec![219
10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255,220
];221
let out = resize_premultiplied(&src, 2, 2, 5, 4);222
for pixel in out.chunks_exact(4) {223
assert!((pixel[0] as i32 - 10).abs() <= 1, "{pixel:?}");224
assert!((pixel[1] as i32 - 20).abs() <= 1, "{pixel:?}");225
assert!((pixel[2] as i32 - 30).abs() <= 1, "{pixel:?}");226
assert_eq!(pixel[3], 255);227
}228
}229
230
#[test]231
fn clear_pixels_stay_clear() {232
let out = resize_premultiplied(&[0; 16], 2, 2, 8, 8);233
assert!(out.iter().all(|byte| *byte == 0));234
}235
236
#[test]237
fn window_size_is_square_and_still_a_light_figure() {238
let pixels = hicolor_rgba(64);239
assert_eq!(pixels.len(), 64 * 64 * 4);240
let center = (32 * 64 + 32) * 4;241
assert!(pixels[center + 3] > 200);242
assert!(pixels[center] > 200);243
let corner = pixels[3];244
assert_eq!(corner, 0);245
let (width, height, window) = window_icon_rgba();246
assert_eq!((width, height, window.len()), (128, 128, 128 * 128 * 4));247
}248
}@@ -1,11 +1,17 @@
1
//! Foundation library for the Paperdoll Renderer shell.
1
//! Paperdoll Renderer library.
2
2
//!
3
//! The shell stores a `.ppd` project and reads glTF/GLB plus OBJ+MTL.
4
//! It does not render paperdolls and it does not play skeletal animation.
3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4
//! and draws a live skinned glTF preview.
5
5
6
pub mod dialogs;
6
7
pub mod editor;
8
pub mod file_assoc;
7
9
pub mod foundation;
10
pub mod icon;
8
11
pub mod import;
9
12
pub mod preview;
10
13
pub mod preview_gl;
11
14
pub mod project;
15
pub mod render_job;
16
pub mod session;
17
pub mod still;
@@ -1,11 +1,17 @@1
//! Foundation library for the Paperdoll Renderer shell.1
//! Paperdoll Renderer library.2
//!2
//!3
//! The shell stores a `.ppd` project and reads glTF/GLB plus OBJ+MTL.3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,4
//! It does not render paperdolls and it does not play skeletal animation.4
//! and draws a live skinned glTF preview.5
5
6
pub mod dialogs;6
pub mod editor;7
pub mod editor;8
pub mod file_assoc;7
pub mod foundation;9
pub mod foundation;10
pub mod icon;8
pub mod import;11
pub mod import;9
pub mod preview;12
pub mod preview;10
pub mod preview_gl;13
pub mod preview_gl;11
pub mod project;14
pub mod project;15
pub mod render_job;16
pub mod session;17
pub mod still;@@ -11,6 +11,32 @@ fn main() -> ExitCode {
11
11
let args: Vec<String> = std::env::args().skip(1).collect();
12
12
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
13
13
match parse_cli(borrowed) {
14
Ok(CliRequest::InstallDesktop) => {
15
#[cfg(target_os = "linux")]
16
{
17
let exe = std::env::var_os("APPIMAGE")
18
.map(std::path::PathBuf::from)
19
.or_else(|| std::env::current_exe().ok());
20
match exe
21
.ok_or_else(|| "geen uitvoerbaar pad".to_string())
22
.and_then(|exe| paperdoll_renderer::file_assoc::install_linux_integration(&exe))
23
{
24
Ok(()) => {
25
println!("Openen met is geregistreerd.");
26
ExitCode::SUCCESS
27
}
28
Err(error) => {
29
eprintln!("{error}");
30
ExitCode::FAILURE
31
}
32
}
33
}
34
#[cfg(not(target_os = "linux"))]
35
{
36
eprintln!("--install-desktop is voor Linux");
37
ExitCode::from(2)
38
}
39
}
14
40
Ok(CliRequest::PrintHelp) => {
15
41
println!("{}", help_text());
16
42
ExitCode::SUCCESS
@@ -19,8 +45,15 @@ fn main() -> ExitCode {
19
45
println!("{}", report());
20
46
ExitCode::SUCCESS
21
47
}
22
Ok(CliRequest::PreviewFixtures) => open_window(true),
23
Ok(CliRequest::OpenWindow) => open_window(false),
48
Ok(CliRequest::PreviewFixtures) => open_window(true, None),
49
Ok(CliRequest::OpenWindow) => open_window(false, None),
50
Ok(CliRequest::OpenProject(path)) => {
51
open_window(false, Some(std::path::PathBuf::from(path)))
52
}
53
Err(CliError::Usage(message)) => {
54
eprintln!("paperdoll-renderer: {message}");
55
ExitCode::from(2)
56
}
24
57
Err(CliError::UnknownArgument(arg)) => {
25
58
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");
26
59
eprintln!("Gebruik --help.");
@@ -29,12 +62,18 @@ fn main() -> ExitCode {
29
62
}
30
63
}
31
64
32
fn open_window(fixtures: bool) -> ExitCode {
65
fn open_window(fixtures: bool, path: Option<std::path::PathBuf>) -> ExitCode {
66
#[cfg(windows)]
67
if let Ok(exe) = std::env::current_exe() {
68
if let Err(error) = paperdoll_renderer::file_assoc::ensure_user_file_association(&exe) {
69
eprintln!("Openen met registreren: {error}");
70
}
71
}
33
72
if let Some(message) = window_preflight() {
34
73
eprintln!("paperdoll-renderer: {message}");
35
74
return ExitCode::from(1);
36
75
}
37
match run_window(fixtures) {
76
match run_window(fixtures, path) {
38
77
Ok(()) => ExitCode::SUCCESS,
39
78
Err(err) => {
40
79
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");
@@ -61,12 +100,18 @@ fn window_preflight() -> Option<String> {
61
100
}
62
101
}
63
102
64
fn run_window(fixtures: bool) -> eframe::Result<()> {
103
fn run_window(fixtures: bool, path: Option<std::path::PathBuf>) -> eframe::Result<()> {
104
let (width, height, rgba) = paperdoll_renderer::icon::window_icon_rgba();
65
105
let options = eframe::NativeOptions {
66
106
viewport: eframe::egui::ViewportBuilder::default()
67
107
.with_title("Paperdoll Renderer")
68
.with_inner_size([1180.0, 760.0])
69
.with_min_inner_size([900.0, 560.0]),
108
.with_inner_size(paperdoll_renderer::session::START_WINDOW)
109
.with_min_inner_size([420.0, 220.0])
110
.with_icon(eframe::egui::IconData {
111
rgba,
112
width,
113
height,
114
}),
70
115
..Default::default()
71
116
};
72
117
eframe::run_native(
@@ -75,7 +120,10 @@ fn run_window(fixtures: bool) -> eframe::Result<()> {
75
120
Box::new(move |_creation| {
76
121
let mut app = EditorApp::default();
77
122
if fixtures {
78
app.load_fixture_bodies();
123
app.open_fixture_project();
124
}
125
if let Some(path) = path {
126
app.open_path(path);
79
127
}
80
128
Ok(Box::new(app))
81
129
}),
@@ -11,6 +11,32 @@ fn main() -> ExitCode {11
let args: Vec<String> = std::env::args().skip(1).collect();11
let args: Vec<String> = std::env::args().skip(1).collect();12
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();12
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();13
match parse_cli(borrowed) {13
match parse_cli(borrowed) {14
Ok(CliRequest::InstallDesktop) => {15
#[cfg(target_os = "linux")]16
{17
let exe = std::env::var_os("APPIMAGE")18
.map(std::path::PathBuf::from)19
.or_else(|| std::env::current_exe().ok());20
match exe21
.ok_or_else(|| "geen uitvoerbaar pad".to_string())22
.and_then(|exe| paperdoll_renderer::file_assoc::install_linux_integration(&exe))23
{24
Ok(()) => {25
println!("Openen met is geregistreerd.");26
ExitCode::SUCCESS27
}28
Err(error) => {29
eprintln!("{error}");30
ExitCode::FAILURE31
}32
}33
}34
#[cfg(not(target_os = "linux"))]35
{36
eprintln!("--install-desktop is voor Linux");37
ExitCode::from(2)38
}39
}14
Ok(CliRequest::PrintHelp) => {40
Ok(CliRequest::PrintHelp) => {15
println!("{}", help_text());41
println!("{}", help_text());16
ExitCode::SUCCESS42
ExitCode::SUCCESS@@ -19,8 +45,15 @@ fn main() -> ExitCode {19
println!("{}", report());45
println!("{}", report());20
ExitCode::SUCCESS46
ExitCode::SUCCESS21
}47
}22
Ok(CliRequest::PreviewFixtures) => open_window(true),48
Ok(CliRequest::PreviewFixtures) => open_window(true, None),23
Ok(CliRequest::OpenWindow) => open_window(false),49
Ok(CliRequest::OpenWindow) => open_window(false, None),50
Ok(CliRequest::OpenProject(path)) => {51
open_window(false, Some(std::path::PathBuf::from(path)))52
}53
Err(CliError::Usage(message)) => {54
eprintln!("paperdoll-renderer: {message}");55
ExitCode::from(2)56
}24
Err(CliError::UnknownArgument(arg)) => {57
Err(CliError::UnknownArgument(arg)) => {25
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");58
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");26
eprintln!("Gebruik --help.");59
eprintln!("Gebruik --help.");@@ -29,12 +62,18 @@ fn main() -> ExitCode {29
}62
}30
}63
}31
64
32
fn open_window(fixtures: bool) -> ExitCode {65
fn open_window(fixtures: bool, path: Option<std::path::PathBuf>) -> ExitCode {66
#[cfg(windows)]67
if let Ok(exe) = std::env::current_exe() {68
if let Err(error) = paperdoll_renderer::file_assoc::ensure_user_file_association(&exe) {69
eprintln!("Openen met registreren: {error}");70
}71
}33
if let Some(message) = window_preflight() {72
if let Some(message) = window_preflight() {34
eprintln!("paperdoll-renderer: {message}");73
eprintln!("paperdoll-renderer: {message}");35
return ExitCode::from(1);74
return ExitCode::from(1);36
}75
}37
match run_window(fixtures) {76
match run_window(fixtures, path) {38
Ok(()) => ExitCode::SUCCESS,77
Ok(()) => ExitCode::SUCCESS,39
Err(err) => {78
Err(err) => {40
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");79
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");@@ -61,12 +100,18 @@ fn window_preflight() -> Option<String> {61
}100
}62
}101
}63
102
64
fn run_window(fixtures: bool) -> eframe::Result<()> {103
fn run_window(fixtures: bool, path: Option<std::path::PathBuf>) -> eframe::Result<()> {104
let (width, height, rgba) = paperdoll_renderer::icon::window_icon_rgba();65
let options = eframe::NativeOptions {105
let options = eframe::NativeOptions {66
viewport: eframe::egui::ViewportBuilder::default()106
viewport: eframe::egui::ViewportBuilder::default()67
.with_title("Paperdoll Renderer")107
.with_title("Paperdoll Renderer")68
.with_inner_size([1180.0, 760.0])108
.with_inner_size(paperdoll_renderer::session::START_WINDOW)69
.with_min_inner_size([900.0, 560.0]),109
.with_min_inner_size([420.0, 220.0])110
.with_icon(eframe::egui::IconData {111
rgba,112
width,113
height,114
}),70
..Default::default()115
..Default::default()71
};116
};72
eframe::run_native(117
eframe::run_native(@@ -75,7 +120,10 @@ fn run_window(fixtures: bool) -> eframe::Result<()> {75
Box::new(move |_creation| {120
Box::new(move |_creation| {76
let mut app = EditorApp::default();121
let mut app = EditorApp::default();77
if fixtures {122
if fixtures {78
app.load_fixture_bodies();123
app.open_fixture_project();124
}125
if let Some(path) = path {126
app.open_path(path);79
}127
}80
Ok(Box::new(app))128
Ok(Box::new(app))81
}),129
}),@@ -40,23 +40,24 @@ impl Viewport {
40
40
character: Option<&PreviewCharacter>,
41
41
) {
42
42
ui.horizontal_wrapped(|ui| {
43
if ui
44
.selectable_label(controls.sex == crate::preview::PreviewSex::Male, "Man")
45
.clicked()
46
{
47
controls.sex = crate::preview::PreviewSex::Male;
48
}
49
if ui
50
.selectable_label(controls.sex == crate::preview::PreviewSex::Female, "Vrouw")
51
.clicked()
52
{
53
controls.sex = crate::preview::PreviewSex::Female;
54
}
55
if ui.button("walk").clicked() {
56
controls.select_clip("walk");
57
}
58
if ui.button("attack").clicked() {
59
controls.select_clip("attack");
43
if let Some(character) = character {
44
if !character
45
.clips
46
.iter()
47
.any(|clip| clip.name == controls.clip_name)
48
{
49
if let Some(clip) = character.clips.first() {
50
controls.select_clip(&clip.name);
51
}
52
}
53
for clip in &character.clips {
54
if ui
55
.selectable_label(controls.clip_name == clip.name, &clip.name)
56
.clicked()
57
{
58
controls.select_clip(&clip.name);
59
}
60
}
60
61
}
61
62
if ui
62
63
.selectable_label(
@@ -156,9 +157,20 @@ impl Viewport {
156
157
}
157
158
158
159
fn character_generation(character: &PreviewCharacter) -> u64 {
159
character.positions.len() as u64
160
^ ((character.indices.len() as u64) << 16)
161
^ ((character.base_color[0].to_bits() as u64) << 32)
160
use std::hash::{Hash, Hasher};
161
let mut hash = std::collections::hash_map::DefaultHasher::new();
162
for point in &character.positions {
163
point.map(f32::to_bits).hash(&mut hash);
164
}
165
for normal in &character.normals {
166
normal.map(f32::to_bits).hash(&mut hash);
167
}
168
for weights in &character.weights {
169
weights.map(f32::to_bits).hash(&mut hash);
170
}
171
character.joints.hash(&mut hash);
172
character.indices.hash(&mut hash);
173
hash.finish()
162
174
}
163
175
164
176
#[derive(Clone)]
@@ -40,23 +40,24 @@ impl Viewport {40
character: Option<&PreviewCharacter>,40
character: Option<&PreviewCharacter>,41
) {41
) {42
ui.horizontal_wrapped(|ui| {42
ui.horizontal_wrapped(|ui| {43
if ui43
if let Some(character) = character {44
.selectable_label(controls.sex == crate::preview::PreviewSex::Male, "Man")44
if !character45
.clicked()45
.clips46
{46
.iter()47
controls.sex = crate::preview::PreviewSex::Male;47
.any(|clip| clip.name == controls.clip_name)48
}48
{49
if ui49
if let Some(clip) = character.clips.first() {50
.selectable_label(controls.sex == crate::preview::PreviewSex::Female, "Vrouw")50
controls.select_clip(&clip.name);51
.clicked()51
}52
{52
}53
controls.sex = crate::preview::PreviewSex::Female;53
for clip in &character.clips {54
}54
if ui55
if ui.button("walk").clicked() {55
.selectable_label(controls.clip_name == clip.name, &clip.name)56
controls.select_clip("walk");56
.clicked()57
}57
{58
if ui.button("attack").clicked() {58
controls.select_clip(&clip.name);59
controls.select_clip("attack");59
}60
}60
}61
}61
if ui62
if ui62
.selectable_label(63
.selectable_label(@@ -156,9 +157,20 @@ impl Viewport {156
}157
}157
158
158
fn character_generation(character: &PreviewCharacter) -> u64 {159
fn character_generation(character: &PreviewCharacter) -> u64 {159
character.positions.len() as u64160
use std::hash::{Hash, Hasher};160
^ ((character.indices.len() as u64) << 16)161
let mut hash = std::collections::hash_map::DefaultHasher::new();161
^ ((character.base_color[0].to_bits() as u64) << 32)162
for point in &character.positions {163
point.map(f32::to_bits).hash(&mut hash);164
}165
for normal in &character.normals {166
normal.map(f32::to_bits).hash(&mut hash);167
}168
for weights in &character.weights {169
weights.map(f32::to_bits).hash(&mut hash);170
}171
character.joints.hash(&mut hash);172
character.indices.hash(&mut hash);173
hash.finish()162
}174
}163
175
164
#[derive(Clone)]176
#[derive(Clone)]@@ -552,7 +552,7 @@ fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError>
552
552
}
553
553
}
554
554
555
fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
555
pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
556
556
let path = root.join(relative);
557
557
if path.exists() && !path.is_dir() {
558
558
return Err(ProjectError::Validation(vec![format!(
@@ -552,7 +552,7 @@ fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError>552
}552
}553
}553
}554
554
555
fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {555
pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {556
let path = root.join(relative);556
let path = root.join(relative);557
if path.exists() && !path.is_dir() {557
if path.exists() && !path.is_dir() {558
return Err(ProjectError::Validation(vec![format!(558
return Err(ProjectError::Validation(vec![format!(@@ -0,0 +1,511 @@
1
//! Render queue for one body still.
2
//!
3
//! The worker walks the design order and emits an event after every pass.
4
//! The full production order is recorded, but this still renderer executes
5
//! and counts only its two real color passes. The GUI may coalesce events.
6
7
use std::path::{Path, PathBuf};
8
use std::sync::mpsc::{self, Receiver};
9
use std::thread::{self, JoinHandle};
10
11
use crate::preview::{
12
paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,
13
};
14
use crate::still::raster_body;
15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
pub enum PassKind {
18
HighresImage,
19
DetailMap,
20
TargetResolution,
21
Diffuse,
22
Depth,
23
Normal,
24
Shiny,
25
RimHighlight,
26
}
27
28
impl PassKind {
29
pub fn label(self) -> &'static str {
30
match self {
31
Self::HighresImage => "highres",
32
Self::DetailMap => "detail",
33
Self::TargetResolution => "doelresolutie",
34
Self::Diffuse => "diffuse",
35
Self::Depth => "depth",
36
Self::Normal => "normal",
37
Self::Shiny => "shiny",
38
Self::RimHighlight => "rim",
39
}
40
}
41
42
pub fn produces_image(self) -> bool {
43
matches!(self, Self::HighresImage | Self::TargetResolution)
44
}
45
}
46
47
pub const PASS_ORDER: [PassKind; 8] = [
48
PassKind::HighresImage,
49
PassKind::DetailMap,
50
PassKind::TargetResolution,
51
PassKind::Diffuse,
52
PassKind::Depth,
53
PassKind::Normal,
54
PassKind::Shiny,
55
PassKind::RimHighlight,
56
];
57
58
#[derive(Debug, Clone, Copy, PartialEq)]
59
pub struct ViewAngle {
60
pub pitch_deg: f32,
61
pub yaw_deg: f32,
62
}
63
64
#[derive(Debug, Clone, Copy, PartialEq)]
65
pub struct PassStep {
66
pub pitch_deg: f32,
67
pub yaw_deg: f32,
68
pub kind: PassKind,
69
}
70
71
#[derive(Debug, Clone, PartialEq, Eq)]
72
pub struct ImageBuf {
73
pub width: u32,
74
pub height: u32,
75
pub rgba: Vec<u8>,
76
}
77
78
#[derive(Debug, Clone, PartialEq, Eq)]
79
pub struct Progress {
80
pub completed: u32,
81
pub total: u32,
82
pub label: String,
83
}
84
85
#[derive(Debug, Clone, PartialEq, Eq)]
86
pub enum RenderEvent {
87
Step {
88
index: u32,
89
total: u32,
90
label: String,
91
},
92
Pass {
93
index: u32,
94
total: u32,
95
label: String,
96
image: Option<ImageBuf>,
97
},
98
Failed(String),
99
Finished {
100
saved: Option<String>,
101
},
102
}
103
104
#[derive(Debug, Clone, PartialEq, Eq)]
105
pub struct RenderUiState {
106
pub progress: Progress,
107
pub preview: Option<ImageBuf>,
108
pub saved: Option<String>,
109
pub error: Option<String>,
110
pub running: bool,
111
}
112
113
impl RenderUiState {
114
pub fn idle() -> Self {
115
Self {
116
progress: Progress {
117
completed: 0,
118
total: 0,
119
label: String::new(),
120
},
121
preview: None,
122
saved: None,
123
error: None,
124
running: false,
125
}
126
}
127
128
pub fn apply(&mut self, event: RenderEvent) {
129
match event {
130
RenderEvent::Step {
131
index,
132
total,
133
label,
134
} => {
135
self.progress = Progress {
136
completed: index,
137
total,
138
label,
139
};
140
self.running = true;
141
}
142
RenderEvent::Pass {
143
index,
144
total,
145
label,
146
image,
147
} => {
148
self.progress = Progress {
149
completed: index + 1,
150
total,
151
label,
152
};
153
if let Some(image) = image {
154
self.preview = Some(image);
155
}
156
self.running = true;
157
}
158
RenderEvent::Failed(message) => {
159
self.error = Some(message);
160
self.running = false;
161
}
162
RenderEvent::Finished { saved } => {
163
self.saved = saved;
164
self.running = false;
165
if self.progress.total > 0 {
166
self.progress.completed = self.progress.total;
167
}
168
}
169
}
170
}
171
}
172
173
pub fn export_views() -> Vec<ViewAngle> {
174
let mut views = Vec::with_capacity(PAPERDOLL_PITCHES_DEG.len() * paperdoll_yaws_deg().len());
175
for pitch in PAPERDOLL_PITCHES_DEG {
176
for yaw in paperdoll_yaws_deg() {
177
views.push(ViewAngle {
178
pitch_deg: pitch,
179
yaw_deg: yaw,
180
});
181
}
182
}
183
views
184
}
185
186
pub fn pass_sequence(views: &[ViewAngle]) -> Vec<PassStep> {
187
let mut steps = Vec::with_capacity(views.len() * PASS_ORDER.len());
188
for view in views {
189
for kind in PASS_ORDER {
190
steps.push(PassStep {
191
pitch_deg: view.pitch_deg,
192
yaw_deg: view.yaw_deg,
193
kind,
194
});
195
}
196
}
197
steps
198
}
199
200
pub fn highres_extent(target_width: u32, target_height: u32) -> (u32, u32) {
201
let width = target_width.max(1);
202
let height = target_height.max(1);
203
let mut scale = 8u32;
204
while scale > 1 && (width.saturating_mul(scale) > 1024 || height.saturating_mul(scale) > 1024) {
205
scale -= 1;
206
}
207
(width * scale, height * scale)
208
}
209
210
/// Nearest-neighbor stretch. Every destination pixel is filled, so the
211
/// preview box has no empty border.
212
pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
213
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
214
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
215
return out;
216
}
217
for y in 0..dh {
218
let sy = ((y as u64 * sh as u64) / dh as u64) as u32;
219
let sy = sy.min(sh - 1);
220
for x in 0..dw {
221
let sx = ((x as u64 * sw as u64) / dw as u64) as u32;
222
let sx = sx.min(sw - 1);
223
let from = ((sy * sw + sx) * 4) as usize;
224
let to = ((y * dw + x) * 4) as usize;
225
out[to..to + 4].copy_from_slice(&src[from..from + 4]);
226
}
227
}
228
out
229
}
230
231
pub struct RenderJob {
232
pub character: PreviewCharacter,
233
pub controls: PreviewControls,
234
pub target_width: u32,
235
pub target_height: u32,
236
pub preview_width: u32,
237
pub preview_height: u32,
238
pub output: PathBuf,
239
}
240
241
impl RenderJob {
242
pub fn steps(&self) -> Vec<PassStep> {
243
pass_sequence(&[ViewAngle {
244
pitch_deg: self.controls.pitch_deg,
245
yaw_deg: self.controls.yaw_deg,
246
}])
247
}
248
}
249
250
pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {
251
if job.target_width == 0
252
|| job.target_height == 0
253
|| job.target_width > crate::project::MAX_DIMENSION
254
|| job.target_height > crate::project::MAX_DIMENSION
255
{
256
emit(RenderEvent::Failed(
257
"ongeldige afmetingen voor de render".into(),
258
));
259
return;
260
}
261
// Count only implemented passes. Never report a skipped map as completed.
262
let steps: Vec<_> = job
263
.steps()
264
.into_iter()
265
.filter(|step| step.kind.produces_image())
266
.collect();
267
let total = steps.len() as u32;
268
let mut highres: Option<ImageBuf> = None;
269
let mut saved = None;
270
for (index, step) in steps.iter().enumerate() {
271
let index = index as u32;
272
let label = format!(
273
"pitch {:.0} yaw {:.0} · {}",
274
step.pitch_deg,
275
step.yaw_deg,
276
step.kind.label()
277
);
278
emit(RenderEvent::Step {
279
index,
280
total,
281
label: label.clone(),
282
});
283
if !step.kind.produces_image() {
284
emit(RenderEvent::Pass {
285
index,
286
total,
287
label,
288
image: None,
289
});
290
continue;
291
}
292
let image = match step.kind {
293
PassKind::HighresImage => {
294
let mut controls = job.controls.clone();
295
controls.pitch_deg = step.pitch_deg;
296
controls.yaw_deg = step.yaw_deg;
297
let (width, height) = highres_extent(job.target_width, job.target_height);
298
match raster_body(&job.character, &controls, width, height) {
299
Ok(rgba) => {
300
let image = ImageBuf {
301
width,
302
height,
303
rgba,
304
};
305
highres = Some(image.clone());
306
Some(image)
307
}
308
Err(message) => {
309
emit(RenderEvent::Failed(message));
310
return;
311
}
312
}
313
}
314
PassKind::TargetResolution => {
315
let Some(source) = highres.as_ref() else {
316
emit(RenderEvent::Failed(
317
"de doelresolutie volgt op het highres-beeld".to_string(),
318
));
319
return;
320
};
321
let rgba = nearest_stretch(
322
&source.rgba,
323
source.width,
324
source.height,
325
job.target_width.max(1),
326
job.target_height.max(1),
327
);
328
let target = ImageBuf {
329
width: job.target_width.max(1),
330
height: job.target_height.max(1),
331
rgba,
332
};
333
match write_png(&job.output, &target) {
334
Ok(()) => saved = Some(job.output.display().to_string()),
335
Err(message) => {
336
emit(RenderEvent::Failed(message));
337
return;
338
}
339
}
340
let (box_w, box_h) = (job.preview_width.max(1), job.preview_height.max(1));
341
Some(ImageBuf {
342
width: box_w,
343
height: box_h,
344
rgba: nearest_stretch(&target.rgba, target.width, target.height, box_w, box_h),
345
})
346
}
347
_ => None,
348
};
349
emit(RenderEvent::Pass {
350
index,
351
total,
352
label,
353
image,
354
});
355
}
356
emit(RenderEvent::Finished { saved });
357
}
358
359
pub struct RenderThread {
360
pub rx: Receiver<RenderEvent>,
361
pub handle: JoinHandle<()>,
362
}
363
364
pub fn spawn(job: RenderJob) -> RenderThread {
365
let (tx, rx) = mpsc::channel();
366
let handle = thread::spawn(move || {
367
execute(&job, &mut |event| {
368
let _ = tx.send(event);
369
});
370
});
371
RenderThread { rx, handle }
372
}
373
374
fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> {
375
if let Some(parent) = path.parent() {
376
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
377
}
378
let stamp = std::time::SystemTime::now()
379
.duration_since(std::time::UNIX_EPOCH)
380
.unwrap_or_default()
381
.as_nanos();
382
let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id()));
383
let file = std::fs::OpenOptions::new()
384
.create_new(true)
385
.write(true)
386
.open(&temp)
387
.map_err(|err| err.to_string())?;
388
let mut encoder = png::Encoder::new(file, image.width, image.height);
389
encoder.set_color(png::ColorType::Rgba);
390
encoder.set_depth(png::BitDepth::Eight);
391
let mut writer = encoder.write_header().map_err(|err| err.to_string())?;
392
writer
393
.write_image_data(&image.rgba)
394
.map_err(|err| err.to_string())?;
395
drop(writer);
396
std::fs::rename(&temp, path).map_err(|err| err.to_string())?;
397
Ok(())
398
}
399
400
#[cfg(test)]
401
mod tests {
402
use super::*;
403
use crate::preview::load_gltf_file;
404
use std::time::{SystemTime, UNIX_EPOCH};
405
406
#[test]
407
fn full_view_order_is_pitch_then_yaw_then_passes() {
408
let views = export_views();
409
assert_eq!(views.len(), 36);
410
assert_eq!(views[0].pitch_deg, -30.0);
411
assert_eq!(views[0].yaw_deg, 0.0);
412
assert_eq!(views[11].yaw_deg, 330.0);
413
assert_eq!(views[12].pitch_deg, 0.0);
414
let steps = pass_sequence(&views);
415
assert_eq!(steps.len(), 36 * 8);
416
let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect();
417
assert_eq!(first, PASS_ORDER.to_vec());
418
assert_eq!(steps[8].yaw_deg, 30.0);
419
assert_eq!(steps[8].kind, PassKind::HighresImage);
420
}
421
422
#[test]
423
fn nearest_non_square_uses_source_height_for_rows() {
424
let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];
425
let out = nearest_stretch(&src, 1, 3, 2, 6);
426
for y in 0..6 {
427
assert_eq!(&out[y * 8..y * 8 + 4], &src[(y / 2) * 4..(y / 2) * 4 + 4]);
428
}
429
}
430
431
#[test]
432
fn nearest_stretch_fills_the_box() {
433
let src = [
434
255, 0, 0, 255, 0, 0, 255, 255, // 2x1
435
];
436
let out = nearest_stretch(&src, 2, 1, 4, 3);
437
assert_eq!(out.len(), 4 * 3 * 4);
438
for y in 0..3 {
439
for x in 0..4 {
440
let pixel = &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
441
if x < 2 {
442
assert_eq!(pixel, &[255, 0, 0, 255]);
443
} else {
444
assert_eq!(pixel, &[0, 0, 255, 255]);
445
}
446
}
447
}
448
}
449
450
#[test]
451
fn progress_follows_steps_and_skips_do_not_replace_the_picture() {
452
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
453
.join("tests/fixtures/bodies/test-body-male.glb");
454
let character = load_gltf_file(&path).unwrap();
455
let nanos = SystemTime::now()
456
.duration_since(UNIX_EPOCH)
457
.unwrap()
458
.as_nanos();
459
let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png"));
460
let job = RenderJob {
461
character,
462
controls: PreviewControls::default(),
463
target_width: 32,
464
target_height: 40,
465
preview_width: 64,
466
preview_height: 80,
467
output: output.clone(),
468
};
469
let mut events = Vec::new();
470
execute(&job, &mut |event| events.push(event));
471
let mut ui = RenderUiState::idle();
472
for event in events {
473
ui.apply(event);
474
}
475
assert_eq!(ui.progress.completed, 2);
476
assert_eq!(ui.progress.total, 2);
477
assert!(ui.error.is_none());
478
let preview = ui.preview.expect("a real pass image");
479
assert_eq!((preview.width, preview.height), (64, 80));
480
let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
481
assert_eq!(filled, 64 * 80);
482
assert!(output.is_file());
483
let _ = std::fs::remove_file(&output);
484
}
485
486
#[test]
487
fn skipped_maps_have_no_image() {
488
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
489
.join("tests/fixtures/bodies/test-body-male.glb");
490
let job = RenderJob {
491
character: load_gltf_file(&path).unwrap(),
492
controls: PreviewControls::default(),
493
target_width: 16,
494
target_height: 16,
495
preview_width: 16,
496
preview_height: 16,
497
output: std::env::temp_dir().join("paperdoll-skip-check.png"),
498
};
499
let mut images = Vec::new();
500
execute(&job, &mut |event| {
501
if let RenderEvent::Pass { label, image, .. } = event {
502
images.push((label, image.is_some()));
503
}
504
});
505
assert_eq!(images.len(), 2);
506
assert!(images.iter().all(|(_, has_image)| *has_image));
507
assert!(images[0].0.ends_with("highres"));
508
assert!(images[1].0.ends_with("doelresolutie"));
509
let _ = std::fs::remove_file(&job.output);
510
}
511
}
@@ -0,0 +1,511 @@1
//! Render queue for one body still.2
//!3
//! The worker walks the design order and emits an event after every pass.4
//! The full production order is recorded, but this still renderer executes5
//! and counts only its two real color passes. The GUI may coalesce events.6
7
use std::path::{Path, PathBuf};8
use std::sync::mpsc::{self, Receiver};9
use std::thread::{self, JoinHandle};10
11
use crate::preview::{12
paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,13
};14
use crate::still::raster_body;15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]17
pub enum PassKind {18
HighresImage,19
DetailMap,20
TargetResolution,21
Diffuse,22
Depth,23
Normal,24
Shiny,25
RimHighlight,26
}27
28
impl PassKind {29
pub fn label(self) -> &'static str {30
match self {31
Self::HighresImage => "highres",32
Self::DetailMap => "detail",33
Self::TargetResolution => "doelresolutie",34
Self::Diffuse => "diffuse",35
Self::Depth => "depth",36
Self::Normal => "normal",37
Self::Shiny => "shiny",38
Self::RimHighlight => "rim",39
}40
}41
42
pub fn produces_image(self) -> bool {43
matches!(self, Self::HighresImage | Self::TargetResolution)44
}45
}46
47
pub const PASS_ORDER: [PassKind; 8] = [48
PassKind::HighresImage,49
PassKind::DetailMap,50
PassKind::TargetResolution,51
PassKind::Diffuse,52
PassKind::Depth,53
PassKind::Normal,54
PassKind::Shiny,55
PassKind::RimHighlight,56
];57
58
#[derive(Debug, Clone, Copy, PartialEq)]59
pub struct ViewAngle {60
pub pitch_deg: f32,61
pub yaw_deg: f32,62
}63
64
#[derive(Debug, Clone, Copy, PartialEq)]65
pub struct PassStep {66
pub pitch_deg: f32,67
pub yaw_deg: f32,68
pub kind: PassKind,69
}70
71
#[derive(Debug, Clone, PartialEq, Eq)]72
pub struct ImageBuf {73
pub width: u32,74
pub height: u32,75
pub rgba: Vec<u8>,76
}77
78
#[derive(Debug, Clone, PartialEq, Eq)]79
pub struct Progress {80
pub completed: u32,81
pub total: u32,82
pub label: String,83
}84
85
#[derive(Debug, Clone, PartialEq, Eq)]86
pub enum RenderEvent {87
Step {88
index: u32,89
total: u32,90
label: String,91
},92
Pass {93
index: u32,94
total: u32,95
label: String,96
image: Option<ImageBuf>,97
},98
Failed(String),99
Finished {100
saved: Option<String>,101
},102
}103
104
#[derive(Debug, Clone, PartialEq, Eq)]105
pub struct RenderUiState {106
pub progress: Progress,107
pub preview: Option<ImageBuf>,108
pub saved: Option<String>,109
pub error: Option<String>,110
pub running: bool,111
}112
113
impl RenderUiState {114
pub fn idle() -> Self {115
Self {116
progress: Progress {117
completed: 0,118
total: 0,119
label: String::new(),120
},121
preview: None,122
saved: None,123
error: None,124
running: false,125
}126
}127
128
pub fn apply(&mut self, event: RenderEvent) {129
match event {130
RenderEvent::Step {131
index,132
total,133
label,134
} => {135
self.progress = Progress {136
completed: index,137
total,138
label,139
};140
self.running = true;141
}142
RenderEvent::Pass {143
index,144
total,145
label,146
image,147
} => {148
self.progress = Progress {149
completed: index + 1,150
total,151
label,152
};153
if let Some(image) = image {154
self.preview = Some(image);155
}156
self.running = true;157
}158
RenderEvent::Failed(message) => {159
self.error = Some(message);160
self.running = false;161
}162
RenderEvent::Finished { saved } => {163
self.saved = saved;164
self.running = false;165
if self.progress.total > 0 {166
self.progress.completed = self.progress.total;167
}168
}169
}170
}171
}172
173
pub fn export_views() -> Vec<ViewAngle> {174
let mut views = Vec::with_capacity(PAPERDOLL_PITCHES_DEG.len() * paperdoll_yaws_deg().len());175
for pitch in PAPERDOLL_PITCHES_DEG {176
for yaw in paperdoll_yaws_deg() {177
views.push(ViewAngle {178
pitch_deg: pitch,179
yaw_deg: yaw,180
});181
}182
}183
views184
}185
186
pub fn pass_sequence(views: &[ViewAngle]) -> Vec<PassStep> {187
let mut steps = Vec::with_capacity(views.len() * PASS_ORDER.len());188
for view in views {189
for kind in PASS_ORDER {190
steps.push(PassStep {191
pitch_deg: view.pitch_deg,192
yaw_deg: view.yaw_deg,193
kind,194
});195
}196
}197
steps198
}199
200
pub fn highres_extent(target_width: u32, target_height: u32) -> (u32, u32) {201
let width = target_width.max(1);202
let height = target_height.max(1);203
let mut scale = 8u32;204
while scale > 1 && (width.saturating_mul(scale) > 1024 || height.saturating_mul(scale) > 1024) {205
scale -= 1;206
}207
(width * scale, height * scale)208
}209
210
/// Nearest-neighbor stretch. Every destination pixel is filled, so the211
/// preview box has no empty border.212
pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {213
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];214
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {215
return out;216
}217
for y in 0..dh {218
let sy = ((y as u64 * sh as u64) / dh as u64) as u32;219
let sy = sy.min(sh - 1);220
for x in 0..dw {221
let sx = ((x as u64 * sw as u64) / dw as u64) as u32;222
let sx = sx.min(sw - 1);223
let from = ((sy * sw + sx) * 4) as usize;224
let to = ((y * dw + x) * 4) as usize;225
out[to..to + 4].copy_from_slice(&src[from..from + 4]);226
}227
}228
out229
}230
231
pub struct RenderJob {232
pub character: PreviewCharacter,233
pub controls: PreviewControls,234
pub target_width: u32,235
pub target_height: u32,236
pub preview_width: u32,237
pub preview_height: u32,238
pub output: PathBuf,239
}240
241
impl RenderJob {242
pub fn steps(&self) -> Vec<PassStep> {243
pass_sequence(&[ViewAngle {244
pitch_deg: self.controls.pitch_deg,245
yaw_deg: self.controls.yaw_deg,246
}])247
}248
}249
250
pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {251
if job.target_width == 0252
|| job.target_height == 0253
|| job.target_width > crate::project::MAX_DIMENSION254
|| job.target_height > crate::project::MAX_DIMENSION255
{256
emit(RenderEvent::Failed(257
"ongeldige afmetingen voor de render".into(),258
));259
return;260
}261
// Count only implemented passes. Never report a skipped map as completed.262
let steps: Vec<_> = job263
.steps()264
.into_iter()265
.filter(|step| step.kind.produces_image())266
.collect();267
let total = steps.len() as u32;268
let mut highres: Option<ImageBuf> = None;269
let mut saved = None;270
for (index, step) in steps.iter().enumerate() {271
let index = index as u32;272
let label = format!(273
"pitch {:.0} yaw {:.0} · {}",274
step.pitch_deg,275
step.yaw_deg,276
step.kind.label()277
);278
emit(RenderEvent::Step {279
index,280
total,281
label: label.clone(),282
});283
if !step.kind.produces_image() {284
emit(RenderEvent::Pass {285
index,286
total,287
label,288
image: None,289
});290
continue;291
}292
let image = match step.kind {293
PassKind::HighresImage => {294
let mut controls = job.controls.clone();295
controls.pitch_deg = step.pitch_deg;296
controls.yaw_deg = step.yaw_deg;297
let (width, height) = highres_extent(job.target_width, job.target_height);298
match raster_body(&job.character, &controls, width, height) {299
Ok(rgba) => {300
let image = ImageBuf {301
width,302
height,303
rgba,304
};305
highres = Some(image.clone());306
Some(image)307
}308
Err(message) => {309
emit(RenderEvent::Failed(message));310
return;311
}312
}313
}314
PassKind::TargetResolution => {315
let Some(source) = highres.as_ref() else {316
emit(RenderEvent::Failed(317
"de doelresolutie volgt op het highres-beeld".to_string(),318
));319
return;320
};321
let rgba = nearest_stretch(322
&source.rgba,323
source.width,324
source.height,325
job.target_width.max(1),326
job.target_height.max(1),327
);328
let target = ImageBuf {329
width: job.target_width.max(1),330
height: job.target_height.max(1),331
rgba,332
};333
match write_png(&job.output, &target) {334
Ok(()) => saved = Some(job.output.display().to_string()),335
Err(message) => {336
emit(RenderEvent::Failed(message));337
return;338
}339
}340
let (box_w, box_h) = (job.preview_width.max(1), job.preview_height.max(1));341
Some(ImageBuf {342
width: box_w,343
height: box_h,344
rgba: nearest_stretch(&target.rgba, target.width, target.height, box_w, box_h),345
})346
}347
_ => None,348
};349
emit(RenderEvent::Pass {350
index,351
total,352
label,353
image,354
});355
}356
emit(RenderEvent::Finished { saved });357
}358
359
pub struct RenderThread {360
pub rx: Receiver<RenderEvent>,361
pub handle: JoinHandle<()>,362
}363
364
pub fn spawn(job: RenderJob) -> RenderThread {365
let (tx, rx) = mpsc::channel();366
let handle = thread::spawn(move || {367
execute(&job, &mut |event| {368
let _ = tx.send(event);369
});370
});371
RenderThread { rx, handle }372
}373
374
fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> {375
if let Some(parent) = path.parent() {376
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;377
}378
let stamp = std::time::SystemTime::now()379
.duration_since(std::time::UNIX_EPOCH)380
.unwrap_or_default()381
.as_nanos();382
let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id()));383
let file = std::fs::OpenOptions::new()384
.create_new(true)385
.write(true)386
.open(&temp)387
.map_err(|err| err.to_string())?;388
let mut encoder = png::Encoder::new(file, image.width, image.height);389
encoder.set_color(png::ColorType::Rgba);390
encoder.set_depth(png::BitDepth::Eight);391
let mut writer = encoder.write_header().map_err(|err| err.to_string())?;392
writer393
.write_image_data(&image.rgba)394
.map_err(|err| err.to_string())?;395
drop(writer);396
std::fs::rename(&temp, path).map_err(|err| err.to_string())?;397
Ok(())398
}399
400
#[cfg(test)]401
mod tests {402
use super::*;403
use crate::preview::load_gltf_file;404
use std::time::{SystemTime, UNIX_EPOCH};405
406
#[test]407
fn full_view_order_is_pitch_then_yaw_then_passes() {408
let views = export_views();409
assert_eq!(views.len(), 36);410
assert_eq!(views[0].pitch_deg, -30.0);411
assert_eq!(views[0].yaw_deg, 0.0);412
assert_eq!(views[11].yaw_deg, 330.0);413
assert_eq!(views[12].pitch_deg, 0.0);414
let steps = pass_sequence(&views);415
assert_eq!(steps.len(), 36 * 8);416
let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect();417
assert_eq!(first, PASS_ORDER.to_vec());418
assert_eq!(steps[8].yaw_deg, 30.0);419
assert_eq!(steps[8].kind, PassKind::HighresImage);420
}421
422
#[test]423
fn nearest_non_square_uses_source_height_for_rows() {424
let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];425
let out = nearest_stretch(&src, 1, 3, 2, 6);426
for y in 0..6 {427
assert_eq!(&out[y * 8..y * 8 + 4], &src[(y / 2) * 4..(y / 2) * 4 + 4]);428
}429
}430
431
#[test]432
fn nearest_stretch_fills_the_box() {433
let src = [434
255, 0, 0, 255, 0, 0, 255, 255, // 2x1435
];436
let out = nearest_stretch(&src, 2, 1, 4, 3);437
assert_eq!(out.len(), 4 * 3 * 4);438
for y in 0..3 {439
for x in 0..4 {440
let pixel = &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];441
if x < 2 {442
assert_eq!(pixel, &[255, 0, 0, 255]);443
} else {444
assert_eq!(pixel, &[0, 0, 255, 255]);445
}446
}447
}448
}449
450
#[test]451
fn progress_follows_steps_and_skips_do_not_replace_the_picture() {452
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))453
.join("tests/fixtures/bodies/test-body-male.glb");454
let character = load_gltf_file(&path).unwrap();455
let nanos = SystemTime::now()456
.duration_since(UNIX_EPOCH)457
.unwrap()458
.as_nanos();459
let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png"));460
let job = RenderJob {461
character,462
controls: PreviewControls::default(),463
target_width: 32,464
target_height: 40,465
preview_width: 64,466
preview_height: 80,467
output: output.clone(),468
};469
let mut events = Vec::new();470
execute(&job, &mut |event| events.push(event));471
let mut ui = RenderUiState::idle();472
for event in events {473
ui.apply(event);474
}475
assert_eq!(ui.progress.completed, 2);476
assert_eq!(ui.progress.total, 2);477
assert!(ui.error.is_none());478
let preview = ui.preview.expect("a real pass image");479
assert_eq!((preview.width, preview.height), (64, 80));480
let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();481
assert_eq!(filled, 64 * 80);482
assert!(output.is_file());483
let _ = std::fs::remove_file(&output);484
}485
486
#[test]487
fn skipped_maps_have_no_image() {488
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))489
.join("tests/fixtures/bodies/test-body-male.glb");490
let job = RenderJob {491
character: load_gltf_file(&path).unwrap(),492
controls: PreviewControls::default(),493
target_width: 16,494
target_height: 16,495
preview_width: 16,496
preview_height: 16,497
output: std::env::temp_dir().join("paperdoll-skip-check.png"),498
};499
let mut images = Vec::new();500
execute(&job, &mut |event| {501
if let RenderEvent::Pass { label, image, .. } = event {502
images.push((label, image.is_some()));503
}504
});505
assert_eq!(images.len(), 2);506
assert!(images.iter().all(|(_, has_image)| *has_image));507
assert!(images[0].0.ends_with("highres"));508
assert!(images[1].0.ends_with("doelresolutie"));509
let _ = std::fs::remove_file(&job.output);510
}511
}@@ -0,0 +1,711 @@
1
//! Project navigation, dirty state and equipment records.
2
//!
3
//! The screen starts small. A project opens on Bodies. Equipment is one
4
//! step down from Body. Chosen files are copied into the project so a later
5
//! save stays inside the directory.
6
7
use std::fs;
8
use std::path::{Path, PathBuf};
9
10
use serde::{Deserialize, Serialize};
11
12
use crate::project::{
13
create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile,
14
MANIFEST_NAME,
15
};
16
17
const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb");
18
const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb");
19
20
pub const MALE_ASSET: &str = "assets/body/test-body-male.glb";
21
pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb";
22
23
pub const START_WINDOW: [f32; 2] = [480.0, 280.0];
24
pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0];
25
26
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
pub enum Sex {
28
Male,
29
Female,
30
}
31
32
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
pub enum Screen {
34
Start,
35
Bodies,
36
Body(Sex),
37
EquipmentList,
38
Equipment(usize),
39
}
40
41
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42
pub enum Intent {
43
Quit,
44
PickNew,
45
PickLoad,
46
}
47
48
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49
pub enum Choice {
50
Save,
51
Discard,
52
Cancel,
53
}
54
55
#[derive(Debug, Clone, PartialEq, Eq)]
56
pub enum AfterChoice {
57
Stay,
58
SaveThen(Intent),
59
Go(Intent),
60
}
61
62
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63
struct EquipmentFile {
64
name: String,
65
model: Option<String>,
66
}
67
68
#[derive(Debug, Clone, PartialEq, Eq)]
69
pub struct EquipmentItem {
70
pub slug: String,
71
pub model: Option<String>,
72
pub legacy_path: Option<String>,
73
}
74
75
#[derive(Debug, Clone, PartialEq, Eq)]
76
pub struct Session {
77
pub screen: Screen,
78
pub active_body: Option<Sex>,
79
pub opened: Option<OpenedProject>,
80
pub saved_document: Option<ProjectFile>,
81
pub equipment: Vec<EquipmentItem>,
82
pub saved_equipment: Vec<EquipmentItem>,
83
pub confirm: Option<Intent>,
84
pub notice: String,
85
pub notice_ok: bool,
86
}
87
88
impl Session {
89
pub fn new() -> Self {
90
Self {
91
screen: Screen::Start,
92
active_body: None,
93
opened: None,
94
saved_document: None,
95
equipment: Vec::new(),
96
saved_equipment: Vec::new(),
97
confirm: None,
98
notice: "Laad een project of maak een nieuwe projectmap.".to_string(),
99
notice_ok: true,
100
}
101
}
102
103
pub fn shell_size(&self) -> [f32; 2] {
104
match self.screen {
105
Screen::Start => START_WINDOW,
106
_ => PROJECT_WINDOW,
107
}
108
}
109
110
pub fn is_dirty(&self) -> bool {
111
self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref()
112
|| self.equipment != self.saved_equipment
113
}
114
115
pub fn gate(&mut self, intent: Intent) -> bool {
116
if self.is_dirty() {
117
self.confirm = Some(intent);
118
false
119
} else {
120
true
121
}
122
}
123
124
pub fn resolve(&mut self, choice: Choice) -> AfterChoice {
125
let Some(intent) = self.confirm.take() else {
126
return AfterChoice::Stay;
127
};
128
match choice {
129
Choice::Cancel => {
130
self.confirm = None;
131
AfterChoice::Stay
132
}
133
Choice::Discard => AfterChoice::Go(intent),
134
Choice::Save => {
135
self.confirm = Some(intent);
136
AfterChoice::SaveThen(intent)
137
}
138
}
139
}
140
141
pub fn note_saved(&mut self) {
142
if let Some(opened) = &self.opened {
143
self.saved_document = Some(opened.document.clone());
144
}
145
self.saved_equipment = self.equipment.clone();
146
self.confirm = None;
147
}
148
149
pub fn show_bodies(&mut self) {
150
if self.opened.is_some() {
151
self.screen = Screen::Bodies;
152
}
153
}
154
155
pub fn show_body(&mut self, sex: Sex) {
156
if self.opened.is_some() {
157
self.active_body = Some(sex);
158
self.screen = Screen::Body(sex);
159
}
160
}
161
162
pub fn show_equipment_list(&mut self) {
163
if self.opened.is_some() {
164
self.screen = Screen::EquipmentList;
165
}
166
}
167
168
pub fn show_equipment(&mut self, index: usize) {
169
if index < self.equipment.len() {
170
self.screen = Screen::Equipment(index);
171
}
172
}
173
174
pub fn back(&mut self) {
175
self.screen = match self.screen {
176
Screen::Body(_) => Screen::Bodies,
177
Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
178
Screen::Equipment(_) => Screen::EquipmentList,
179
other => other,
180
};
181
}
182
183
pub fn set_name(&mut self, name: String) {
184
if let Some(opened) = &mut self.opened {
185
opened.document.name = name;
186
}
187
}
188
189
pub fn set_render(&mut self, width: u32, height: u32, fps: u32) {
190
if let Some(opened) = &mut self.opened {
191
opened.document.render.target_width = width;
192
opened.document.render.target_height = height;
193
opened.document.render.fps = fps;
194
}
195
}
196
197
pub fn body_path(&self, sex: Sex) -> Option<&str> {
198
let document = &self.opened.as_ref()?.document;
199
match sex {
200
Sex::Male => document.male_model.as_deref(),
201
Sex::Female => document.female_model.as_deref(),
202
}
203
}
204
205
pub fn set_body_path(&mut self, sex: Sex, relative: String) {
206
let Some(opened) = &mut self.opened else {
207
return;
208
};
209
let slot = match sex {
210
Sex::Male => &mut opened.document.male_model,
211
Sex::Female => &mut opened.document.female_model,
212
};
213
*slot = Some(relative);
214
}
215
216
pub fn add_equipment(&mut self) -> usize {
217
let slug = next_slug(&self.equipment);
218
self.equipment.push(EquipmentItem {
219
slug,
220
model: None,
221
legacy_path: None,
222
});
223
let index = self.equipment.len() - 1;
224
self.screen = Screen::Equipment(index);
225
index
226
}
227
228
pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
229
let slug = slug.trim().to_string();
230
if !valid_slug(&slug) {
231
return Err(
232
"de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'"
233
.to_string(),
234
);
235
}
236
if self
237
.equipment
238
.iter()
239
.enumerate()
240
.any(|(other, item)| other != index && item.slug == slug)
241
{
242
return Err("die equipmentnaam bestaat al".to_string());
243
}
244
if let Some(item) = self.equipment.get_mut(index) {
245
item.slug = slug;
246
item.legacy_path = None;
247
}
248
Ok(())
249
}
250
251
pub fn set_equipment_model(&mut self, index: usize, relative: String) {
252
if let Some(item) = self.equipment.get_mut(index) {
253
item.model = Some(relative);
254
item.legacy_path = None;
255
}
256
}
257
258
pub fn delete_equipment(&mut self, index: usize) {
259
if index < self.equipment.len() {
260
self.equipment.remove(index);
261
self.screen = Screen::EquipmentList;
262
}
263
}
264
265
pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> {
266
let opened = create_seeded_project(root)?;
267
self.install_opened(opened);
268
self.ok("Projectmap aangemaakt, met de testlichamen in assets/body.");
269
Ok(())
270
}
271
272
pub fn install_loaded(&mut self, opened: OpenedProject) {
273
let root = opened
274
.manifest_path
275
.parent()
276
.map(Path::to_path_buf)
277
.unwrap_or_default();
278
let equipment = read_equipment(&root, &opened.document.equipment_presets);
279
self.equipment = equipment.clone();
280
self.saved_equipment = equipment;
281
self.saved_document = Some(opened.document.clone());
282
self.opened = Some(opened);
283
self.screen = Screen::Bodies;
284
self.active_body = None;
285
self.confirm = None;
286
self.ok("Project geopend.");
287
}
288
289
fn install_opened(&mut self, opened: OpenedProject) {
290
self.equipment.clear();
291
self.saved_equipment.clear();
292
self.saved_document = Some(opened.document.clone());
293
self.opened = Some(opened);
294
self.screen = Screen::Bodies;
295
self.active_body = None;
296
self.confirm = None;
297
}
298
299
pub fn save(&mut self) -> Result<(), ProjectError> {
300
let Some(opened) = self.opened.clone() else {
301
return Err(ProjectError::Validation(vec![
302
"er is geen project om op te slaan".to_string(),
303
]));
304
};
305
let root = opened
306
.manifest_path
307
.parent()
308
.ok_or_else(|| ProjectError::Io {
309
path: opened.manifest_path.display().to_string(),
310
message: "het .ppd-bestand heeft geen projectmap".to_string(),
311
})?;
312
opened.document.validate_shape()?;
313
let presets = write_equipment(root, &self.equipment)
314
.map_err(|message| ProjectError::Validation(vec![message]))?;
315
let mut document = opened.document.clone();
316
document.equipment_presets = presets;
317
document.validate_shape()?;
318
let candidate = OpenedProject {
319
manifest_path: opened.manifest_path.clone(),
320
document,
321
disk_issues: Vec::new(),
322
};
323
save_project(&candidate)?;
324
self.opened = Some(candidate);
325
self.note_saved();
326
self.ok("Opgeslagen.");
327
Ok(())
328
}
329
330
pub fn project_root(&self) -> Option<PathBuf> {
331
self.opened
332
.as_ref()?
333
.manifest_path
334
.parent()
335
.map(PathBuf::from)
336
}
337
338
fn ok(&mut self, message: &str) {
339
self.notice = message.to_string();
340
self.notice_ok = true;
341
}
342
343
pub fn fail(&mut self, message: impl Into<String>) {
344
self.notice = message.into();
345
self.notice_ok = false;
346
}
347
}
348
349
impl Default for Session {
350
fn default() -> Self {
351
Self::new()
352
}
353
}
354
355
pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
356
let mut opened = create_project(root, "Nieuw project")?;
357
for (relative, bytes) in [
358
(MALE_ASSET, MALE_GLB),
359
(FEMALE_ASSET, FEMALE_GLB),
360
(
361
"assets/body/test-bodies-LICENSE.txt",
362
include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(),
363
),
364
] {
365
let path = checked_project_path(root, relative)
366
.map_err(|error| ProjectError::Validation(vec![error]))?;
367
if !path.exists() {
368
write_bytes(&path, bytes)?;
369
}
370
}
371
opened.document.male_model = Some(MALE_ASSET.to_string());
372
opened.document.female_model = Some(FEMALE_ASSET.to_string());
373
save_project(&opened)?;
374
Ok(opened)
375
}
376
377
pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result<String, String> {
378
if !source.is_file() {
379
return Err("kies een bestand".to_string());
380
}
381
let name = source
382
.file_name()
383
.and_then(|name| name.to_str())
384
.filter(|name| {
385
!name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0')
386
})
387
.ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?;
388
if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) {
389
if let Ok(inside) = source_full.strip_prefix(&root_full) {
390
let relative = inside.to_string_lossy().replace('\\', "/");
391
portable_relative(&relative)?;
392
return Ok(relative);
393
}
394
}
395
let mut relative = format!("{folder}/{name}");
396
let stem = Path::new(name).file_stem().unwrap().to_string_lossy();
397
let ext = Path::new(name)
398
.extension()
399
.map(|x| format!(".{}", x.to_string_lossy()))
400
.unwrap_or_default();
401
let mut n = 2;
402
while root.join(&relative).exists() {
403
relative = format!("{folder}/{stem}-{n}{ext}");
404
n += 1;
405
}
406
portable_relative(&relative)?;
407
let dest = checked_project_path(root, &relative)?;
408
if let Some(parent) = dest.parent() {
409
fs::create_dir_all(parent).map_err(|err| err.to_string())?;
410
}
411
fs::copy(source, &dest).map_err(|err| err.to_string())?;
412
Ok(relative)
413
}
414
415
fn checked_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
416
portable_relative(relative)?;
417
let root_full = root.canonicalize().map_err(|e| e.to_string())?;
418
let path = root.join(relative);
419
let mut ancestor = path.as_path();
420
while std::fs::symlink_metadata(ancestor).is_err() {
421
ancestor = ancestor.parent().ok_or("ongeldig projectpad")?;
422
}
423
let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?;
424
if !canonical.starts_with(&root_full) {
425
return Err("het bestand ligt buiten de projectmap".into());
426
}
427
Ok(path)
428
}
429
430
fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
431
if let Some(parent) = path.parent() {
432
fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?;
433
}
434
fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string()))
435
}
436
437
fn io_at(path: &Path, message: &str) -> ProjectError {
438
ProjectError::Io {
439
path: path.display().to_string(),
440
message: message.to_string(),
441
}
442
}
443
444
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
445
presets
446
.iter()
447
.map(|path| {
448
let file = match checked_project_path(root, path) {
449
Ok(path) => path,
450
Err(_) => {
451
return EquipmentItem {
452
slug: "onveilig".into(),
453
model: None,
454
legacy_path: Some(path.clone()),
455
}
456
}
457
};
458
if let Ok(text) = fs::read_to_string(&file) {
459
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
460
if valid_slug(&parsed.name) {
461
return EquipmentItem {
462
slug: parsed.name,
463
model: parsed.model,
464
legacy_path: None,
465
};
466
}
467
}
468
}
469
let slug = path
470
.rsplit('/')
471
.next()
472
.unwrap_or(path)
473
.split('.')
474
.next()
475
.unwrap_or("item")
476
.to_string();
477
EquipmentItem {
478
slug,
479
model: None,
480
legacy_path: Some(path.clone()),
481
}
482
})
483
.collect()
484
}
485
486
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
487
let mut paths = Vec::new();
488
let mut names = std::collections::BTreeSet::new();
489
// Validate every record before writing. Config files are immutable snapshots:
490
// a failed manifest save leaves its previous equipment bytes intact.
491
for item in items {
492
if !names.insert(&item.slug) {
493
return Err("dubbele equipmentnaam".into());
494
}
495
if item.legacy_path.is_some() && item.model.is_none() {
496
continue;
497
}
498
if !valid_slug(&item.slug) {
499
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
500
}
501
if let Some(model) = &item.model {
502
portable_relative(model)?;
503
}
504
}
505
for item in items {
506
if item.model.is_none() {
507
if let Some(legacy) = &item.legacy_path {
508
paths.push(legacy.clone());
509
continue;
510
}
511
}
512
let file = EquipmentFile {
513
name: item.slug.clone(),
514
model: item.model.clone(),
515
};
516
let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;
517
let mut number = 1;
518
loop {
519
let relative = format!("presets/{}-{number}.json", item.slug);
520
let path = checked_project_path(root, &relative)?;
521
if let Ok(existing) = fs::read_to_string(&path) {
522
if existing == text {
523
paths.push(relative);
524
break;
525
}
526
} else if !path.exists() {
527
fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?;
528
use std::io::Write;
529
let mut out = fs::OpenOptions::new()
530
.write(true)
531
.create_new(true)
532
.open(&path)
533
.map_err(|err| err.to_string())?;
534
out.write_all(text.as_bytes())
535
.map_err(|err| err.to_string())?;
536
out.sync_all().map_err(|err| err.to_string())?;
537
paths.push(relative);
538
break;
539
}
540
number += 1;
541
}
542
}
543
Ok(paths)
544
}
545
546
fn next_slug(items: &[EquipmentItem]) -> String {
547
let mut number = 1u32;
548
loop {
549
let slug = if number == 1 {
550
"nieuw".to_string()
551
} else {
552
format!("nieuw-{number}")
553
};
554
if !items.iter().any(|item| item.slug == slug) {
555
return slug;
556
}
557
number += 1;
558
}
559
}
560
561
fn valid_slug(slug: &str) -> bool {
562
!slug.is_empty()
563
&& slug != "body"
564
&& slug
565
.chars()
566
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
567
}
568
569
pub fn manifest_in(root: &Path) -> PathBuf {
570
root.join(MANIFEST_NAME)
571
}
572
573
#[cfg(test)]
574
mod tests {
575
use super::*;
576
use std::time::{SystemTime, UNIX_EPOCH};
577
578
fn scratch() -> PathBuf {
579
let nanos = SystemTime::now()
580
.duration_since(UNIX_EPOCH)
581
.unwrap()
582
.as_nanos();
583
let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}"));
584
let _ = fs::remove_dir_all(&path);
585
path
586
}
587
588
#[test]
589
fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
590
let root = scratch();
591
let mut session = Session::new();
592
session.install_new_project(&root).unwrap();
593
assert_eq!(session.screen, Screen::Bodies);
594
assert!(!session.is_dirty());
595
assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
596
assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB);
597
let outside = root
598
.parent()
599
.unwrap()
600
.join(format!("pd-src-{}", std::process::id()));
601
let _ = fs::remove_dir_all(&outside);
602
fs::create_dir_all(&outside).unwrap();
603
let source = outside.join("test-body-male.glb");
604
fs::write(&source, b"replacement-body").unwrap();
605
let relative = adopt_file(&root, &source, "assets/body").unwrap();
606
assert_ne!(relative, MALE_ASSET);
607
assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body");
608
assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
609
let extra = outside.join("extra.glb");
610
fs::write(&extra, b"extra-body").unwrap();
611
let relative = adopt_file(&root, &extra, "assets/body").unwrap();
612
session.set_body_path(Sex::Male, relative);
613
assert!(session.is_dirty());
614
session.save().unwrap();
615
assert!(!session.is_dirty());
616
assert_eq!(
617
fs::read(root.join("assets/body/extra.glb")).unwrap(),
618
b"extra-body"
619
);
620
let _ = fs::remove_dir_all(&outside);
621
let _ = fs::remove_dir_all(&root);
622
}
623
624
#[test]
625
fn navigation_and_equipment_crud_roundtrip() {
626
let root = scratch();
627
let mut session = Session::new();
628
session.show_body(Sex::Male);
629
assert_eq!(session.screen, Screen::Start);
630
session.install_new_project(&root).unwrap();
631
session.show_body(Sex::Female);
632
assert_eq!(session.screen, Screen::Body(Sex::Female));
633
session.show_equipment_list();
634
assert_eq!(session.screen, Screen::EquipmentList);
635
let index = session.add_equipment();
636
assert_eq!(index, 0);
637
session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string());
638
session.save().unwrap();
639
let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap();
640
assert!(text.contains("zwaard.glb"));
641
let mut again = Session::new();
642
let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
643
again.install_loaded(opened);
644
assert_eq!(
645
again.equipment[0].model.as_deref(),
646
Some("assets/equipment/zwaard.glb")
647
);
648
again.delete_equipment(0);
649
again.save().unwrap();
650
assert!(again
651
.opened
652
.as_ref()
653
.unwrap()
654
.document
655
.equipment_presets
656
.is_empty());
657
// Old immutable record stays recoverable if an earlier save is restored.
658
assert!(root.join("presets/nieuw-1.json").exists());
659
let _ = fs::remove_dir_all(&root);
660
}
661
662
#[test]
663
fn failed_save_preserves_previous_equipment_and_manifest_bytes() {
664
let root = scratch();
665
let mut session = Session::new();
666
session.install_new_project(&root).unwrap();
667
session.add_equipment();
668
session.save().unwrap();
669
let manifest = fs::read(manifest_in(&root)).unwrap();
670
let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
671
let preset = fs::read(root.join(&relative)).unwrap();
672
session.equipment[0].slug = "changed".into();
673
// Missing body fails after immutable equipment snapshots are written.
674
session.set_body_path(Sex::Male, "assets/body/missing.glb".into());
675
assert!(session.save().is_err());
676
assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest);
677
assert_eq!(fs::read(root.join(&relative)).unwrap(), preset);
678
assert!(session.is_dirty());
679
let _ = fs::remove_dir_all(root);
680
}
681
682
#[test]
683
fn dirty_confirm_save_discard_and_cancel_keep_bytes() {
684
let root = scratch();
685
let mut session = Session::new();
686
session.install_new_project(&root).unwrap();
687
session.set_name("Gewijzigd".to_string());
688
assert!(session.is_dirty());
689
assert!(!session.gate(Intent::Quit));
690
assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay);
691
assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
692
assert!(!session.gate(Intent::PickLoad));
693
assert_eq!(
694
session.resolve(Choice::Discard),
695
AfterChoice::Go(Intent::PickLoad)
696
);
697
assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
698
assert!(session.is_dirty()); // canceled file picker must not discard the old project
699
session.set_name("Bewaard".to_string());
700
assert!(!session.gate(Intent::PickNew));
701
assert_eq!(
702
session.resolve(Choice::Save),
703
AfterChoice::SaveThen(Intent::PickNew)
704
);
705
session.save().unwrap();
706
assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard");
707
let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
708
assert_eq!(opened.document.name, "Bewaard");
709
let _ = fs::remove_dir_all(&root);
710
}
711
}
@@ -0,0 +1,711 @@1
//! Project navigation, dirty state and equipment records.2
//!3
//! The screen starts small. A project opens on Bodies. Equipment is one4
//! step down from Body. Chosen files are copied into the project so a later5
//! save stays inside the directory.6
7
use std::fs;8
use std::path::{Path, PathBuf};9
10
use serde::{Deserialize, Serialize};11
12
use crate::project::{13
create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile,14
MANIFEST_NAME,15
};16
17
const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb");18
const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb");19
20
pub const MALE_ASSET: &str = "assets/body/test-body-male.glb";21
pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb";22
23
pub const START_WINDOW: [f32; 2] = [480.0, 280.0];24
pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0];25
26
#[derive(Debug, Clone, Copy, PartialEq, Eq)]27
pub enum Sex {28
Male,29
Female,30
}31
32
#[derive(Debug, Clone, Copy, PartialEq, Eq)]33
pub enum Screen {34
Start,35
Bodies,36
Body(Sex),37
EquipmentList,38
Equipment(usize),39
}40
41
#[derive(Debug, Clone, Copy, PartialEq, Eq)]42
pub enum Intent {43
Quit,44
PickNew,45
PickLoad,46
}47
48
#[derive(Debug, Clone, Copy, PartialEq, Eq)]49
pub enum Choice {50
Save,51
Discard,52
Cancel,53
}54
55
#[derive(Debug, Clone, PartialEq, Eq)]56
pub enum AfterChoice {57
Stay,58
SaveThen(Intent),59
Go(Intent),60
}61
62
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]63
struct EquipmentFile {64
name: String,65
model: Option<String>,66
}67
68
#[derive(Debug, Clone, PartialEq, Eq)]69
pub struct EquipmentItem {70
pub slug: String,71
pub model: Option<String>,72
pub legacy_path: Option<String>,73
}74
75
#[derive(Debug, Clone, PartialEq, Eq)]76
pub struct Session {77
pub screen: Screen,78
pub active_body: Option<Sex>,79
pub opened: Option<OpenedProject>,80
pub saved_document: Option<ProjectFile>,81
pub equipment: Vec<EquipmentItem>,82
pub saved_equipment: Vec<EquipmentItem>,83
pub confirm: Option<Intent>,84
pub notice: String,85
pub notice_ok: bool,86
}87
88
impl Session {89
pub fn new() -> Self {90
Self {91
screen: Screen::Start,92
active_body: None,93
opened: None,94
saved_document: None,95
equipment: Vec::new(),96
saved_equipment: Vec::new(),97
confirm: None,98
notice: "Laad een project of maak een nieuwe projectmap.".to_string(),99
notice_ok: true,100
}101
}102
103
pub fn shell_size(&self) -> [f32; 2] {104
match self.screen {105
Screen::Start => START_WINDOW,106
_ => PROJECT_WINDOW,107
}108
}109
110
pub fn is_dirty(&self) -> bool {111
self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref()112
|| self.equipment != self.saved_equipment113
}114
115
pub fn gate(&mut self, intent: Intent) -> bool {116
if self.is_dirty() {117
self.confirm = Some(intent);118
false119
} else {120
true121
}122
}123
124
pub fn resolve(&mut self, choice: Choice) -> AfterChoice {125
let Some(intent) = self.confirm.take() else {126
return AfterChoice::Stay;127
};128
match choice {129
Choice::Cancel => {130
self.confirm = None;131
AfterChoice::Stay132
}133
Choice::Discard => AfterChoice::Go(intent),134
Choice::Save => {135
self.confirm = Some(intent);136
AfterChoice::SaveThen(intent)137
}138
}139
}140
141
pub fn note_saved(&mut self) {142
if let Some(opened) = &self.opened {143
self.saved_document = Some(opened.document.clone());144
}145
self.saved_equipment = self.equipment.clone();146
self.confirm = None;147
}148
149
pub fn show_bodies(&mut self) {150
if self.opened.is_some() {151
self.screen = Screen::Bodies;152
}153
}154
155
pub fn show_body(&mut self, sex: Sex) {156
if self.opened.is_some() {157
self.active_body = Some(sex);158
self.screen = Screen::Body(sex);159
}160
}161
162
pub fn show_equipment_list(&mut self) {163
if self.opened.is_some() {164
self.screen = Screen::EquipmentList;165
}166
}167
168
pub fn show_equipment(&mut self, index: usize) {169
if index < self.equipment.len() {170
self.screen = Screen::Equipment(index);171
}172
}173
174
pub fn back(&mut self) {175
self.screen = match self.screen {176
Screen::Body(_) => Screen::Bodies,177
Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),178
Screen::Equipment(_) => Screen::EquipmentList,179
other => other,180
};181
}182
183
pub fn set_name(&mut self, name: String) {184
if let Some(opened) = &mut self.opened {185
opened.document.name = name;186
}187
}188
189
pub fn set_render(&mut self, width: u32, height: u32, fps: u32) {190
if let Some(opened) = &mut self.opened {191
opened.document.render.target_width = width;192
opened.document.render.target_height = height;193
opened.document.render.fps = fps;194
}195
}196
197
pub fn body_path(&self, sex: Sex) -> Option<&str> {198
let document = &self.opened.as_ref()?.document;199
match sex {200
Sex::Male => document.male_model.as_deref(),201
Sex::Female => document.female_model.as_deref(),202
}203
}204
205
pub fn set_body_path(&mut self, sex: Sex, relative: String) {206
let Some(opened) = &mut self.opened else {207
return;208
};209
let slot = match sex {210
Sex::Male => &mut opened.document.male_model,211
Sex::Female => &mut opened.document.female_model,212
};213
*slot = Some(relative);214
}215
216
pub fn add_equipment(&mut self) -> usize {217
let slug = next_slug(&self.equipment);218
self.equipment.push(EquipmentItem {219
slug,220
model: None,221
legacy_path: None,222
});223
let index = self.equipment.len() - 1;224
self.screen = Screen::Equipment(index);225
index226
}227
228
pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {229
let slug = slug.trim().to_string();230
if !valid_slug(&slug) {231
return Err(232
"de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'"233
.to_string(),234
);235
}236
if self237
.equipment238
.iter()239
.enumerate()240
.any(|(other, item)| other != index && item.slug == slug)241
{242
return Err("die equipmentnaam bestaat al".to_string());243
}244
if let Some(item) = self.equipment.get_mut(index) {245
item.slug = slug;246
item.legacy_path = None;247
}248
Ok(())249
}250
251
pub fn set_equipment_model(&mut self, index: usize, relative: String) {252
if let Some(item) = self.equipment.get_mut(index) {253
item.model = Some(relative);254
item.legacy_path = None;255
}256
}257
258
pub fn delete_equipment(&mut self, index: usize) {259
if index < self.equipment.len() {260
self.equipment.remove(index);261
self.screen = Screen::EquipmentList;262
}263
}264
265
pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> {266
let opened = create_seeded_project(root)?;267
self.install_opened(opened);268
self.ok("Projectmap aangemaakt, met de testlichamen in assets/body.");269
Ok(())270
}271
272
pub fn install_loaded(&mut self, opened: OpenedProject) {273
let root = opened274
.manifest_path275
.parent()276
.map(Path::to_path_buf)277
.unwrap_or_default();278
let equipment = read_equipment(&root, &opened.document.equipment_presets);279
self.equipment = equipment.clone();280
self.saved_equipment = equipment;281
self.saved_document = Some(opened.document.clone());282
self.opened = Some(opened);283
self.screen = Screen::Bodies;284
self.active_body = None;285
self.confirm = None;286
self.ok("Project geopend.");287
}288
289
fn install_opened(&mut self, opened: OpenedProject) {290
self.equipment.clear();291
self.saved_equipment.clear();292
self.saved_document = Some(opened.document.clone());293
self.opened = Some(opened);294
self.screen = Screen::Bodies;295
self.active_body = None;296
self.confirm = None;297
}298
299
pub fn save(&mut self) -> Result<(), ProjectError> {300
let Some(opened) = self.opened.clone() else {301
return Err(ProjectError::Validation(vec![302
"er is geen project om op te slaan".to_string(),303
]));304
};305
let root = opened306
.manifest_path307
.parent()308
.ok_or_else(|| ProjectError::Io {309
path: opened.manifest_path.display().to_string(),310
message: "het .ppd-bestand heeft geen projectmap".to_string(),311
})?;312
opened.document.validate_shape()?;313
let presets = write_equipment(root, &self.equipment)314
.map_err(|message| ProjectError::Validation(vec![message]))?;315
let mut document = opened.document.clone();316
document.equipment_presets = presets;317
document.validate_shape()?;318
let candidate = OpenedProject {319
manifest_path: opened.manifest_path.clone(),320
document,321
disk_issues: Vec::new(),322
};323
save_project(&candidate)?;324
self.opened = Some(candidate);325
self.note_saved();326
self.ok("Opgeslagen.");327
Ok(())328
}329
330
pub fn project_root(&self) -> Option<PathBuf> {331
self.opened332
.as_ref()?333
.manifest_path334
.parent()335
.map(PathBuf::from)336
}337
338
fn ok(&mut self, message: &str) {339
self.notice = message.to_string();340
self.notice_ok = true;341
}342
343
pub fn fail(&mut self, message: impl Into<String>) {344
self.notice = message.into();345
self.notice_ok = false;346
}347
}348
349
impl Default for Session {350
fn default() -> Self {351
Self::new()352
}353
}354
355
pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {356
let mut opened = create_project(root, "Nieuw project")?;357
for (relative, bytes) in [358
(MALE_ASSET, MALE_GLB),359
(FEMALE_ASSET, FEMALE_GLB),360
(361
"assets/body/test-bodies-LICENSE.txt",362
include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(),363
),364
] {365
let path = checked_project_path(root, relative)366
.map_err(|error| ProjectError::Validation(vec![error]))?;367
if !path.exists() {368
write_bytes(&path, bytes)?;369
}370
}371
opened.document.male_model = Some(MALE_ASSET.to_string());372
opened.document.female_model = Some(FEMALE_ASSET.to_string());373
save_project(&opened)?;374
Ok(opened)375
}376
377
pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result<String, String> {378
if !source.is_file() {379
return Err("kies een bestand".to_string());380
}381
let name = source382
.file_name()383
.and_then(|name| name.to_str())384
.filter(|name| {385
!name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0')386
})387
.ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?;388
if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) {389
if let Ok(inside) = source_full.strip_prefix(&root_full) {390
let relative = inside.to_string_lossy().replace('\\', "/");391
portable_relative(&relative)?;392
return Ok(relative);393
}394
}395
let mut relative = format!("{folder}/{name}");396
let stem = Path::new(name).file_stem().unwrap().to_string_lossy();397
let ext = Path::new(name)398
.extension()399
.map(|x| format!(".{}", x.to_string_lossy()))400
.unwrap_or_default();401
let mut n = 2;402
while root.join(&relative).exists() {403
relative = format!("{folder}/{stem}-{n}{ext}");404
n += 1;405
}406
portable_relative(&relative)?;407
let dest = checked_project_path(root, &relative)?;408
if let Some(parent) = dest.parent() {409
fs::create_dir_all(parent).map_err(|err| err.to_string())?;410
}411
fs::copy(source, &dest).map_err(|err| err.to_string())?;412
Ok(relative)413
}414
415
fn checked_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {416
portable_relative(relative)?;417
let root_full = root.canonicalize().map_err(|e| e.to_string())?;418
let path = root.join(relative);419
let mut ancestor = path.as_path();420
while std::fs::symlink_metadata(ancestor).is_err() {421
ancestor = ancestor.parent().ok_or("ongeldig projectpad")?;422
}423
let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?;424
if !canonical.starts_with(&root_full) {425
return Err("het bestand ligt buiten de projectmap".into());426
}427
Ok(path)428
}429
430
fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {431
if let Some(parent) = path.parent() {432
fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?;433
}434
fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string()))435
}436
437
fn io_at(path: &Path, message: &str) -> ProjectError {438
ProjectError::Io {439
path: path.display().to_string(),440
message: message.to_string(),441
}442
}443
444
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {445
presets446
.iter()447
.map(|path| {448
let file = match checked_project_path(root, path) {449
Ok(path) => path,450
Err(_) => {451
return EquipmentItem {452
slug: "onveilig".into(),453
model: None,454
legacy_path: Some(path.clone()),455
}456
}457
};458
if let Ok(text) = fs::read_to_string(&file) {459
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {460
if valid_slug(&parsed.name) {461
return EquipmentItem {462
slug: parsed.name,463
model: parsed.model,464
legacy_path: None,465
};466
}467
}468
}469
let slug = path470
.rsplit('/')471
.next()472
.unwrap_or(path)473
.split('.')474
.next()475
.unwrap_or("item")476
.to_string();477
EquipmentItem {478
slug,479
model: None,480
legacy_path: Some(path.clone()),481
}482
})483
.collect()484
}485
486
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {487
let mut paths = Vec::new();488
let mut names = std::collections::BTreeSet::new();489
// Validate every record before writing. Config files are immutable snapshots:490
// a failed manifest save leaves its previous equipment bytes intact.491
for item in items {492
if !names.insert(&item.slug) {493
return Err("dubbele equipmentnaam".into());494
}495
if item.legacy_path.is_some() && item.model.is_none() {496
continue;497
}498
if !valid_slug(&item.slug) {499
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));500
}501
if let Some(model) = &item.model {502
portable_relative(model)?;503
}504
}505
for item in items {506
if item.model.is_none() {507
if let Some(legacy) = &item.legacy_path {508
paths.push(legacy.clone());509
continue;510
}511
}512
let file = EquipmentFile {513
name: item.slug.clone(),514
model: item.model.clone(),515
};516
let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;517
let mut number = 1;518
loop {519
let relative = format!("presets/{}-{number}.json", item.slug);520
let path = checked_project_path(root, &relative)?;521
if let Ok(existing) = fs::read_to_string(&path) {522
if existing == text {523
paths.push(relative);524
break;525
}526
} else if !path.exists() {527
fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?;528
use std::io::Write;529
let mut out = fs::OpenOptions::new()530
.write(true)531
.create_new(true)532
.open(&path)533
.map_err(|err| err.to_string())?;534
out.write_all(text.as_bytes())535
.map_err(|err| err.to_string())?;536
out.sync_all().map_err(|err| err.to_string())?;537
paths.push(relative);538
break;539
}540
number += 1;541
}542
}543
Ok(paths)544
}545
546
fn next_slug(items: &[EquipmentItem]) -> String {547
let mut number = 1u32;548
loop {549
let slug = if number == 1 {550
"nieuw".to_string()551
} else {552
format!("nieuw-{number}")553
};554
if !items.iter().any(|item| item.slug == slug) {555
return slug;556
}557
number += 1;558
}559
}560
561
fn valid_slug(slug: &str) -> bool {562
!slug.is_empty()563
&& slug != "body"564
&& slug565
.chars()566
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')567
}568
569
pub fn manifest_in(root: &Path) -> PathBuf {570
root.join(MANIFEST_NAME)571
}572
573
#[cfg(test)]574
mod tests {575
use super::*;576
use std::time::{SystemTime, UNIX_EPOCH};577
578
fn scratch() -> PathBuf {579
let nanos = SystemTime::now()580
.duration_since(UNIX_EPOCH)581
.unwrap()582
.as_nanos();583
let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}"));584
let _ = fs::remove_dir_all(&path);585
path586
}587
588
#[test]589
fn new_project_copies_both_test_bodies_and_can_be_overwritten() {590
let root = scratch();591
let mut session = Session::new();592
session.install_new_project(&root).unwrap();593
assert_eq!(session.screen, Screen::Bodies);594
assert!(!session.is_dirty());595
assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);596
assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB);597
let outside = root598
.parent()599
.unwrap()600
.join(format!("pd-src-{}", std::process::id()));601
let _ = fs::remove_dir_all(&outside);602
fs::create_dir_all(&outside).unwrap();603
let source = outside.join("test-body-male.glb");604
fs::write(&source, b"replacement-body").unwrap();605
let relative = adopt_file(&root, &source, "assets/body").unwrap();606
assert_ne!(relative, MALE_ASSET);607
assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body");608
assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);609
let extra = outside.join("extra.glb");610
fs::write(&extra, b"extra-body").unwrap();611
let relative = adopt_file(&root, &extra, "assets/body").unwrap();612
session.set_body_path(Sex::Male, relative);613
assert!(session.is_dirty());614
session.save().unwrap();615
assert!(!session.is_dirty());616
assert_eq!(617
fs::read(root.join("assets/body/extra.glb")).unwrap(),618
b"extra-body"619
);620
let _ = fs::remove_dir_all(&outside);621
let _ = fs::remove_dir_all(&root);622
}623
624
#[test]625
fn navigation_and_equipment_crud_roundtrip() {626
let root = scratch();627
let mut session = Session::new();628
session.show_body(Sex::Male);629
assert_eq!(session.screen, Screen::Start);630
session.install_new_project(&root).unwrap();631
session.show_body(Sex::Female);632
assert_eq!(session.screen, Screen::Body(Sex::Female));633
session.show_equipment_list();634
assert_eq!(session.screen, Screen::EquipmentList);635
let index = session.add_equipment();636
assert_eq!(index, 0);637
session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string());638
session.save().unwrap();639
let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap();640
assert!(text.contains("zwaard.glb"));641
let mut again = Session::new();642
let opened = crate::project::open_project(&manifest_in(&root)).unwrap();643
again.install_loaded(opened);644
assert_eq!(645
again.equipment[0].model.as_deref(),646
Some("assets/equipment/zwaard.glb")647
);648
again.delete_equipment(0);649
again.save().unwrap();650
assert!(again651
.opened652
.as_ref()653
.unwrap()654
.document655
.equipment_presets656
.is_empty());657
// Old immutable record stays recoverable if an earlier save is restored.658
assert!(root.join("presets/nieuw-1.json").exists());659
let _ = fs::remove_dir_all(&root);660
}661
662
#[test]663
fn failed_save_preserves_previous_equipment_and_manifest_bytes() {664
let root = scratch();665
let mut session = Session::new();666
session.install_new_project(&root).unwrap();667
session.add_equipment();668
session.save().unwrap();669
let manifest = fs::read(manifest_in(&root)).unwrap();670
let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();671
let preset = fs::read(root.join(&relative)).unwrap();672
session.equipment[0].slug = "changed".into();673
// Missing body fails after immutable equipment snapshots are written.674
session.set_body_path(Sex::Male, "assets/body/missing.glb".into());675
assert!(session.save().is_err());676
assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest);677
assert_eq!(fs::read(root.join(&relative)).unwrap(), preset);678
assert!(session.is_dirty());679
let _ = fs::remove_dir_all(root);680
}681
682
#[test]683
fn dirty_confirm_save_discard_and_cancel_keep_bytes() {684
let root = scratch();685
let mut session = Session::new();686
session.install_new_project(&root).unwrap();687
session.set_name("Gewijzigd".to_string());688
assert!(session.is_dirty());689
assert!(!session.gate(Intent::Quit));690
assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay);691
assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");692
assert!(!session.gate(Intent::PickLoad));693
assert_eq!(694
session.resolve(Choice::Discard),695
AfterChoice::Go(Intent::PickLoad)696
);697
assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");698
assert!(session.is_dirty()); // canceled file picker must not discard the old project699
session.set_name("Bewaard".to_string());700
assert!(!session.gate(Intent::PickNew));701
assert_eq!(702
session.resolve(Choice::Save),703
AfterChoice::SaveThen(Intent::PickNew)704
);705
session.save().unwrap();706
assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard");707
let opened = crate::project::open_project(&manifest_in(&root)).unwrap();708
assert_eq!(opened.document.name, "Bewaard");709
let _ = fs::remove_dir_all(&root);710
}711
}@@ -0,0 +1,196 @@
1
//! CPU picture of one skinned pose. This is the high-resolution color pass
2
//! the render button can finish today.
3
4
use crate::preview::{
5
eye_offset, mul, perspective, sample_skin_matrices, view_matrix, Mat4, PreviewCharacter,
6
PreviewControls,
7
};
8
9
pub fn raster_body(
10
character: &PreviewCharacter,
11
controls: &PreviewControls,
12
width: u32,
13
height: u32,
14
) -> Result<Vec<u8>, String> {
15
if width == 0 || height == 0 {
16
return Err("de afbeelding heeft geen pixels".to_string());
17
}
18
if character.indices.len() < 3 || character.positions.is_empty() {
19
return Err("het lichaam heeft geen mesh".to_string());
20
}
21
let width = width as usize;
22
let height = height as usize;
23
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
24
let target = [0.0, 0.9, 0.0];
25
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
26
let eye = [
27
target[0] + offset[0],
28
target[1] + offset[1],
29
target[2] + offset[2],
30
];
31
let view_proj = mul(
32
perspective(38.0, width as f32 / height as f32, 0.05, 40.0),
33
view_matrix(eye, target),
34
);
35
let mut color = [18u8, 22, 28, 255].repeat(width * height);
36
let mut depth = vec![f32::INFINITY; width * height];
37
let positions = skinned_positions(character, &bones);
38
for triangle in character.indices.chunks_exact(3) {
39
let a = project(&view_proj, positions[triangle[0] as usize], width, height);
40
let b = project(&view_proj, positions[triangle[1] as usize], width, height);
41
let c = project(&view_proj, positions[triangle[2] as usize], width, height);
42
if !a.on_screen && !b.on_screen && !c.on_screen {
43
continue;
44
}
45
let min_x = a.x.min(b.x).min(c.x).floor().max(0.0) as usize;
46
let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32 - 1.0) as usize;
47
let min_y = a.y.min(b.y).min(c.y).floor().max(0.0) as usize;
48
let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32 - 1.0) as usize;
49
let area = edge(a.x, a.y, b.x, b.y, c.x, c.y);
50
if area.abs() < 1e-5 {
51
continue;
52
}
53
let shade = shade_triangle(
54
positions[triangle[0] as usize],
55
positions[triangle[1] as usize],
56
positions[triangle[2] as usize],
57
character.base_color,
58
);
59
for y in min_y..=max_y {
60
for x in min_x..=max_x {
61
let px = x as f32 + 0.5;
62
let py = y as f32 + 0.5;
63
let w0 = edge(b.x, b.y, c.x, c.y, px, py) / area;
64
let w1 = edge(c.x, c.y, a.x, a.y, px, py) / area;
65
let w2 = edge(a.x, a.y, b.x, b.y, px, py) / area;
66
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
67
continue;
68
}
69
let z = w0 * a.z + w1 * b.z + w2 * c.z;
70
let index = y * width + x;
71
if z < depth[index] {
72
depth[index] = z;
73
let pixel = &mut color[index * 4..index * 4 + 4];
74
pixel[0] = shade[0];
75
pixel[1] = shade[1];
76
pixel[2] = shade[2];
77
pixel[3] = 255;
78
}
79
}
80
}
81
}
82
Ok(color)
83
}
84
85
struct Projected {
86
x: f32,
87
y: f32,
88
z: f32,
89
on_screen: bool,
90
}
91
92
fn project(view_proj: &Mat4, point: [f32; 3], width: usize, height: usize) -> Projected {
93
let clip = transform_point(view_proj, point);
94
let on_screen = clip[3] > 0.05;
95
let inv = 1.0 / clip[3];
96
let ndc_x = clip[0] * inv;
97
let ndc_y = clip[1] * inv;
98
Projected {
99
x: (ndc_x * 0.5 + 0.5) * width as f32,
100
y: (1.0 - (ndc_y * 0.5 + 0.5)) * height as f32,
101
z: clip[2] * inv,
102
on_screen,
103
}
104
}
105
106
fn transform_point(matrix: &Mat4, point: [f32; 3]) -> [f32; 4] {
107
let [x, y, z] = point;
108
[
109
matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
110
matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
111
matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14],
112
matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15],
113
]
114
}
115
116
fn edge(ax: f32, ay: f32, bx: f32, by: f32, cx: f32, cy: f32) -> f32 {
117
(cx - ax) * (by - ay) - (cy - ay) * (bx - ax)
118
}
119
120
fn shade_triangle(a: [f32; 3], b: [f32; 3], c: [f32; 3], base: [f32; 4]) -> [u8; 3] {
121
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
122
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
123
let normal = [
124
ab[1] * ac[2] - ab[2] * ac[1],
125
ab[2] * ac[0] - ab[0] * ac[2],
126
ab[0] * ac[1] - ab[1] * ac[0],
127
];
128
let length = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
129
let light: [f32; 3] = [-0.25, 0.8, 0.45];
130
let light_len = (light[0] * light[0] + light[1] * light[1] + light[2] * light[2]).sqrt();
131
let mut ndot = 0.55;
132
if length > 1e-6 {
133
let dot = (normal[0] * light[0] + normal[1] * light[1] + normal[2] * light[2]).abs()
134
/ (length * light_len);
135
ndot = 0.35 + 0.65 * dot;
136
}
137
[
138
(base[0] * ndot * 255.0).clamp(0.0, 255.0) as u8,
139
(base[1] * ndot * 255.0).clamp(0.0, 255.0) as u8,
140
(base[2] * ndot * 255.0).clamp(0.0, 255.0) as u8,
141
]
142
}
143
144
fn skinned_positions(character: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {
145
character
146
.positions
147
.iter()
148
.enumerate()
149
.map(|(index, position)| {
150
if bones.is_empty() || character.weights.len() != character.positions.len() {
151
return *position;
152
}
153
let weights = character.weights[index];
154
let joints = character.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);
155
let mut mixed = [0.0; 16];
156
for slot in 0..4 {
157
let weight = weights[slot];
158
if weight == 0.0 {
159
continue;
160
}
161
let joint = joints[slot] as usize;
162
let bone = bones
163
.get(joint)
164
.copied()
165
.unwrap_or_else(crate::preview::identity);
166
for value in 0..16 {
167
mixed[value] += bone[value] * weight;
168
}
169
}
170
let point = transform_point(&mixed, *position);
171
[point[0], point[1], point[2]]
172
})
173
.collect()
174
}
175
176
#[cfg(test)]
177
mod tests {
178
use super::*;
179
use crate::preview::load_gltf_file;
180
use std::path::PathBuf;
181
182
#[test]
183
fn male_walk_still_has_a_visible_body() {
184
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
185
.join("tests/fixtures/bodies/test-body-male.glb");
186
let character = load_gltf_file(&path).unwrap();
187
let controls = PreviewControls::default();
188
let image = raster_body(&character, &controls, 96, 128).unwrap();
189
let body = image
190
.chunks_exact(4)
191
.filter(|pixel| pixel[0] > 40 || pixel[2] > 40)
192
.filter(|pixel| pixel[0] != 18 || pixel[1] != 22 || pixel[2] != 28)
193
.count();
194
assert!(body > 30, "body pixels {body}");
195
}
196
}
@@ -0,0 +1,196 @@1
//! CPU picture of one skinned pose. This is the high-resolution color pass2
//! the render button can finish today.3
4
use crate::preview::{5
eye_offset, mul, perspective, sample_skin_matrices, view_matrix, Mat4, PreviewCharacter,6
PreviewControls,7
};8
9
pub fn raster_body(10
character: &PreviewCharacter,11
controls: &PreviewControls,12
width: u32,13
height: u32,14
) -> Result<Vec<u8>, String> {15
if width == 0 || height == 0 {16
return Err("de afbeelding heeft geen pixels".to_string());17
}18
if character.indices.len() < 3 || character.positions.is_empty() {19
return Err("het lichaam heeft geen mesh".to_string());20
}21
let width = width as usize;22
let height = height as usize;23
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);24
let target = [0.0, 0.9, 0.0];25
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);26
let eye = [27
target[0] + offset[0],28
target[1] + offset[1],29
target[2] + offset[2],30
];31
let view_proj = mul(32
perspective(38.0, width as f32 / height as f32, 0.05, 40.0),33
view_matrix(eye, target),34
);35
let mut color = [18u8, 22, 28, 255].repeat(width * height);36
let mut depth = vec![f32::INFINITY; width * height];37
let positions = skinned_positions(character, &bones);38
for triangle in character.indices.chunks_exact(3) {39
let a = project(&view_proj, positions[triangle[0] as usize], width, height);40
let b = project(&view_proj, positions[triangle[1] as usize], width, height);41
let c = project(&view_proj, positions[triangle[2] as usize], width, height);42
if !a.on_screen && !b.on_screen && !c.on_screen {43
continue;44
}45
let min_x = a.x.min(b.x).min(c.x).floor().max(0.0) as usize;46
let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32 - 1.0) as usize;47
let min_y = a.y.min(b.y).min(c.y).floor().max(0.0) as usize;48
let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32 - 1.0) as usize;49
let area = edge(a.x, a.y, b.x, b.y, c.x, c.y);50
if area.abs() < 1e-5 {51
continue;52
}53
let shade = shade_triangle(54
positions[triangle[0] as usize],55
positions[triangle[1] as usize],56
positions[triangle[2] as usize],57
character.base_color,58
);59
for y in min_y..=max_y {60
for x in min_x..=max_x {61
let px = x as f32 + 0.5;62
let py = y as f32 + 0.5;63
let w0 = edge(b.x, b.y, c.x, c.y, px, py) / area;64
let w1 = edge(c.x, c.y, a.x, a.y, px, py) / area;65
let w2 = edge(a.x, a.y, b.x, b.y, px, py) / area;66
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {67
continue;68
}69
let z = w0 * a.z + w1 * b.z + w2 * c.z;70
let index = y * width + x;71
if z < depth[index] {72
depth[index] = z;73
let pixel = &mut color[index * 4..index * 4 + 4];74
pixel[0] = shade[0];75
pixel[1] = shade[1];76
pixel[2] = shade[2];77
pixel[3] = 255;78
}79
}80
}81
}82
Ok(color)83
}84
85
struct Projected {86
x: f32,87
y: f32,88
z: f32,89
on_screen: bool,90
}91
92
fn project(view_proj: &Mat4, point: [f32; 3], width: usize, height: usize) -> Projected {93
let clip = transform_point(view_proj, point);94
let on_screen = clip[3] > 0.05;95
let inv = 1.0 / clip[3];96
let ndc_x = clip[0] * inv;97
let ndc_y = clip[1] * inv;98
Projected {99
x: (ndc_x * 0.5 + 0.5) * width as f32,100
y: (1.0 - (ndc_y * 0.5 + 0.5)) * height as f32,101
z: clip[2] * inv,102
on_screen,103
}104
}105
106
fn transform_point(matrix: &Mat4, point: [f32; 3]) -> [f32; 4] {107
let [x, y, z] = point;108
[109
matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],110
matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],111
matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14],112
matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15],113
]114
}115
116
fn edge(ax: f32, ay: f32, bx: f32, by: f32, cx: f32, cy: f32) -> f32 {117
(cx - ax) * (by - ay) - (cy - ay) * (bx - ax)118
}119
120
fn shade_triangle(a: [f32; 3], b: [f32; 3], c: [f32; 3], base: [f32; 4]) -> [u8; 3] {121
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];122
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];123
let normal = [124
ab[1] * ac[2] - ab[2] * ac[1],125
ab[2] * ac[0] - ab[0] * ac[2],126
ab[0] * ac[1] - ab[1] * ac[0],127
];128
let length = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();129
let light: [f32; 3] = [-0.25, 0.8, 0.45];130
let light_len = (light[0] * light[0] + light[1] * light[1] + light[2] * light[2]).sqrt();131
let mut ndot = 0.55;132
if length > 1e-6 {133
let dot = (normal[0] * light[0] + normal[1] * light[1] + normal[2] * light[2]).abs()134
/ (length * light_len);135
ndot = 0.35 + 0.65 * dot;136
}137
[138
(base[0] * ndot * 255.0).clamp(0.0, 255.0) as u8,139
(base[1] * ndot * 255.0).clamp(0.0, 255.0) as u8,140
(base[2] * ndot * 255.0).clamp(0.0, 255.0) as u8,141
]142
}143
144
fn skinned_positions(character: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {145
character146
.positions147
.iter()148
.enumerate()149
.map(|(index, position)| {150
if bones.is_empty() || character.weights.len() != character.positions.len() {151
return *position;152
}153
let weights = character.weights[index];154
let joints = character.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);155
let mut mixed = [0.0; 16];156
for slot in 0..4 {157
let weight = weights[slot];158
if weight == 0.0 {159
continue;160
}161
let joint = joints[slot] as usize;162
let bone = bones163
.get(joint)164
.copied()165
.unwrap_or_else(crate::preview::identity);166
for value in 0..16 {167
mixed[value] += bone[value] * weight;168
}169
}170
let point = transform_point(&mixed, *position);171
[point[0], point[1], point[2]]172
})173
.collect()174
}175
176
#[cfg(test)]177
mod tests {178
use super::*;179
use crate::preview::load_gltf_file;180
use std::path::PathBuf;181
182
#[test]183
fn male_walk_still_has_a_visible_body() {184
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))185
.join("tests/fixtures/bodies/test-body-male.glb");186
let character = load_gltf_file(&path).unwrap();187
let controls = PreviewControls::default();188
let image = raster_body(&character, &controls, 96, 128).unwrap();189
let body = image190
.chunks_exact(4)191
.filter(|pixel| pixel[0] > 40 || pixel[2] > 40)192
.filter(|pixel| pixel[0] != 18 || pixel[1] != 22 || pixel[2] != 28)193
.count();194
assert!(body > 30, "body pixels {body}");195
}196
}@@ -0,0 +1,133 @@
1
//! Packaged icons are rasters of Tim Falken's original PNG.
2
3
use std::fs;
4
use std::io::Cursor;
5
use std::path::PathBuf;
6
7
use paperdoll_renderer::icon::{decode_rgba, hicolor_rgba, HICOLOR_SIZES};
8
9
fn root() -> PathBuf {
10
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
11
}
12
13
fn encode_png(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
14
let mut bytes = Vec::new();
15
let mut encoder = png::Encoder::new(Cursor::new(&mut bytes), width, height);
16
encoder.set_color(png::ColorType::Rgba);
17
encoder.set_depth(png::BitDepth::Eight);
18
let mut writer = encoder.write_header().expect("png header");
19
writer.write_image_data(pixels).expect("png pixels");
20
drop(writer);
21
bytes
22
}
23
24
fn write_packages() {
25
let manifest = root();
26
for size in HICOLOR_SIZES {
27
let pixels = hicolor_rgba(size);
28
let path = manifest.join(format!(
29
"packaging/linux/hicolor/{size}x{size}/apps/paperdoll-renderer.png"
30
));
31
fs::create_dir_all(path.parent().expect("icon dir")).expect("icon dir");
32
fs::write(&path, encode_png(size, size, &pixels)).expect("write png");
33
}
34
let mut images = Vec::new();
35
for size in [256, 64, 48, 32, 24, 16] {
36
images.push((size, encode_png(size, size, &hicolor_rgba(size))));
37
}
38
let ico = manifest.join("packaging/windows/paperdoll-renderer.ico");
39
fs::create_dir_all(ico.parent().expect("ico dir")).expect("ico dir");
40
fs::write(&ico, ico_bytes(&images)).expect("write ico");
41
}
42
43
fn ico_bytes(images: &[(u32, Vec<u8>)]) -> Vec<u8> {
44
let count = u16::try_from(images.len()).expect("ico count");
45
let mut bytes = Vec::new();
46
bytes.extend_from_slice(&0u16.to_le_bytes());
47
bytes.extend_from_slice(&1u16.to_le_bytes());
48
bytes.extend_from_slice(&count.to_le_bytes());
49
let mut offset = 6 + images.len() * 16;
50
for (size, png) in images {
51
let dim = if *size >= 256 { 0 } else { *size as u8 };
52
bytes.push(dim);
53
bytes.push(dim);
54
bytes.push(0);
55
bytes.push(0);
56
bytes.extend_from_slice(&1u16.to_le_bytes());
57
bytes.extend_from_slice(&32u16.to_le_bytes());
58
bytes.extend_from_slice(&(u32::try_from(png.len()).expect("png len")).to_le_bytes());
59
bytes.extend_from_slice(&(u32::try_from(offset).expect("ico offset")).to_le_bytes());
60
offset += png.len();
61
}
62
for (_, png) in images {
63
bytes.extend_from_slice(png);
64
}
65
bytes
66
}
67
68
fn read(path: &str) -> String {
69
fs::read_to_string(root().join(path)).unwrap_or_else(|err| panic!("{path}: {err}"))
70
}
71
72
#[test]
73
fn packaged_icons_match_tims_png() {
74
if std::env::var("PAPERDOLL_WRITE_ICONS").ok().as_deref() == Some("1") {
75
write_packages();
76
}
77
for size in HICOLOR_SIZES {
78
let path = format!("packaging/linux/hicolor/{size}x{size}/apps/paperdoll-renderer.png");
79
let bytes = fs::read(root().join(&path)).unwrap_or_else(|err| panic!("{path}: {err}"));
80
let (width, height, pixels) = decode_rgba(&bytes).unwrap();
81
assert_eq!((width, height), (size, size), "{path}");
82
assert_eq!(pixels, hicolor_rgba(size), "{path}");
83
}
84
85
let ico = fs::read(root().join("packaging/windows/paperdoll-renderer.ico")).expect("ico");
86
let count = u16::from_le_bytes([ico[4], ico[5]]) as usize;
87
assert_eq!(u16::from_le_bytes([ico[0], ico[1]]), 0);
88
assert_eq!(u16::from_le_bytes([ico[2], ico[3]]), 1);
89
assert_eq!(count, 6);
90
for index in 0..count {
91
let entry = 6 + index * 16;
92
let width = match ico[entry] {
93
0 => 256,
94
value => u32::from(value),
95
};
96
let bytes_len = u32::from_le_bytes(ico[entry + 8..entry + 12].try_into().unwrap()) as usize;
97
let offset = u32::from_le_bytes(ico[entry + 12..entry + 16].try_into().unwrap()) as usize;
98
let png = &ico[offset..offset + bytes_len];
99
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
100
let (got_w, got_h, pixels) = decode_rgba(png).unwrap();
101
assert_eq!((got_w, got_h), (width, width));
102
assert_eq!(pixels, hicolor_rgba(width));
103
}
104
}
105
106
#[test]
107
fn desktop_and_appdata_name_the_same_icon() {
108
let desktop = read("packaging/linux/paperdoll-renderer.desktop");
109
let metainfo = read("packaging/linux/paperdoll-renderer.metainfo.xml");
110
assert!(desktop
111
.lines()
112
.any(|line| line == "Icon=paperdoll-renderer"));
113
assert!(metainfo.contains("<icon type=\"stock\">paperdoll-renderer</icon>"));
114
assert!(metainfo.contains("paperdoll-renderer.desktop"));
115
for script in [
116
"scripts/package/deb.sh",
117
"scripts/package/appimage.sh",
118
"packaging/arch/PKGBUILD",
119
] {
120
let text = read(script);
121
assert!(
122
text.contains("hicolor") && text.contains("paperdoll-renderer.metainfo.xml"),
123
"{script} does not install the hicolor icon and the metainfo"
124
);
125
assert!(
126
!text.contains("icon-64.png"),
127
"{script} still installs the old placeholder icon"
128
);
129
}
130
let windows = read("scripts/package/windows.sh");
131
assert!(windows.contains("pe_has_icon.py"));
132
assert!(!windows.contains(".ico"));
133
}
@@ -0,0 +1,133 @@1
//! Packaged icons are rasters of Tim Falken's original PNG.2
3
use std::fs;4
use std::io::Cursor;5
use std::path::PathBuf;6
7
use paperdoll_renderer::icon::{decode_rgba, hicolor_rgba, HICOLOR_SIZES};8
9
fn root() -> PathBuf {10
PathBuf::from(env!("CARGO_MANIFEST_DIR"))11
}12
13
fn encode_png(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {14
let mut bytes = Vec::new();15
let mut encoder = png::Encoder::new(Cursor::new(&mut bytes), width, height);16
encoder.set_color(png::ColorType::Rgba);17
encoder.set_depth(png::BitDepth::Eight);18
let mut writer = encoder.write_header().expect("png header");19
writer.write_image_data(pixels).expect("png pixels");20
drop(writer);21
bytes22
}23
24
fn write_packages() {25
let manifest = root();26
for size in HICOLOR_SIZES {27
let pixels = hicolor_rgba(size);28
let path = manifest.join(format!(29
"packaging/linux/hicolor/{size}x{size}/apps/paperdoll-renderer.png"30
));31
fs::create_dir_all(path.parent().expect("icon dir")).expect("icon dir");32
fs::write(&path, encode_png(size, size, &pixels)).expect("write png");33
}34
let mut images = Vec::new();35
for size in [256, 64, 48, 32, 24, 16] {36
images.push((size, encode_png(size, size, &hicolor_rgba(size))));37
}38
let ico = manifest.join("packaging/windows/paperdoll-renderer.ico");39
fs::create_dir_all(ico.parent().expect("ico dir")).expect("ico dir");40
fs::write(&ico, ico_bytes(&images)).expect("write ico");41
}42
43
fn ico_bytes(images: &[(u32, Vec<u8>)]) -> Vec<u8> {44
let count = u16::try_from(images.len()).expect("ico count");45
let mut bytes = Vec::new();46
bytes.extend_from_slice(&0u16.to_le_bytes());47
bytes.extend_from_slice(&1u16.to_le_bytes());48
bytes.extend_from_slice(&count.to_le_bytes());49
let mut offset = 6 + images.len() * 16;50
for (size, png) in images {51
let dim = if *size >= 256 { 0 } else { *size as u8 };52
bytes.push(dim);53
bytes.push(dim);54
bytes.push(0);55
bytes.push(0);56
bytes.extend_from_slice(&1u16.to_le_bytes());57
bytes.extend_from_slice(&32u16.to_le_bytes());58
bytes.extend_from_slice(&(u32::try_from(png.len()).expect("png len")).to_le_bytes());59
bytes.extend_from_slice(&(u32::try_from(offset).expect("ico offset")).to_le_bytes());60
offset += png.len();61
}62
for (_, png) in images {63
bytes.extend_from_slice(png);64
}65
bytes66
}67
68
fn read(path: &str) -> String {69
fs::read_to_string(root().join(path)).unwrap_or_else(|err| panic!("{path}: {err}"))70
}71
72
#[test]73
fn packaged_icons_match_tims_png() {74
if std::env::var("PAPERDOLL_WRITE_ICONS").ok().as_deref() == Some("1") {75
write_packages();76
}77
for size in HICOLOR_SIZES {78
let path = format!("packaging/linux/hicolor/{size}x{size}/apps/paperdoll-renderer.png");79
let bytes = fs::read(root().join(&path)).unwrap_or_else(|err| panic!("{path}: {err}"));80
let (width, height, pixels) = decode_rgba(&bytes).unwrap();81
assert_eq!((width, height), (size, size), "{path}");82
assert_eq!(pixels, hicolor_rgba(size), "{path}");83
}84
85
let ico = fs::read(root().join("packaging/windows/paperdoll-renderer.ico")).expect("ico");86
let count = u16::from_le_bytes([ico[4], ico[5]]) as usize;87
assert_eq!(u16::from_le_bytes([ico[0], ico[1]]), 0);88
assert_eq!(u16::from_le_bytes([ico[2], ico[3]]), 1);89
assert_eq!(count, 6);90
for index in 0..count {91
let entry = 6 + index * 16;92
let width = match ico[entry] {93
0 => 256,94
value => u32::from(value),95
};96
let bytes_len = u32::from_le_bytes(ico[entry + 8..entry + 12].try_into().unwrap()) as usize;97
let offset = u32::from_le_bytes(ico[entry + 12..entry + 16].try_into().unwrap()) as usize;98
let png = &ico[offset..offset + bytes_len];99
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");100
let (got_w, got_h, pixels) = decode_rgba(png).unwrap();101
assert_eq!((got_w, got_h), (width, width));102
assert_eq!(pixels, hicolor_rgba(width));103
}104
}105
106
#[test]107
fn desktop_and_appdata_name_the_same_icon() {108
let desktop = read("packaging/linux/paperdoll-renderer.desktop");109
let metainfo = read("packaging/linux/paperdoll-renderer.metainfo.xml");110
assert!(desktop111
.lines()112
.any(|line| line == "Icon=paperdoll-renderer"));113
assert!(metainfo.contains("<icon type=\"stock\">paperdoll-renderer</icon>"));114
assert!(metainfo.contains("paperdoll-renderer.desktop"));115
for script in [116
"scripts/package/deb.sh",117
"scripts/package/appimage.sh",118
"packaging/arch/PKGBUILD",119
] {120
let text = read(script);121
assert!(122
text.contains("hicolor") && text.contains("paperdoll-renderer.metainfo.xml"),123
"{script} does not install the hicolor icon and the metainfo"124
);125
assert!(126
!text.contains("icon-64.png"),127
"{script} still installs the old placeholder icon"128
);129
}130
let windows = read("scripts/package/windows.sh");131
assert!(windows.contains("pe_has_icon.py"));132
assert!(!windows.contains(".ico"));133
}