merge: live preview and project editor recovery checkpoint
@@ -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"
@@ -1808,14 +2119,26 @@ name = "paperdoll-renderer"
1808
2119
version = "0.1.0"
1809
2120
dependencies = [
1810
2121
"eframe",
2122
"egui_glow",
2123
"glow 0.14.2",
1811
2124
"gltf",
1812
2125
"libloading",
2126
"png",
2127
"rfd",
1813
2128
"serde",
1814
2129
"serde_json",
1815
2130
"tobj",
1816
2131
"ufbx",
2132
"winreg",
2133
"winresource",
1817
2134
]
1818
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
1819
2142
[[package]]
1820
2143
name = "parking_lot"
1821
2144
version = "0.12.5"
@@ -1877,6 +2200,17 @@ version = "0.2.17"
1877
2200
source = "registry+https://github.com/rust-lang/crates.io-index"
1878
2201
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1879
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
1880
2214
[[package]]
1881
2215
name = "pkg-config"
1882
2216
version = "0.3.34"
@@ -1916,6 +2250,12 @@ dependencies = [
1916
2250
"windows-sys 0.61.2",
1917
2251
]
1918
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
1919
2259
[[package]]
1920
2260
name = "potential_utf"
1921
2261
version = "0.1.6"
@@ -1925,6 +2265,15 @@ dependencies = [
1925
2265
"zerovec",
1926
2266
]
1927
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
1928
2277
[[package]]
1929
2278
name = "presser"
1930
2279
version = "0.3.1"
@@ -1991,6 +2340,35 @@ version = "6.0.0"
1991
2340
source = "registry+https://github.com/rust-lang/crates.io-index"
1992
2341
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1993
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
1994
2372
[[package]]
1995
2373
name = "raw-window-handle"
1996
2374
version = "0.6.2"
@@ -2030,6 +2408,30 @@ version = "1.1.0"
2030
2408
source = "registry+https://github.com/rust-lang/crates.io-index"
2031
2409
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2032
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
2033
2435
[[package]]
2034
2436
name = "rustc-hash"
2035
2437
version = "1.1.0"
@@ -2153,12 +2555,42 @@ dependencies = [
2153
2555
"zmij",
2154
2556
]
2155
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
2156
2578
[[package]]
2157
2579
name = "shlex"
2158
2580
version = "2.0.1"
2159
2581
source = "registry+https://github.com/rust-lang/crates.io-index"
2160
2582
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2161
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
2162
2594
[[package]]
2163
2595
name = "simd-adler32"
2164
2596
version = "0.3.10"
@@ -2339,6 +2771,19 @@ dependencies = [
2339
2771
"syn 3.0.9",
2340
2772
]
2341
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
2342
2787
[[package]]
2343
2788
name = "termcolor"
2344
2789
version = "1.4.1"
@@ -2416,6 +2861,21 @@ dependencies = [
2416
2861
"pin-project-lite",
2417
2862
]
2418
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
2419
2879
[[package]]
2420
2880
name = "toml_datetime"
2421
2881
version = "1.1.3+spec-1.1.0"
@@ -2446,6 +2906,12 @@ dependencies = [
2446
2906
"winnow",
2447
2907
]
2448
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
2449
2915
[[package]]
2450
2916
name = "tracing"
2451
2917
version = "0.1.44"
@@ -2454,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2454
2920
dependencies = [
2455
2921
"log",
2456
2922
"pin-project-lite",
2923
"tracing-attributes",
2457
2924
"tracing-core",
2458
2925
]
2459
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
2460
2938
[[package]]
2461
2939
name = "tracing-core"
2462
2940
version = "0.1.36"
2463
2941
source = "registry+https://github.com/rust-lang/crates.io-index"
2464
2942
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2943
dependencies = [
2944
"once_cell",
2945
]
2465
2946
2466
2947
[[package]]
2467
2948
name = "ttf-parser"
@@ -2478,6 +2959,17 @@ dependencies = [
2478
2959
"rustc-hash 2.1.3",
2479
2960
]
2480
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
2481
2973
[[package]]
2482
2974
name = "ufbx"
2483
2975
version = "0.11.5"
@@ -2521,6 +3013,7 @@ dependencies = [
2521
3013
"idna",
2522
3014
"percent-encoding",
2523
3015
"serde",
3016
"serde_derive",
2524
3017
]
2525
3018
2526
3019
[[package]]
@@ -2544,6 +3037,17 @@ version = "1.0.0"
2544
3037
source = "registry+https://github.com/rust-lang/crates.io-index"
2545
3038
checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
2546
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
2547
3051
[[package]]
2548
3052
name = "version_check"
2549
3053
version = "0.9.5"
@@ -3059,7 +3563,7 @@ dependencies = [
3059
3563
"android-activity",
3060
3564
"atomic-waker",
3061
3565
"bitflags 2.13.2",
3062
"block2",
3566
"block2 0.5.1",
3063
3567
"bytemuck",
3064
3568
"calloop 0.13.0",
3065
3569
"cfg_aliases 0.2.2",
@@ -3109,6 +3613,26 @@ dependencies = [
3109
3613
"memchr",
3110
3614
]
3111
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
3112
3636
[[package]]
3113
3637
name = "wit-bindgen"
3114
3638
version = "0.57.1"
@@ -3207,6 +3731,76 @@ dependencies = [
3207
3731
"synstructure",
3208
3732
]
3209
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
3210
3804
[[package]]
3211
3805
name = "zerocopy"
3212
3806
version = "0.8.63"
@@ -3307,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3307
3901
dependencies = [
3308
3902
"zune-core",
3309
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"@@ -1808,14 +2119,26 @@ name = "paperdoll-renderer"1808
version = "0.1.0"2119
version = "0.1.0"1809
dependencies = [2120
dependencies = [1810
"eframe",2121
"eframe",2122
"egui_glow",2123
"glow 0.14.2",1811
"gltf",2124
"gltf",1812
"libloading",2125
"libloading",2126
"png",2127
"rfd",1813
"serde",2128
"serde",1814
"serde_json",2129
"serde_json",1815
"tobj",2130
"tobj",1816
"ufbx",2131
"ufbx",2132
"winreg",2133
"winresource",1817
]2134
]1818
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
1819
[[package]]2142
[[package]]1820
name = "parking_lot"2143
name = "parking_lot"1821
version = "0.12.5"2144
version = "0.12.5"@@ -1877,6 +2200,17 @@ version = "0.2.17"1877
source = "registry+https://github.com/rust-lang/crates.io-index"2200
source = "registry+https://github.com/rust-lang/crates.io-index"1878
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2201
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"1879
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
1880
[[package]]2214
[[package]]1881
name = "pkg-config"2215
name = "pkg-config"1882
version = "0.3.34"2216
version = "0.3.34"@@ -1916,6 +2250,12 @@ dependencies = [1916
"windows-sys 0.61.2",2250
"windows-sys 0.61.2",1917
]2251
]1918
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
1919
[[package]]2259
[[package]]1920
name = "potential_utf"2260
name = "potential_utf"1921
version = "0.1.6"2261
version = "0.1.6"@@ -1925,6 +2265,15 @@ dependencies = [1925
"zerovec",2265
"zerovec",1926
]2266
]1927
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
1928
[[package]]2277
[[package]]1929
name = "presser"2278
name = "presser"1930
version = "0.3.1"2279
version = "0.3.1"@@ -1991,6 +2340,35 @@ version = "6.0.0"1991
source = "registry+https://github.com/rust-lang/crates.io-index"2340
source = "registry+https://github.com/rust-lang/crates.io-index"1992
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"2341
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"1993
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
1994
[[package]]2372
[[package]]1995
name = "raw-window-handle"2373
name = "raw-window-handle"1996
version = "0.6.2"2374
version = "0.6.2"@@ -2030,6 +2408,30 @@ version = "1.1.0"2030
source = "registry+https://github.com/rust-lang/crates.io-index"2408
source = "registry+https://github.com/rust-lang/crates.io-index"2031
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"2409
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"2032
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
2033
[[package]]2435
[[package]]2034
name = "rustc-hash"2436
name = "rustc-hash"2035
version = "1.1.0"2437
version = "1.1.0"@@ -2153,12 +2555,42 @@ dependencies = [2153
"zmij",2555
"zmij",2154
]2556
]2155
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
2156
[[package]]2578
[[package]]2157
name = "shlex"2579
name = "shlex"2158
version = "2.0.1"2580
version = "2.0.1"2159
source = "registry+https://github.com/rust-lang/crates.io-index"2581
source = "registry+https://github.com/rust-lang/crates.io-index"2160
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"2582
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"2161
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
2162
[[package]]2594
[[package]]2163
name = "simd-adler32"2595
name = "simd-adler32"2164
version = "0.3.10"2596
version = "0.3.10"@@ -2339,6 +2771,19 @@ dependencies = [2339
"syn 3.0.9",2771
"syn 3.0.9",2340
]2772
]2341
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
2342
[[package]]2787
[[package]]2343
name = "termcolor"2788
name = "termcolor"2344
version = "1.4.1"2789
version = "1.4.1"@@ -2416,6 +2861,21 @@ dependencies = [2416
"pin-project-lite",2861
"pin-project-lite",2417
]2862
]2418
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
2419
[[package]]2879
[[package]]2420
name = "toml_datetime"2880
name = "toml_datetime"2421
version = "1.1.3+spec-1.1.0"2881
version = "1.1.3+spec-1.1.0"@@ -2446,6 +2906,12 @@ dependencies = [2446
"winnow",2906
"winnow",2447
]2907
]2448
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
2449
[[package]]2915
[[package]]2450
name = "tracing"2916
name = "tracing"2451
version = "0.1.44"2917
version = "0.1.44"@@ -2454,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"2454
dependencies = [2920
dependencies = [2455
"log",2921
"log",2456
"pin-project-lite",2922
"pin-project-lite",2923
"tracing-attributes",2457
"tracing-core",2924
"tracing-core",2458
]2925
]2459
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
2460
[[package]]2938
[[package]]2461
name = "tracing-core"2939
name = "tracing-core"2462
version = "0.1.36"2940
version = "0.1.36"2463
source = "registry+https://github.com/rust-lang/crates.io-index"2941
source = "registry+https://github.com/rust-lang/crates.io-index"2464
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"2942
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"2943
dependencies = [2944
"once_cell",2945
]2465
2946
2466
[[package]]2947
[[package]]2467
name = "ttf-parser"2948
name = "ttf-parser"@@ -2478,6 +2959,17 @@ dependencies = [2478
"rustc-hash 2.1.3",2959
"rustc-hash 2.1.3",2479
]2960
]2480
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
2481
[[package]]2973
[[package]]2482
name = "ufbx"2974
name = "ufbx"2483
version = "0.11.5"2975
version = "0.11.5"@@ -2521,6 +3013,7 @@ dependencies = [2521
"idna",3013
"idna",2522
"percent-encoding",3014
"percent-encoding",2523
"serde",3015
"serde",3016
"serde_derive",2524
]3017
]2525
3018
2526
[[package]]3019
[[package]]@@ -2544,6 +3037,17 @@ version = "1.0.0"2544
source = "registry+https://github.com/rust-lang/crates.io-index"3037
source = "registry+https://github.com/rust-lang/crates.io-index"2545
checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"3038
checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"2546
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
2547
[[package]]3051
[[package]]2548
name = "version_check"3052
name = "version_check"2549
version = "0.9.5"3053
version = "0.9.5"@@ -3059,7 +3563,7 @@ dependencies = [3059
"android-activity",3563
"android-activity",3060
"atomic-waker",3564
"atomic-waker",3061
"bitflags 2.13.2",3565
"bitflags 2.13.2",3062
"block2",3566
"block2 0.5.1",3063
"bytemuck",3567
"bytemuck",3064
"calloop 0.13.0",3568
"calloop 0.13.0",3065
"cfg_aliases 0.2.2",3569
"cfg_aliases 0.2.2",@@ -3109,6 +3613,26 @@ dependencies = [3109
"memchr",3613
"memchr",3110
]3614
]3111
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
3112
[[package]]3636
[[package]]3113
name = "wit-bindgen"3637
name = "wit-bindgen"3114
version = "0.57.1"3638
version = "0.57.1"@@ -3207,6 +3731,76 @@ dependencies = [3207
"synstructure",3731
"synstructure",3208
]3732
]3209
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
3210
[[package]]3804
[[package]]3211
name = "zerocopy"3805
name = "zerocopy"3212
version = "0.8.63"3806
version = "0.8.63"@@ -3307,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"3307
dependencies = [3901
dependencies = [3308
"zune-core",3902
"zune-core",3309
]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,13 +4,23 @@ 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"] }
11
egui_glow = "0.29.1"
12
glow = "0.14.2"
11
13
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
12
14
libloading = "0.8"
15
png = "0.18"
16
rfd = "0.15.4"
13
17
serde = { version = "1", features = ["derive"] }
14
18
serde_json = "1"
15
19
tobj = "4.0.3"
16
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,13 +4,23 @@ 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"] }11
egui_glow = "0.29.1"12
glow = "0.14.2"11
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"] }12
libloading = "0.8"14
libloading = "0.8"15
png = "0.18"16
rfd = "0.15.4"13
serde = { version = "1", features = ["derive"] }17
serde = { version = "1", features = ["derive"] }14
serde_json = "1"18
serde_json = "1"15
tobj = "4.0.3"19
tobj = "4.0.3"16
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,51 +1,55 @@
1
1
# Paperdoll Renderer
2
2
3
Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB en FBX (mesh, materialen, textures, skeleton en animatieclips) plus OBJ+MTL als statische equipment. FBX gaat via ufbx, meegecompileerd in de binary, niet via een losse libassimp. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
3
Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
4
4
5
## Eisen
5
## Starten
6
6
7
- Rust 1.96.0 (vastgezet in `rust-toolchain.toml`; hetzelfde image als de publieke posprint-pipeline, `rust:1.96-bookworm`)
8
- Een X11-display voor het venster
9
- Voor compileren: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev`, `libgl1-mesa-dev`
10
- Voor het venster zelf: `libxkbcommon-x11-0`, `libxcb1`, `libx11-6`, `libgl1`. Die laadt eframe pas bij het openen. Ontbreken ze, dan stopt het programma met een melding in plaats van een panic.
11
12
## Lokaal
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.
13
8
14
9
```bash
15
cargo test --locked
16
cargo run -- --status
17
10
cargo run
11
cargo run -- "/pad met spaties/character.ppd"
12
cargo run -- --preview-fixture
13
cargo run -- --status
18
14
```
19
15
20
`--status` print de capability-lijst en opent geen venster. Een modelpad op de opdrachtregel wordt geweigerd. In het venster vullen Nieuw, Open en Opslaan een projectmap. Kies daarna bestanden in die map; de opgeslagen paden blijven relatief.
16
## Project en bodies
21
17
22
## Testlichamen
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.
23
19
24
`tests/fixtures/bodies/` bevat twee procedurele GLB-bestanden, man en vrouw, elk met een skeleton en de clips `walk` en `attack`. Ze zijn CC0, duidelijk als testasset gelabeld, en geen definitieve karakters. Opnieuw maken:
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.
25
21
26
```bash
27
python3 scripts/generate_test_bodies.py
28
```
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
29
30
`cargo test --locked` controleert of de bestanden leesbaar zijn, het skeleton en beide clips bevatten, en nog gelijk zijn aan de generator. Het venster laadt ze niet.
30

31
31
32
## Wat deze build niet doet
32
## Equipment en opslaan
33
33
34
OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.
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/`.
35
35
36
## BlueGit
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.
37
37
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna het release-script.
38
## Openen met
39
39
40
Dat script publiceert niet. De publieke BlueGit-routes kunnen artifacts niet aan een bestaande tag of release hangen. `POST /api/v1/repos/{owner}/{repo}/releases` maakt de tag zelf (HTTP 409 als die er al is). `replace: true` is op een tag-pipeline HTTP 403. `scripts/ci/release.sh` doet geen van beide en stopt met exit 3 (`release-blocked: no-attach-endpoint`). De volgorde blijft een tag-push. De ontbrekende serverroute staat in `design.md`. Er is geen tag en geen release gestart.
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.
41
43
42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.
44
Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.
43
45
44
Pakketten lokaal, zonder publiceren:
46
## Tests
45
47
46
48
```bash
47
bash scripts/package/deb.sh
48
bash scripts/package/appimage.sh
49
cargo fmt --check
50
cargo check --workspace --all-targets --locked
51
cargo clippy --workspace --all-targets --locked -- -D warnings
52
cargo test --workspace --locked
49
53
```
50
54
51
Op deze machine zijn het `.deb`-pakket en de AppImage gebouwd. `paperdoll-renderer --status` via de AppImage geeft dezelfde status als de gewone binary. Het Arch-pakket heeft `makepkg` nodig en de Windows-zip de MinGW-crosscompiler; die twee builds zijn hier niet gedraaid.
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,51 +1,55 @@1
# Paperdoll Renderer1
# Paperdoll Renderer2
2
3
Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB en FBX (mesh, materialen, textures, skeleton en animatieclips) plus OBJ+MTL als statische equipment. FBX gaat via ufbx, meegecompileerd in de binary, niet via een losse libassimp. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).3
Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.4
4
5
## Eisen5
## Starten6
6
7
- Rust 1.96.0 (vastgezet in `rust-toolchain.toml`; hetzelfde image als de publieke posprint-pipeline, `rust:1.96-bookworm`)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.8
- Een X11-display voor het venster9
- Voor compileren: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev`, `libgl1-mesa-dev`10
- Voor het venster zelf: `libxkbcommon-x11-0`, `libxcb1`, `libx11-6`, `libgl1`. Die laadt eframe pas bij het openen. Ontbreken ze, dan stopt het programma met een melding in plaats van een panic.11
12
## Lokaal13
8
14
```bash9
```bash15
cargo test --locked16
cargo run -- --status17
cargo run10
cargo run11
cargo run -- "/pad met spaties/character.ppd"12
cargo run -- --preview-fixture13
cargo run -- --status18
```14
```19
15
20
`--status` print de capability-lijst en opent geen venster. Een modelpad op de opdrachtregel wordt geweigerd. In het venster vullen Nieuw, Open en Opslaan een projectmap. Kies daarna bestanden in die map; de opgeslagen paden blijven relatief.16
## Project en bodies21
17
22
## Testlichamen18
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.23
19
24
`tests/fixtures/bodies/` bevat twee procedurele GLB-bestanden, man en vrouw, elk met een skeleton en de clips `walk` en `attack`. Ze zijn CC0, duidelijk als testasset gelabeld, en geen definitieve karakters. Opnieuw maken: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.25
21
26
```bash22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:27
python3 scripts/generate_test_bodies.py23
28
```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
29
30
`cargo test --locked` controleert of de bestanden leesbaar zijn, het skeleton en beide clips bevatten, en nog gelijk zijn aan de generator. Het venster laadt ze niet.30
31
31
32
## Wat deze build niet doet32
## Equipment en opslaan33
33
34
OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.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/`.35
35
36
## BlueGit36
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.37
37
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna het release-script.38
## Openen met39
39
40
Dat script publiceert niet. De publieke BlueGit-routes kunnen artifacts niet aan een bestaande tag of release hangen. `POST /api/v1/repos/{owner}/{repo}/releases` maakt de tag zelf (HTTP 409 als die er al is). `replace: true` is op een tag-pipeline HTTP 403. `scripts/ci/release.sh` doet geen van beide en stopt met exit 3 (`release-blocked: no-attach-endpoint`). De volgorde blijft een tag-push. De ontbrekende serverroute staat in `design.md`. Er is geen tag en geen release gestart.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.41
43
42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.44
Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.43
45
44
Pakketten lokaal, zonder publiceren:46
## Tests45
47
46
```bash48
```bash47
bash scripts/package/deb.sh49
cargo fmt --check48
bash scripts/package/appimage.sh50
cargo check --workspace --all-targets --locked51
cargo clippy --workspace --all-targets --locked -- -D warnings52
cargo test --workspace --locked49
```53
```50
54
51
Op deze machine zijn het `.deb`-pakket en de AppImage gebouwd. `paperdoll-renderer --status` via de AppImage geeft dezelfde status als de gewone binary. Het Arch-pakket heeft `makepkg` nodig en de Windows-zip de MinGW-crosscompiler; die twee builds zijn hier niet gedraaid.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
}@@ -63,15 +63,15 @@ Do not create empty crates solely to imply completion. Establish boundaries as f
63
63
64
64
### Import implementation status
65
65
66
The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. A later viewport can consume the same CPU records (`MeshRecord`, `JointRecord`, `ClipRecord`) without treating this importer as a renderer.
66
The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. The live preview loads glTF/GLB on its own and skins the mesh; the inventory records stay the import report.
67
67
68
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.
69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. Playback, the 3D viewport and paperdoll rendering remain absent. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the orthographic paperdoll export is later work, recorded in section 11. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
71
71
72
72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
73
73
|---|---|---|---|---|---|
74
| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. Not rendered. |
74
| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |
75
75
| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |
76
76
| FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters and bone parents | Takes as animation stacks, with duration and transform channels | Read in this build by vendored ufbx. Not rendered. Not a claim that every FBX dialect matches. |
77
77
| Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |
@@ -141,7 +141,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write
141
141
142
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.
143
143
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.
145
145
146
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.
147
147
@@ -157,6 +157,12 @@ Depth compositor selects the nearest valid surface per pixel from body and chose
157
157
158
158
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.
159
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.
161
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.
165
160
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.
161
167
162
168
Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.
@@ -167,6 +173,19 @@ Release publication uses documented BlueGit release/artifact API and job-scoped
167
173
168
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.
169
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
170
189
## 12. Incremental feature plan and acceptance gates
171
190
172
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.
@@ -63,15 +63,15 @@ Do not create empty crates solely to imply completion. Establish boundaries as f63
63
64
### Import implementation status64
### Import implementation status65
65
66
The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. A later viewport can consume the same CPU records (`MeshRecord`, `JointRecord`, `ClipRecord`) without treating this importer as a renderer.66
The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. The live preview loads glTF/GLB on its own and skins the mesh; the inventory records stay the import report.67
67
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. Playback, the 3D viewport and paperdoll rendering remain absent. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the orthographic paperdoll export is later work, recorded in section 11. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).71
71
72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |73
|---|---|---|---|---|---|73
|---|---|---|---|---|---|74
| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. Not rendered. |74
| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |75
| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |75
| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |76
| FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters and bone parents | Takes as animation stacks, with duration and transform channels | Read in this build by vendored ufbx. Not rendered. Not a claim that every FBX dialect matches. |76
| FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters and bone parents | Takes as animation stacks, with duration and transform channels | Read in this build by vendored ufbx. Not rendered. Not a claim that every FBX dialect matches. |77
| Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |77
| Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |@@ -141,7 +141,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write141
141
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.143
143
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.145
145
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.147
147
@@ -157,6 +157,12 @@ Depth compositor selects the nearest valid surface per pixel from body and chose157
157
158
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.158
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.159
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.161
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.165
160
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.161
167
162
Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.168
Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.@@ -167,6 +173,19 @@ Release publication uses documented BlueGit release/artifact API and job-scoped167
173
168
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.169
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
170
## 12. Incremental feature plan and acceptance gates189
## 12. Incremental feature plan and acceptance gates171
190
172
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,567 +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::project::{
9
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
10
ProjectError, FORMAT_VERSION, MANIFEST_NAME,
11
};
8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9
use crate::import::{import_project_model, ImportedModel};
10
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
11
use crate::preview_gl::Viewport;
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};
12
15
13
16
pub struct EditorApp {
14
root_input: String,
15
open_input: String,
16
opened: Option<OpenedProject>,
17
name: String,
18
male: String,
19
female: String,
20
presets: Vec<String>,
21
preset_input: String,
22
output: String,
23
width: String,
24
height: String,
25
fps: String,
26
browser: String,
27
male_import: Option<Result<ImportedModel, String>>,
28
female_import: Option<Result<ImportedModel, String>>,
29
notice: String,
30
notice_ok: bool,
17
session: Session,
18
dialogs: Box<dyn Dialogs>,
19
controls: PreviewControls,
20
preview_male: Option<PreviewCharacter>,
21
preview_female: Option<PreviewCharacter>,
22
viewport: Viewport,
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,
31
32
}
32
33
33
34
impl EditorApp {
34
pub fn new() -> Self {
35
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
35
36
Self {
36
root_input: String::new(),
37
open_input: String::new(),
38
opened: None,
39
name: "Nieuw project".to_string(),
40
male: String::new(),
41
female: String::new(),
42
presets: Vec::new(),
43
preset_input: String::new(),
44
output: "renders".to_string(),
45
width: "128".to_string(),
46
height: "128".to_string(),
47
fps: "12".to_string(),
48
browser: String::new(),
49
male_import: None,
50
female_import: None,
51
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
52
notice_ok: true,
37
session: Session::new(),
38
dialogs,
39
controls: PreviewControls::default(),
40
preview_male: None,
41
preview_female: None,
42
viewport: Viewport::new(),
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,
53
52
}
54
53
}
55
54
56
fn load(&mut self, opened: OpenedProject, notice: &str) {
57
self.name = opened.document.name.clone();
58
self.male = opened.document.male_model.clone().unwrap_or_default();
59
self.female = opened.document.female_model.clone().unwrap_or_default();
60
self.presets = opened.document.equipment_presets.clone();
61
self.output = opened.document.output_directory.clone();
62
self.width = opened.document.render.target_width.to_string();
63
self.height = opened.document.render.target_height.to_string();
64
self.fps = opened.document.render.fps.to_string();
65
self.browser.clear();
66
self.male_import = None;
67
self.female_import = None;
68
self.opened = Some(opened.clone());
69
let male = self.male.clone();
70
let female = self.female.clone();
71
if !male.trim().is_empty() {
72
self.inspect(true);
73
}
74
if !female.trim().is_empty() {
75
self.inspect(false);
76
}
77
if male.trim().is_empty() && female.trim().is_empty() {
78
if opened.disk_issues.is_empty() {
79
self.ok(notice);
80
} else {
81
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());
82
77
}
83
78
}
84
79
}
85
80
86
fn ok(&mut self, message: &str) {
87
self.notice = message.to_string();
88
self.notice_ok = true;
89
}
90
91
fn fail(&mut self, error: ProjectError) {
92
self.notice = error.to_string();
93
self.notice_ok = false;
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());
92
return;
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");
94
99
}
95
100
96
fn fail_text(&mut self, message: impl Into<String>) {
97
self.notice = message.into();
98
self.notice_ok = false;
101
fn reset_render(&mut self) {
102
self.render_rx = None;
103
self.render_ui = RenderUiState::idle();
99
104
}
100
105
101
fn draft(&self) -> Result<ProjectDraft, String> {
102
let opened = self
103
.opened
104
.as_ref()
105
.ok_or_else(|| "open of maak eerst een project".to_string())?;
106
Ok(ProjectDraft {
107
id: opened.document.id.clone(),
108
name: self.name.clone(),
109
male_model: self.male.clone(),
110
female_model: self.female.clone(),
111
equipment_presets: self.presets.clone(),
112
output_directory: self.output.clone(),
113
target_width: parse_u32("breedte", &self.width)?,
114
target_height: parse_u32("hoogte", &self.height)?,
115
fps: parse_u32("fps", &self.fps)?,
116
})
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
}
117
112
}
118
113
119
fn create(&mut self) {
120
let root = PathBuf::from(self.root_input.trim());
121
if self.root_input.trim().is_empty() {
122
self.fail_text("vul de projectmap in");
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 {
123
119
return;
124
}
125
match create_project(&root, "Nieuw project") {
126
Ok(opened) => self.load(
127
opened,
128
"Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
129
),
130
Err(error) => self.fail(error),
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
}
131
144
}
132
145
}
133
146
134
fn open(&mut self) {
135
let path = PathBuf::from(self.open_input.trim());
136
if self.open_input.trim().is_empty() {
137
self.fail_text("vul het pad naar een .ppd-bestand in");
138
return;
139
}
140
match open_project(&path) {
141
Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
142
Err(error) => self.fail(error),
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(),
143
151
}
144
152
}
145
153
146
fn save(&mut self) {
147
let Some(current) = self.opened.clone() else {
148
self.fail_text("open of maak eerst een project");
149
return;
150
};
151
let draft = match self.draft() {
152
Ok(draft) => draft,
153
Err(message) => {
154
self.fail_text(message);
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 {
155
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),
156
182
}
183
}
184
}
185
186
fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
187
let Some(root) = self.session.project_root() else {
188
return;
157
189
};
158
let document = match assemble(draft) {
159
Ok(document) => document,
160
Err(error) => {
161
self.fail(error);
162
return;
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
}
163
202
}
203
Err(error) => self.session.fail(error),
204
}
205
}
206
207
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208
let Some(root) = self.session.project_root() else {
209
return;
164
210
};
165
let candidate = OpenedProject {
166
manifest_path: current.manifest_path,
167
document,
168
disk_issues: Vec::new(),
169
};
170
match save_project(&candidate) {
171
Ok(()) => {
172
let path = candidate.manifest_path.display().to_string();
173
self.opened = Some(candidate);
174
self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
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;
175
216
}
176
Err(error) => self.fail(error),
217
Err(error) => self.session.fail(error),
177
218
}
178
219
}
179
220
180
fn add_preset(&mut self, relative: String) {
181
if relative.trim().is_empty() {
182
return;
221
fn begin(&mut self, intent: Intent) {
222
if self.session.gate(intent) {
223
self.run_intent(intent);
183
224
}
184
self.presets.push(relative);
185
if let Err(error) = self
186
.draft()
187
.and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
188
{
189
self.presets.pop();
190
self.fail_text(error);
191
} else {
192
self.preset_input.clear();
193
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),
194
232
}
195
233
}
196
234
197
fn assign_file(&mut self, relative: &str) {
198
if relative == MANIFEST_NAME {
199
self.fail_text("character.ppd is het projectbestand, geen model of preset");
200
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),
201
243
}
202
let lower = relative.to_ascii_lowercase();
203
if lower.contains("/preset") || lower.starts_with("presets/") {
204
self.add_preset(relative.to_string());
205
} else if self.male.trim().is_empty() {
206
self.male = relative.to_string();
207
self.inspect(true);
208
} else if self.female.trim().is_empty() {
209
self.female = relative.to_string();
210
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);
211
250
} else {
212
self.add_preset(relative.to_string());
251
self.run_intent(intent);
213
252
}
214
253
}
215
254
216
fn inspect(&mut self, male: bool) {
217
let Some(root) = self.opened.as_ref().and_then(|opened| {
218
opened
219
.manifest_path
220
.parent()
221
.map(std::path::Path::to_path_buf)
222
}) else {
223
self.fail_text("open of maak eerst een project");
255
fn save(&mut self) {
256
if let Err(error) = self.session.save() {
257
self.session.fail(error.to_string());
258
}
259
}
260
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");
224
278
return;
225
279
};
226
let relative = if male {
227
self.male.trim()
228
} else {
229
self.female.trim()
280
let Some(root) = self.session.project_root() else {
281
return;
230
282
};
231
if relative.is_empty() {
232
if male {
233
self.male_import = None;
234
} else {
235
self.female_import = None;
236
}
237
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());
238
290
return;
239
291
}
240
match import_project_model(&root, relative) {
241
Ok(model) => {
242
let message = format!(
243
"{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
244
model.meshes.len(),
245
model.joints.len(),
246
model.clips.len()
247
);
248
if male {
249
self.male_import = Some(Ok(model));
250
} else {
251
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;
252
329
}
253
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;
254
335
}
255
Err(error) => {
256
let text = error.to_string();
257
if male {
258
self.male_import = Some(Err(text.clone()));
259
} else {
260
self.female_import = Some(Err(text.clone()));
261
}
262
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;
263
343
}
264
344
}
265
345
}
266
346
}
267
347
348
impl Default for EditorApp {
349
fn default() -> Self {
350
Self::new(Box::new(SystemDialogs::new()))
351
}
352
}
353
268
354
impl eframe::App for EditorApp {
269
355
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
270
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
271
ui.horizontal_wrapped(|ui| {
272
if ui.button("Nieuw").clicked() {
273
self.create();
274
}
275
if ui.button("Open").clicked() {
276
self.open();
277
}
278
if ui.button("Opslaan").clicked() {
279
self.save();
280
}
281
if ui.button("Sluiten").clicked() {
282
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
283
}
284
ui.label(format!("format_version {FORMAT_VERSION}"));
285
});
286
});
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
}
287
386
egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
288
let color = if self.notice_ok {
289
egui::Color32::from_rgb(20, 90, 40)
387
let color = if self.session.notice_ok {
388
egui::Color32::LIGHT_GREEN
290
389
} else {
291
egui::Color32::from_rgb(150, 30, 30)
390
egui::Color32::LIGHT_RED
292
391
};
293
ui.colored_label(color, &self.notice);
392
ui.colored_label(color, &self.session.notice);
294
393
});
295
egui::SidePanel::right("browser")
296
.min_width(240.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)
297
418
.show(ctx, |ui| {
298
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
});
299
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) {
300
438
egui::CentralPanel::default().show(ctx, |ui| {
301
egui::ScrollArea::vertical().show(ui, |ui| {
302
self.show_form(ui);
303
});
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
}
304
451
});
305
452
}
306
}
307
453
308
impl EditorApp {
309
fn show_form(&mut self, ui: &mut egui::Ui) {
310
ui.heading("Paperdoll Renderer");
311
ui.label(
312
"Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",
313
);
314
ui.add_space(8.0);
315
ui.horizontal(|ui| {
316
ui.label("Projectmap");
317
ui.add(
318
egui::TextEdit::singleline(&mut self.root_input)
319
.desired_width(360.0)
320
.hint_text("/pad/naar/nieuwe-map"),
321
);
322
});
323
ui.horizontal(|ui| {
324
ui.label(".ppd openen");
325
ui.add(
326
egui::TextEdit::singleline(&mut self.open_input)
327
.desired_width(360.0)
328
.hint_text("/pad/naar/character.ppd"),
329
);
330
});
331
ui.separator();
332
ui.horizontal(|ui| {
333
ui.label("Naam");
334
ui.text_edit_singleline(&mut self.name);
335
});
336
ui.horizontal(|ui| {
337
ui.label("Man");
338
let male_edit = ui.add(
339
egui::TextEdit::singleline(&mut self.male)
340
.desired_width(360.0)
341
.hint_text("assets/body/male.glb"),
342
);
343
if male_edit.changed() {
344
self.male_import = None;
345
}
346
if ui.button("Wis man").clicked() {
347
self.male.clear();
348
self.male_import = None;
349
}
350
if ui.button("Lees man").clicked() {
351
self.inspect(true);
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();
352
459
}
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
});
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();
353
479
});
354
show_import(ui, "Import man", self.male_import.as_ref());
480
}
481
482
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
355
483
ui.horizontal(|ui| {
356
ui.label("Vrouw");
357
let female_edit = ui.add(
358
egui::TextEdit::singleline(&mut self.female)
359
.desired_width(360.0)
360
.hint_text("assets/body/female.glb"),
361
);
362
if female_edit.changed() {
363
self.female_import = None;
364
}
365
if ui.button("Wis vrouw").clicked() {
366
self.female.clear();
367
self.female_import = None;
368
}
369
if ui.button("Lees vrouw").clicked() {
370
self.inspect(false);
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));
493
}
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);
371
500
}
372
501
});
373
show_import(ui, "Import vrouw", self.female_import.as_ref());
374
ui.label("Equipment-presets, relatief aan de projectmap");
375
let mut remove = None;
376
for (index, preset) in self.presets.iter().enumerate() {
377
ui.horizontal(|ui| {
378
ui.label(preset);
379
if ui.button("Verwijder").clicked() {
380
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);
381
531
}
382
532
});
383
533
}
384
if let Some(index) = remove {
385
self.presets.remove(index);
386
}
387
ui.horizontal(|ui| {
388
ui.add(
389
egui::TextEdit::singleline(&mut self.preset_input)
390
.desired_width(280.0)
391
.hint_text("presets/zwaard.bin"),
392
);
393
if ui.button("Voeg preset toe").clicked() {
394
let value = self.preset_input.trim().to_string();
395
self.add_preset(value);
396
}
397
});
398
ui.horizontal(|ui| {
399
ui.label("Uitvoer");
400
ui.text_edit_singleline(&mut self.output);
401
});
402
ui.horizontal(|ui| {
403
ui.label("Breedte");
404
ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
405
ui.label("Hoogte");
406
ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
407
ui.label("fps");
408
ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
409
});
410
ui.add_space(8.0);
411
ui.label(
412
"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.",
413
);
414
534
}
415
535
416
fn show_browser(&mut self, ui: &mut egui::Ui) {
417
ui.heading("Bestanden");
418
let Some(opened) = self.opened.clone() else {
419
ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
420
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,
421
541
};
422
let root = opened
423
.manifest_path
424
.parent()
425
.map(PathBuf::from)
426
.unwrap_or_default();
427
ui.label(if self.browser.is_empty() {
428
"projectmap".to_string()
429
} else {
430
self.browser.clone()
431
});
432
if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
433
self.browser = parent_relative(&self.browser);
434
}
435
if ui.button("Deze map als uitvoer").clicked() {
436
if self.browser.is_empty() {
437
self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
438
} else {
439
self.output = self.browser.clone();
440
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();
441
549
}
442
}
443
let entries = match list_dir(&root, &self.browser) {
444
Ok(entries) => entries,
445
Err(error) => {
446
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
447
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
)));
448
576
}
449
};
450
let mut enter = None;
451
let mut choose = None;
452
for entry in &entries {
453
let label = if entry.is_dir {
454
format!("{}/", entry.name)
577
if let Some(image) = &self.render_ui.preview {
578
self.paint_pass(ui, image);
455
579
} else {
456
entry.name.clone()
457
};
458
if ui.button(label).clicked() {
459
if entry.is_dir {
460
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);
461
586
} else {
462
choose = Some(entry.relative.clone());
587
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
463
588
}
464
589
}
465
}
466
if let Some(relative) = enter {
467
self.browser = relative;
468
}
469
if let Some(relative) = choose {
470
self.assign_file(&relative);
471
}
590
});
472
591
}
473
}
474
592
475
fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
476
let Some(report) = report else {
477
return;
478
};
479
ui.group(|ui| {
480
ui.strong(title);
481
match report {
482
Ok(model) => {
483
let format = match model.format {
484
ModelFormat::Gltf => "glTF/GLB",
485
ModelFormat::Obj => "OBJ",
486
ModelFormat::Fbx => "FBX",
487
};
488
ui.label(format);
489
for mesh in &model.meshes {
490
ui.label(format!(
491
"mesh {}: {} vertices, {} driehoeken{}",
492
mesh.name,
493
mesh.vertex_count,
494
mesh.triangle_count,
495
if mesh.has_skin { ", skin" } else { "" }
496
));
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",
497
624
}
498
for material in &model.materials {
499
ui.label(format!(
500
"materiaal {}{}",
501
material.name,
502
material
503
.base_color_texture
504
.as_ref()
505
.map(|name| format!(", texture {name}"))
506
.unwrap_or_default()
507
));
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);
508
637
}
509
for texture in &model.textures {
510
ui.label(format!(
511
"texture {} ({}x{})",
512
texture.source, texture.width, texture.height
513
));
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();
514
692
}
515
if model.joints.is_empty() {
516
ui.label("geen skeleton");
517
} else {
518
ui.label(format!("{} joints", model.joints.len()));
519
for joint in model.joints.iter().take(8) {
520
match &joint.parent {
521
Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
522
None => ui.label(format!("{} (wortel)", joint.name)),
523
};
524
}
693
if ui.button("Opslaan").clicked() {
694
self.save();
525
695
}
526
if model.clips.is_empty() {
527
ui.label("geen animation clips");
528
} else {
529
for clip in &model.clips {
530
ui.label(format!(
531
"clip {} ({:.2} s, {} kanalen)",
532
clip.name, clip.duration_seconds, clip.channel_count
533
));
534
}
696
if ui
697
.add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
698
.clicked()
699
{
700
self.begin(Intent::PickNew);
535
701
}
536
for notice in &model.notices {
537
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);
538
707
}
539
}
540
Err(message) => {
541
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
542
}
543
}
544
});
708
});
709
});
710
}
545
711
}
546
712
547
fn parent_relative(relative: &str) -> String {
548
match relative.rfind('/') {
549
Some(index) => relative[..index].to_string(),
550
None => String::new(),
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
))
551
727
}
552
}
553
728
554
fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
555
let text = text.trim();
556
if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
557
return Err(format!("{label} moet een geheel getal zijn"));
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);
558
753
}
559
text.parse::<u32>()
560
.map_err(|_| format!("{label} is te groot"))
561
}
562
754
563
impl Default for EditorApp {
564
fn default() -> Self {
565
Self::new()
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);
770
}
771
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());
566
797
}
567
798
}
@@ -1,567 +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};8
use crate::project::{9
use crate::import::{import_project_model, ImportedModel};9
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,10
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};10
ProjectError, FORMAT_VERSION, MANIFEST_NAME,11
use crate::preview_gl::Viewport;11
};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};12
15
13
pub struct EditorApp {16
pub struct EditorApp {14
root_input: String,17
session: Session,15
open_input: String,18
dialogs: Box<dyn Dialogs>,16
opened: Option<OpenedProject>,19
controls: PreviewControls,17
name: String,20
preview_male: Option<PreviewCharacter>,18
male: String,21
preview_female: Option<PreviewCharacter>,19
female: String,22
viewport: Viewport,20
presets: Vec<String>,23
inventories: [Option<Result<ImportedModel, String>>; 2],21
preset_input: String,24
render_rx: Option<Receiver<RenderEvent>>,22
output: String,25
render_ui: RenderUiState,23
width: String,26
applied_shell: Option<[f32; 2]>,24
height: String,27
width_text: String,25
fps: String,28
height_text: String,26
browser: String,29
fps_text: String,27
male_import: Option<Result<ImportedModel, String>>,30
allow_close: bool,28
female_import: Option<Result<ImportedModel, String>>,31
dialog_pending: bool,29
notice: String,30
notice_ok: bool,31
}32
}32
33
33
impl EditorApp {34
impl EditorApp {34
pub fn new() -> Self {35
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {35
Self {36
Self {36
root_input: String::new(),37
session: Session::new(),37
open_input: String::new(),38
dialogs,38
opened: None,39
controls: PreviewControls::default(),39
name: "Nieuw project".to_string(),40
preview_male: None,40
male: String::new(),41
preview_female: None,41
female: String::new(),42
viewport: Viewport::new(),42
presets: Vec::new(),43
inventories: [None, None],43
preset_input: String::new(),44
render_rx: None,44
output: "renders".to_string(),45
render_ui: RenderUiState::idle(),45
width: "128".to_string(),46
applied_shell: None,46
height: "128".to_string(),47
width_text: "128".to_string(),47
fps: "12".to_string(),48
height_text: "128".to_string(),48
browser: String::new(),49
fps_text: "12".to_string(),49
male_import: None,50
allow_close: false,50
female_import: None,51
dialog_pending: false,51
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),52
notice_ok: true,53
}52
}54
}53
}55
54
56
fn load(&mut self, opened: OpenedProject, notice: &str) {55
pub fn open_path(&mut self, path: PathBuf) {57
self.name = opened.document.name.clone();56
let candidate = std::env::current_dir()58
self.male = opened.document.male_model.clone().unwrap_or_default();57
.map_err(|e| e.to_string())59
self.female = opened.document.female_model.clone().unwrap_or_default();58
.and_then(|base| {60
self.presets = opened.document.equipment_presets.clone();59
crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())61
self.output = opened.document.output_directory.clone();60
});62
self.width = opened.document.render.target_width.to_string();61
let path = match candidate {63
self.height = opened.document.render.target_height.to_string();62
Ok(path) => path,64
self.fps = opened.document.render.fps.to_string();63
Err(error) => {65
self.browser.clear();64
self.session.fail(error);66
self.male_import = None;65
return;67
self.female_import = None;66
}68
self.opened = Some(opened.clone());67
};69
let male = self.male.clone();68
match open_project(&path) {70
let female = self.female.clone();69
Ok(opened) => {71
if !male.trim().is_empty() {70
self.session.install_loaded(opened);72
self.inspect(true);71
self.reset_render();73
}72
self.sync_fields();74
if !female.trim().is_empty() {73
self.reload_bodies();75
self.inspect(false);74
}76
}75
Err(error) => {77
if male.trim().is_empty() && female.trim().is_empty() {76
self.session.fail(error.to_string());78
if opened.disk_issues.is_empty() {79
self.ok(notice);80
} else {81
self.fail_text(opened.disk_issues.join("\n"));82
}77
}83
}78
}84
}79
}85
80
86
fn ok(&mut self, message: &str) {81
pub fn open_fixture_project(&mut self) {87
self.notice = message.to_string();82
let root = std::env::temp_dir().join(format!(88
self.notice_ok = true;83
"paperdoll-renderer-fixture-{}-{}",89
}84
std::process::id(),90
85
std::time::SystemTime::now()91
fn fail(&mut self, error: ProjectError) {86
.duration_since(std::time::UNIX_EPOCH)92
self.notice = error.to_string();87
.unwrap_or_default()93
self.notice_ok = false;88
.as_nanos()89
));90
if let Err(error) = self.session.install_new_project(&root) {91
self.session.fail(error.to_string());92
return;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");94
}99
}95
100
96
fn fail_text(&mut self, message: impl Into<String>) {101
fn reset_render(&mut self) {97
self.notice = message.into();102
self.render_rx = None;98
self.notice_ok = false;103
self.render_ui = RenderUiState::idle();99
}104
}100
105
101
fn draft(&self) -> Result<ProjectDraft, String> {106
fn sync_fields(&mut self) {102
let opened = self107
if let Some(opened) = &self.session.opened {103
.opened108
self.width_text = opened.document.render.target_width.to_string();104
.as_ref()109
self.height_text = opened.document.render.target_height.to_string();105
.ok_or_else(|| "open of maak eerst een project".to_string())?;110
self.fps_text = opened.document.render.fps.to_string();106
Ok(ProjectDraft {111
}107
id: opened.document.id.clone(),108
name: self.name.clone(),109
male_model: self.male.clone(),110
female_model: self.female.clone(),111
equipment_presets: self.presets.clone(),112
output_directory: self.output.clone(),113
target_width: parse_u32("breedte", &self.width)?,114
target_height: parse_u32("hoogte", &self.height)?,115
fps: parse_u32("fps", &self.fps)?,116
})117
}112
}118
113
119
fn create(&mut self) {114
fn reload_bodies(&mut self) {120
let root = PathBuf::from(self.root_input.trim());115
self.preview_male = None;121
if self.root_input.trim().is_empty() {116
self.preview_female = None;122
self.fail_text("vul de projectmap in");117
self.inventories = [None, None];118
let Some(root) = self.session.project_root() else {123
return;119
return;124
}120
};125
match create_project(&root, "Nieuw project") {121
for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {126
Ok(opened) => self.load(122
if let Some(relative) = self.session.body_path(sex) {127
opened,123
let imported =128
"Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",124
import_project_model(&root, relative).map_err(|error| error.to_string());129
),125
// Validate the source and its dependencies before the viewport reads it.130
Err(error) => self.fail(error),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
}131
}144
}132
}145
}133
146
134
fn open(&mut self) {147
fn character(&self) -> Option<&PreviewCharacter> {135
let path = PathBuf::from(self.open_input.trim());148
match self.controls.sex {136
if self.open_input.trim().is_empty() {149
PreviewSex::Male => self.preview_male.as_ref(),137
self.fail_text("vul het pad naar een .ppd-bestand in");150
PreviewSex::Female => self.preview_female.as_ref(),138
return;139
}140
match open_project(&path) {141
Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),142
Err(error) => self.fail(error),143
}151
}144
}152
}145
153
146
fn save(&mut self) {154
fn poll_dialogs(&mut self) {147
let Some(current) = self.opened.clone() else {155
let poll = self.dialogs.poll();148
self.fail_text("open of maak eerst een project");156
self.dialog_pending = matches!(poll, DialogPoll::Pending);149
return;157
if let DialogPoll::Ready { purpose, path } = poll {150
};158
let Some(path) = path else {151
let draft = match self.draft() {152
Ok(draft) => draft,153
Err(message) => {154
self.fail_text(message);155
return;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),156
}182
}183
}184
}185
186
fn take_model(&mut self, sex: Sex, path: &std::path::Path) {187
let Some(root) = self.session.project_root() else {188
return;157
};189
};158
let document = match assemble(draft) {190
match adopt_file(&root, path, "assets/body") {159
Ok(document) => document,191
Ok(relative) => {160
Err(error) => {192
self.session.set_body_path(sex, relative.clone());161
self.fail(error);193
self.reload_bodies();162
return;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
}163
}202
}203
Err(error) => self.session.fail(error),204
}205
}206
207
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {208
let Some(root) = self.session.project_root() else {209
return;164
};210
};165
let candidate = OpenedProject {211
match adopt_file(&root, path, "assets/equipment") {166
manifest_path: current.manifest_path,212
Ok(relative) => {167
document,213
self.session.set_equipment_model(index, relative.clone());168
disk_issues: Vec::new(),214
self.session.notice = format!("{relative} hoort bij deze equipment.");169
};215
self.session.notice_ok = true;170
match save_project(&candidate) {171
Ok(()) => {172
let path = candidate.manifest_path.display().to_string();173
self.opened = Some(candidate);174
self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));175
}216
}176
Err(error) => self.fail(error),217
Err(error) => self.session.fail(error),177
}218
}178
}219
}179
220
180
fn add_preset(&mut self, relative: String) {221
fn begin(&mut self, intent: Intent) {181
if relative.trim().is_empty() {222
if self.session.gate(intent) {182
return;223
self.run_intent(intent);183
}224
}184
self.presets.push(relative);225
}185
if let Err(error) = self226
186
.draft()227
fn run_intent(&mut self, intent: Intent) {187
.and_then(|draft| assemble(draft).map_err(|error| error.to_string()))228
match intent {188
{229
Intent::Quit => {}189
self.presets.pop();230
Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),190
self.fail_text(error);231
Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),191
} else {192
self.preset_input.clear();193
self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");194
}232
}195
}233
}196
234
197
fn assign_file(&mut self, relative: &str) {235
fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {198
if relative == MANIFEST_NAME {236
match self.session.resolve(choice) {199
self.fail_text("character.ppd is het projectbestand, geen model of preset");237
AfterChoice::Stay => {}200
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),201
}243
}202
let lower = relative.to_ascii_lowercase();244
}203
if lower.contains("/preset") || lower.starts_with("presets/") {245
204
self.add_preset(relative.to_string());246
fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {205
} else if self.male.trim().is_empty() {247
if intent == Intent::Quit {206
self.male = relative.to_string();248
self.allow_close = true;207
self.inspect(true);249
ctx.send_viewport_cmd(egui::ViewportCommand::Close);208
} else if self.female.trim().is_empty() {209
self.female = relative.to_string();210
self.inspect(false);211
} else {250
} else {212
self.add_preset(relative.to_string());251
self.run_intent(intent);213
}252
}214
}253
}215
254
216
fn inspect(&mut self, male: bool) {255
fn save(&mut self) {217
let Some(root) = self.opened.as_ref().and_then(|opened| {256
if let Err(error) = self.session.save() {218
opened257
self.session.fail(error.to_string());219
.manifest_path258
}220
.parent()259
}221
.map(std::path::Path::to_path_buf)260
222
}) else {261
fn apply_render_fields(&mut self) {223
self.fail_text("open of maak eerst een project");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.session277
.fail("dit lichaam heeft geen glTF dat de preview kan tekenen");224
return;278
return;225
};279
};226
let relative = if male {280
let Some(root) = self.session.project_root() else {227
self.male.trim()281
return;228
} else {229
self.female.trim()230
};282
};231
if relative.is_empty() {283
let Some(opened) = &self.session.opened else {232
if male {284
return;233
self.male_import = None;285
};234
} else {286
if let Err(error) =235
self.female_import = None;287
crate::project::prepare_output_dir(&root, &opened.document.output_directory)236
}288
{237
self.fail_text("dit slot heeft nog geen modelpad");289
self.session.fail(error.to_string());238
return;290
return;239
}291
}240
match import_project_model(&root, relative) {292
let sex = match self.controls.sex {241
Ok(model) => {293
PreviewSex::Male => "male",242
let message = format!(294
PreviewSex::Female => "female",243
"{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",295
};244
model.meshes.len(),296
let output = root245
model.joints.len(),297
.join(&opened.document.output_directory)246
model.clips.len()298
.join(format!("body-{sex}-still.png"));247
);299
let job = RenderJob {248
if male {300
character,249
self.male_import = Some(Ok(model));301
controls: self.controls.clone(),250
} else {302
target_width: opened.document.render.target_width.max(1),251
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;252
}329
}253
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;254
}335
}255
Err(error) => {336
if let RenderEvent::Failed(message) = &event {256
let text = error.to_string();337
self.session.fail(message.clone());257
if male {338
}258
self.male_import = Some(Err(text.clone()));339
self.render_ui.apply(event);259
} else {340
if finished {260
self.female_import = Some(Err(text.clone()));341
self.render_rx = None;261
}342
break;262
self.fail_text(text);263
}343
}264
}344
}265
}345
}266
}346
}267
347
348
impl Default for EditorApp {349
fn default() -> Self {350
Self::new(Box::new(SystemDialogs::new()))351
}352
}353
268
impl eframe::App for EditorApp {354
impl eframe::App for EditorApp {269
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {355
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {270
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {356
let shell = self.session.shell_size();271
ui.horizontal_wrapped(|ui| {357
if self.applied_shell != Some(shell) {272
if ui.button("Nieuw").clicked() {358
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(273
self.create();359
shell[0], shell[1],274
}360
)));275
if ui.button("Open").clicked() {361
let min = if shell == crate::session::START_WINDOW {276
self.open();362
egui::vec2(420.0, 220.0)277
}363
} else {278
if ui.button("Opslaan").clicked() {364
egui::vec2(900.0, 560.0)279
self.save();365
};280
}366
ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));281
if ui.button("Sluiten").clicked() {367
self.applied_shell = Some(shell);282
ctx.send_viewport_cmd(egui::ViewportCommand::Close);368
}283
}369
if ctx.input(|input| input.viewport().close_requested())284
ui.label(format!("format_version {FORMAT_VERSION}"));370
&& !self.allow_close285
});371
&& self.session.confirm.is_none()286
});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
}287
egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {386
egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {288
let color = if self.notice_ok {387
let color = if self.session.notice_ok {289
egui::Color32::from_rgb(20, 90, 40)388
egui::Color32::LIGHT_GREEN290
} else {389
} else {291
egui::Color32::from_rgb(150, 30, 30)390
egui::Color32::LIGHT_RED292
};391
};293
ui.colored_label(color, &self.notice);392
ui.colored_label(color, &self.session.notice);294
});393
});295
egui::SidePanel::right("browser")394
match self.session.screen {296
.min_width(240.0)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)297
.show(ctx, |ui| {418
.show(ctx, |ui| {298
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
});299
});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) {300
egui::CentralPanel::default().show(ctx, |ui| {438
egui::CentralPanel::default().show(ctx, |ui| {301
egui::ScrollArea::vertical().show(ui, |ui| {439
if self.session.confirm.is_some() || self.dialog_pending {302
self.show_form(ui);440
ui.disable();303
});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
}304
});451
});305
}452
}306
}307
453
308
impl EditorApp {454
fn show_bodies(&mut self, ctx: &egui::Context) {309
fn show_form(&mut self, ui: &mut egui::Ui) {455
self.toolbar(ctx, true);310
ui.heading("Paperdoll Renderer");456
egui::CentralPanel::default().show(ctx, |ui| {311
ui.label(457
if self.session.confirm.is_some() || self.dialog_pending {312
"Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",458
ui.disable();313
);314
ui.add_space(8.0);315
ui.horizontal(|ui| {316
ui.label("Projectmap");317
ui.add(318
egui::TextEdit::singleline(&mut self.root_input)319
.desired_width(360.0)320
.hint_text("/pad/naar/nieuwe-map"),321
);322
});323
ui.horizontal(|ui| {324
ui.label(".ppd openen");325
ui.add(326
egui::TextEdit::singleline(&mut self.open_input)327
.desired_width(360.0)328
.hint_text("/pad/naar/character.ppd"),329
);330
});331
ui.separator();332
ui.horizontal(|ui| {333
ui.label("Naam");334
ui.text_edit_singleline(&mut self.name);335
});336
ui.horizontal(|ui| {337
ui.label("Man");338
let male_edit = ui.add(339
egui::TextEdit::singleline(&mut self.male)340
.desired_width(360.0)341
.hint_text("assets/body/male.glb"),342
);343
if male_edit.changed() {344
self.male_import = None;345
}346
if ui.button("Wis man").clicked() {347
self.male.clear();348
self.male_import = None;349
}350
if ui.button("Lees man").clicked() {351
self.inspect(true);352
}459
}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
});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();353
});479
});354
show_import(ui, "Import man", self.male_import.as_ref());480
}481
482
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {355
ui.horizontal(|ui| {483
ui.horizontal(|ui| {356
ui.label("Vrouw");484
ui.label(title);357
let female_edit = ui.add(485
let path = self358
egui::TextEdit::singleline(&mut self.female)486
.session359
.desired_width(360.0)487
.body_path(sex)360
.hint_text("assets/body/female.glb"),488
.unwrap_or("geen model")361
);489
.to_string();362
if female_edit.changed() {490
ui.label(path);363
self.female_import = None;491
if ui.button(pick).clicked() {364
}492
self.dialogs.start(DialogPurpose::Body(sex));365
if ui.button("Wis vrouw").clicked() {493
}366
self.female.clear();494
if ui.button(open).clicked() {367
self.female_import = None;495
self.controls.sex = match sex {368
}496
Sex::Male => PreviewSex::Male,369
if ui.button("Lees vrouw").clicked() {497
Sex::Female => PreviewSex::Female,370
self.inspect(false);498
};499
self.session.show_body(sex);371
}500
}372
});501
});373
show_import(ui, "Import vrouw", self.female_import.as_ref());502
let index = if sex == Sex::Male { 0 } else { 1 };374
ui.label("Equipment-presets, relatief aan de projectmap");503
if let Some(inventory) = &self.inventories[index] {375
let mut remove = None;504
ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {376
for (index, preset) in self.presets.iter().enumerate() {505
Ok(model) => {377
ui.horizontal(|ui| {506
for mesh in &model.meshes {378
ui.label(preset);507
ui.label(format!(379
if ui.button("Verwijder").clicked() {508
"{}: {} vertices, {} driehoeken",380
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);381
}531
}382
});532
});383
}533
}384
if let Some(index) = remove {385
self.presets.remove(index);386
}387
ui.horizontal(|ui| {388
ui.add(389
egui::TextEdit::singleline(&mut self.preset_input)390
.desired_width(280.0)391
.hint_text("presets/zwaard.bin"),392
);393
if ui.button("Voeg preset toe").clicked() {394
let value = self.preset_input.trim().to_string();395
self.add_preset(value);396
}397
});398
ui.horizontal(|ui| {399
ui.label("Uitvoer");400
ui.text_edit_singleline(&mut self.output);401
});402
ui.horizontal(|ui| {403
ui.label("Breedte");404
ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));405
ui.label("Hoogte");406
ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));407
ui.label("fps");408
ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));409
});410
ui.add_space(8.0);411
ui.label(412
"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.",413
);414
}534
}415
535
416
fn show_browser(&mut self, ui: &mut egui::Ui) {536
fn show_body(&mut self, ctx: &egui::Context) {417
ui.heading("Bestanden");537
self.toolbar(ctx, true);418
let Some(opened) = self.opened.clone() else {538
let sex = match self.session.screen {419
ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");539
Screen::Body(sex) => sex,420
return;540
_ => Sex::Male,421
};541
};422
let root = opened542
self.controls.sex = match sex {423
.manifest_path543
Sex::Male => PreviewSex::Male,424
.parent()544
Sex::Female => PreviewSex::Female,425
.map(PathBuf::from)545
};426
.unwrap_or_default();546
egui::CentralPanel::default().show(ctx, |ui| {427
ui.label(if self.browser.is_empty() {547
if self.session.confirm.is_some() || self.dialog_pending {428
"projectmap".to_string()548
ui.disable();429
} else {430
self.browser.clone()431
});432
if !self.browser.is_empty() && ui.button("Omhoog").clicked() {433
self.browser = parent_relative(&self.browser);434
}435
if ui.button("Deze map als uitvoer").clicked() {436
if self.browser.is_empty() {437
self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");438
} else {439
self.output = self.browser.clone();440
self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");441
}549
}442
}550
ui.horizontal_wrapped(|ui| {443
let entries = match list_dir(&root, &self.browser) {551
if ui.button("Terug").clicked() {444
Ok(entries) => entries,552
self.session.show_bodies();445
Err(error) => {553
}446
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());554
if ui.button("Equipment").clicked() {447
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
)));448
}576
}449
};577
if let Some(image) = &self.render_ui.preview {450
let mut enter = None;578
self.paint_pass(ui, image);451
let mut choose = None;452
for entry in &entries {453
let label = if entry.is_dir {454
format!("{}/", entry.name)455
} else {579
} else {456
entry.name.clone()580
let character = match self.controls.sex {457
};581
PreviewSex::Male => self.preview_male.as_ref(),458
if ui.button(label).clicked() {582
PreviewSex::Female => self.preview_female.as_ref(),459
if entry.is_dir {583
};460
enter = Some(entry.relative.clone());584
if character.is_some() {585
self.viewport.show(ui, &mut self.controls, character);461
} else {586
} else {462
choose = Some(entry.relative.clone());587
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");463
}588
}464
}589
}465
}590
});466
if let Some(relative) = enter {467
self.browser = relative;468
}469
if let Some(relative) = choose {470
self.assign_file(&relative);471
}472
}591
}473
}474
592
475
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) {476
let Some(report) = report else {594
let color = egui::ColorImage::from_rgba_unmultiplied(477
return;595
[image.width as usize, image.height as usize],478
};596
&image.rgba,479
ui.group(|ui| {597
);480
ui.strong(title);598
let texture = ui.ctx().load_texture(481
match report {599
"render-pass",482
Ok(model) => {600
color,483
let format = match model.format {601
egui::TextureOptions {484
ModelFormat::Gltf => "glTF/GLB",602
magnification: egui::TextureFilter::Nearest,485
ModelFormat::Obj => "OBJ",603
minification: egui::TextureFilter::Nearest,486
ModelFormat::Fbx => "FBX",604
wrap_mode: egui::TextureWrapMode::ClampToEdge,487
};605
..Default::default()488
ui.label(format);606
},489
for mesh in &model.meshes {607
);490
ui.label(format!(608
let available = ui.available_size();491
"mesh {}: {} vertices, {} driehoeken{}",609
ui.image((texture.id(), available));492
mesh.name,610
}493
mesh.vertex_count,611
494
mesh.triangle_count,612
fn show_equipment_list(&mut self, ctx: &egui::Context) {495
if mesh.has_skin { ", skin" } else { "" }613
self.toolbar(ctx, true);496
));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",497
}624
}498
for material in &model.materials {625
));499
ui.label(format!(626
if ui.button("Terug naar body").clicked() {500
"materiaal {}{}",627
self.session.back();501
material.name,628
}502
material629
let mut open = None;503
.base_color_texture630
for (index, item) in self.session.equipment.iter().enumerate() {504
.as_ref()631
let label = match &item.model {505
.map(|name| format!(", texture {name}"))632
Some(model) => format!("{} {model}", item.slug),506
.unwrap_or_default()633
None => item.slug.clone(),507
));634
};635
if ui.button(label).clicked() {636
open = Some(index);508
}637
}509
for texture in &model.textures {638
}510
ui.label(format!(639
if ui.button("Nieuw").clicked() {511
"texture {} ({}x{})",640
self.session.add_equipment();512
texture.source, texture.width, texture.height641
}513
));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();514
}692
}515
if model.joints.is_empty() {693
if ui.button("Opslaan").clicked() {516
ui.label("geen skeleton");694
self.save();517
} else {518
ui.label(format!("{} joints", model.joints.len()));519
for joint in model.joints.iter().take(8) {520
match &joint.parent {521
Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),522
None => ui.label(format!("{} (wortel)", joint.name)),523
};524
}525
}695
}526
if model.clips.is_empty() {696
if ui527
ui.label("geen animation clips");697
.add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))528
} else {698
.clicked()529
for clip in &model.clips {699
{530
ui.label(format!(700
self.begin(Intent::PickNew);531
"clip {} ({:.2} s, {} kanalen)",532
clip.name, clip.duration_seconds, clip.channel_count533
));534
}535
}701
}536
for notice in &model.notices {702
if ui537
ui.label(notice);703
.add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))704
.clicked()705
{706
self.begin(Intent::PickLoad);538
}707
}539
}708
});540
Err(message) => {709
});541
ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);710
}542
}543
}544
});545
}711
}546
712
547
fn parent_relative(relative: &str) -> String {713
#[cfg(test)]548
match relative.rfind('/') {714
mod tests {549
Some(index) => relative[..index].to_string(),715
use super::*;550
None => String::new(),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
))551
}727
}552
}553
728
554
fn parse_u32(label: &str, text: &str) -> Result<u32, String> {729
#[test]555
let text = text.trim();730
fn picker_creates_project_and_cancelled_switch_preserves_edits() {556
if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {731
let root = root();557
return Err(format!("{label} moet een geheel getal zijn"));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);558
}753
}559
text.parse::<u32>()560
.map_err(|_| format!("{label} is te groot"))561
}562
754
563
impl Default for EditorApp {755
#[test]564
fn default() -> Self {756
fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {565
Self::new()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);770
}771
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_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());566
}797
}567
}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,11 +24,14 @@ 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,
34
PreviewFixtures,
32
35
}
33
36
34
37
/// Rejected command line. A model path is rejected on purpose: accepting one
@@ -36,6 +39,7 @@ pub enum CliRequest {
36
39
#[derive(Debug, Clone, PartialEq, Eq)]
37
40
pub enum CliError {
38
41
UnknownArgument(String),
42
Usage(String),
39
43
}
40
44
41
45
const CAPABILITIES: [Capability; 8] = [
@@ -58,11 +62,11 @@ pub fn availability(capability: Capability) -> Availability {
58
62
Capability::ProjectShell
59
63
| Capability::ObjMtlTextures
60
64
| Capability::GltfGlb
61
| Capability::Fbx => Availability::Present,
62
Capability::SkeletalAnimation
63
| Capability::AttachmentEditor
64
| Capability::PaperdollRender
65
| Capability::Preview2d => Availability::NotBuilt,
65
| Capability::Fbx
66
| Capability::SkeletalAnimation => Availability::Present,
67
Capability::AttachmentEditor | Capability::PaperdollRender | Capability::Preview2d => {
68
Availability::NotBuilt
69
}
66
70
}
67
71
}
68
72
@@ -107,14 +111,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
107
111
}
108
112
109
113
pub fn dutch_summary() -> &'static str {
110
"Deze build leest glTF/GLB en FBX uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. Het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."
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."
111
115
}
112
116
113
117
/// Stable status text shared by `--status` and the window.
114
118
pub fn report() -> String {
115
119
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
116
120
lines.push("name: Paperdoll Renderer".to_string());
117
lines.push("phase: fbx-import".to_string());
121
lines.push("phase: project-editor".to_string());
118
122
for capability in CAPABILITIES {
119
123
lines.push(format!(
120
124
"{}: {}",
@@ -138,7 +142,7 @@ pub const LINUX_WINDOW_LIBRARIES: &[&str] = &[
138
142
139
143
pub fn missing_library_message(missing: &[&str]) -> String {
140
144
format!(
141
"het statusvenster kan niet openen; deze bibliotheken ontbreken: {}. Import en render zijn niet gebouwd.",
145
"het venster kan niet openen; deze bibliotheken ontbreken: {}.",
142
146
missing.join(", ")
143
147
)
144
148
}
@@ -158,35 +162,70 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
158
162
159
163
pub fn help_text() -> &'static str {
160
164
"\
161
Paperdoll Renderer — modelimport
165
Paperdoll Renderer
162
166
163
167
Gebruik:
164
paperdoll-renderer open het projectvenster
165
paperdoll-renderer --status print de status en sluit
166
paperdoll-renderer --help toon deze tekst
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
167
174
168
Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB, FBX en OBJ+MTL worden in het venster gelezen. Render is niet gebouwd."
175
--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd."
169
176
}
170
177
171
178
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
172
179
where
173
180
I: IntoIterator<Item = &'a str>,
174
181
{
175
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;
176
190
for arg in args {
177
191
match arg {
178
"--help" | "-h" => request = CliRequest::PrintHelp,
179
"--status" => {
180
if request != CliRequest::PrintHelp {
181
request = CliRequest::PrintStatus;
182
}
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()));
183
197
}
184
198
other => {
185
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());
186
203
}
187
204
}
188
205
}
189
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)
190
229
}
191
230
192
231
#[cfg(test)]
@@ -205,8 +244,11 @@ mod tests {
205
244
Availability::Present
206
245
);
207
246
assert_eq!(availability(Capability::Fbx), Availability::Present);
247
assert_eq!(
248
availability(Capability::SkeletalAnimation),
249
Availability::Present
250
);
208
251
for capability in [
209
Capability::SkeletalAnimation,
210
252
Capability::AttachmentEditor,
211
253
Capability::PaperdollRender,
212
254
Capability::Preview2d,
@@ -222,19 +264,20 @@ mod tests {
222
264
#[test]
223
265
fn status_report_matches_the_import_build() {
224
266
let report = report();
225
assert!(report.starts_with("name: Paperdoll Renderer\nphase: fbx-import\n"));
226
for key in ["project_shell", "obj_mtl_textures", "gltf_glb", "fbx"] {
267
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
268
for key in [
269
"project_shell",
270
"obj_mtl_textures",
271
"gltf_glb",
272
"fbx",
273
"skeletal_animation",
274
] {
227
275
assert!(
228
276
report.contains(&format!("{key}: present")),
229
277
"missing present {key} in {report}"
230
278
);
231
279
}
232
for key in [
233
"skeletal_animation",
234
"attachment_editor",
235
"paperdoll_render",
236
"preview_2d",
237
] {
280
for key in ["attachment_editor", "paperdoll_render", "preview_2d"] {
238
281
assert!(
239
282
report.contains(&format!("{key}: not_built")),
240
283
"missing {key} in {report}"
@@ -245,12 +288,9 @@ mod tests {
245
288
);
246
289
}
247
290
let summary = dutch_summary();
248
assert!(summary.contains("niet gebouwd"));
249
assert!(summary.contains("OBJ+MTL"));
291
assert!(summary.contains("3D-preview"));
250
292
assert!(summary.contains("glTF/GLB"));
251
assert!(summary.contains("FBX"));
252
let lower = summary.to_lowercase();
253
assert!(!lower.contains("render werkt"));
293
assert!(summary.contains("walk"));
254
294
}
255
295
256
296
#[test]
@@ -260,24 +300,34 @@ mod tests {
260
300
assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));
261
301
assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));
262
302
assert_eq!(
263
parse_cli(["tests/fixtures/bodies/test-body-male.glb"]),
264
Err(CliError::UnknownArgument(
265
"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()
266
312
))
267
313
);
268
314
assert_eq!(
269
315
parse_cli(["--status", "--render"]),
270
316
Err(CliError::UnknownArgument("--render".to_string()))
271
317
);
272
assert!(help_text().contains("niet gebouwd"));
318
assert!(help_text().contains("--preview-fixture"));
319
assert_eq!(
320
parse_cli(["--preview-fixture"]),
321
Ok(CliRequest::PreviewFixtures)
322
);
273
323
}
274
324
275
325
#[test]
276
326
fn missing_library_message_names_the_library_and_does_not_claim_import() {
277
327
let message = missing_library_message(&["libxkbcommon-x11.so.0"]);
278
328
assert!(message.contains("libxkbcommon-x11.so.0"));
279
assert!(message.contains("niet gebouwd"));
280
assert!(!message.to_lowercase().contains("import werkt"));
329
assert!(!message.to_lowercase().contains("import"));
330
assert!(!message.to_lowercase().contains("niet gebouwd"));
281
331
assert!(!LINUX_WINDOW_LIBRARIES.is_empty());
282
332
}
283
333
}
@@ -24,11 +24,14 @@ 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,34
PreviewFixtures,32
}35
}33
36
34
/// Rejected command line. A model path is rejected on purpose: accepting one37
/// Rejected command line. A model path is rejected on purpose: accepting one@@ -36,6 +39,7 @@ pub enum CliRequest {36
#[derive(Debug, Clone, PartialEq, Eq)]39
#[derive(Debug, Clone, PartialEq, Eq)]37
pub enum CliError {40
pub enum CliError {38
UnknownArgument(String),41
UnknownArgument(String),42
Usage(String),39
}43
}40
44
41
const CAPABILITIES: [Capability; 8] = [45
const CAPABILITIES: [Capability; 8] = [@@ -58,11 +62,11 @@ pub fn availability(capability: Capability) -> Availability {58
Capability::ProjectShell62
Capability::ProjectShell59
| Capability::ObjMtlTextures63
| Capability::ObjMtlTextures60
| Capability::GltfGlb64
| Capability::GltfGlb61
| Capability::Fbx => Availability::Present,65
| Capability::Fbx62
Capability::SkeletalAnimation66
| Capability::SkeletalAnimation => Availability::Present,63
| Capability::AttachmentEditor67
Capability::AttachmentEditor | Capability::PaperdollRender | Capability::Preview2d => {64
| Capability::PaperdollRender68
Availability::NotBuilt65
| Capability::Preview2d => Availability::NotBuilt,69
}66
}70
}67
}71
}68
72
@@ -107,14 +111,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {107
}111
}108
112
109
pub fn dutch_summary() -> &'static str {113
pub fn dutch_summary() -> &'static str {110
"Deze build leest glTF/GLB en FBX uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. Het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."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."111
}115
}112
116
113
/// Stable status text shared by `--status` and the window.117
/// Stable status text shared by `--status` and the window.114
pub fn report() -> String {118
pub fn report() -> String {115
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());119
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());116
lines.push("name: Paperdoll Renderer".to_string());120
lines.push("name: Paperdoll Renderer".to_string());117
lines.push("phase: fbx-import".to_string());121
lines.push("phase: project-editor".to_string());118
for capability in CAPABILITIES {122
for capability in CAPABILITIES {119
lines.push(format!(123
lines.push(format!(120
"{}: {}",124
"{}: {}",@@ -138,7 +142,7 @@ pub const LINUX_WINDOW_LIBRARIES: &[&str] = &[138
142
139
pub fn missing_library_message(missing: &[&str]) -> String {143
pub fn missing_library_message(missing: &[&str]) -> String {140
format!(144
format!(141
"het statusvenster kan niet openen; deze bibliotheken ontbreken: {}. Import en render zijn niet gebouwd.",145
"het venster kan niet openen; deze bibliotheken ontbreken: {}.",142
missing.join(", ")146
missing.join(", ")143
)147
)144
}148
}@@ -158,35 +162,70 @@ pub fn missing_window_libraries() -> Vec<&'static str> {158
162
159
pub fn help_text() -> &'static str {163
pub fn help_text() -> &'static str {160
"\164
"\161
Paperdoll Renderer — modelimport165
Paperdoll Renderer162
166
163
Gebruik:167
Gebruik:164
paperdoll-renderer open het projectvenster168
paperdoll-renderer startscherm165
paperdoll-renderer --status print de status en sluit169
paperdoll-renderer pad/naar/character.ppd open dat project166
paperdoll-renderer --help toon deze tekst170
paperdoll-renderer --install-desktop registreer Openen met op Linux171
paperdoll-renderer --status print de status en sluit172
paperdoll-renderer --help toon deze tekst173
paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam167
174
168
Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB, FBX en OBJ+MTL worden in het venster gelezen. Render is niet gebouwd."175
--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd."169
}176
}170
177
171
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>178
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>172
where179
where173
I: IntoIterator<Item = &'a str>,180
I: IntoIterator<Item = &'a str>,174
{181
{175
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;176
for arg in args {190
for arg in args {177
match arg {191
match arg {178
"--help" | "-h" => request = CliRequest::PrintHelp,192
"--help" | "-h" => help = true,179
"--status" => {193
"--status" => status = true,180
if request != CliRequest::PrintHelp {194
"--preview-fixture" => preview = true,181
request = CliRequest::PrintStatus;195
other if other.starts_with('-') => {182
}196
return Err(CliError::UnknownArgument(other.to_string()));183
}197
}184
other => {198
other => {185
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());186
}203
}187
}204
}188
}205
}189
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)190
}229
}191
230
192
#[cfg(test)]231
#[cfg(test)]@@ -205,8 +244,11 @@ mod tests {205
Availability::Present244
Availability::Present206
);245
);207
assert_eq!(availability(Capability::Fbx), Availability::Present);246
assert_eq!(availability(Capability::Fbx), Availability::Present);247
assert_eq!(248
availability(Capability::SkeletalAnimation),249
Availability::Present250
);208
for capability in [251
for capability in [209
Capability::SkeletalAnimation,210
Capability::AttachmentEditor,252
Capability::AttachmentEditor,211
Capability::PaperdollRender,253
Capability::PaperdollRender,212
Capability::Preview2d,254
Capability::Preview2d,@@ -222,19 +264,20 @@ mod tests {222
#[test]264
#[test]223
fn status_report_matches_the_import_build() {265
fn status_report_matches_the_import_build() {224
let report = report();266
let report = report();225
assert!(report.starts_with("name: Paperdoll Renderer\nphase: fbx-import\n"));267
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));226
for key in ["project_shell", "obj_mtl_textures", "gltf_glb", "fbx"] {268
for key in [269
"project_shell",270
"obj_mtl_textures",271
"gltf_glb",272
"fbx",273
"skeletal_animation",274
] {227
assert!(275
assert!(228
report.contains(&format!("{key}: present")),276
report.contains(&format!("{key}: present")),229
"missing present {key} in {report}"277
"missing present {key} in {report}"230
);278
);231
}279
}232
for key in [280
for key in ["attachment_editor", "paperdoll_render", "preview_2d"] {233
"skeletal_animation",234
"attachment_editor",235
"paperdoll_render",236
"preview_2d",237
] {238
assert!(281
assert!(239
report.contains(&format!("{key}: not_built")),282
report.contains(&format!("{key}: not_built")),240
"missing {key} in {report}"283
"missing {key} in {report}"@@ -245,12 +288,9 @@ mod tests {245
);288
);246
}289
}247
let summary = dutch_summary();290
let summary = dutch_summary();248
assert!(summary.contains("niet gebouwd"));291
assert!(summary.contains("3D-preview"));249
assert!(summary.contains("OBJ+MTL"));250
assert!(summary.contains("glTF/GLB"));292
assert!(summary.contains("glTF/GLB"));251
assert!(summary.contains("FBX"));293
assert!(summary.contains("walk"));252
let lower = summary.to_lowercase();253
assert!(!lower.contains("render werkt"));254
}294
}255
295
256
#[test]296
#[test]@@ -260,24 +300,34 @@ mod tests {260
assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));300
assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));261
assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));301
assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));262
assert_eq!(302
assert_eq!(263
parse_cli(["tests/fixtures/bodies/test-body-male.glb"]),303
parse_cli(["mijn project/character.ppd"]),264
Err(CliError::UnknownArgument(304
Ok(CliRequest::OpenProject(265
"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()266
))312
))267
);313
);268
assert_eq!(314
assert_eq!(269
parse_cli(["--status", "--render"]),315
parse_cli(["--status", "--render"]),270
Err(CliError::UnknownArgument("--render".to_string()))316
Err(CliError::UnknownArgument("--render".to_string()))271
);317
);272
assert!(help_text().contains("niet gebouwd"));318
assert!(help_text().contains("--preview-fixture"));319
assert_eq!(320
parse_cli(["--preview-fixture"]),321
Ok(CliRequest::PreviewFixtures)322
);273
}323
}274
324
275
#[test]325
#[test]276
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() {277
let message = missing_library_message(&["libxkbcommon-x11.so.0"]);327
let message = missing_library_message(&["libxkbcommon-x11.so.0"]);278
assert!(message.contains("libxkbcommon-x11.so.0"));328
assert!(message.contains("libxkbcommon-x11.so.0"));279
assert!(message.contains("niet gebouwd"));329
assert!(!message.to_lowercase().contains("import"));280
assert!(!message.to_lowercase().contains("import werkt"));330
assert!(!message.to_lowercase().contains("niet gebouwd"));281
assert!(!LINUX_WINDOW_LIBRARIES.is_empty());331
assert!(!LINUX_WINDOW_LIBRARIES.is_empty());282
}332
}283
}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,9 +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;
12
pub mod preview;
13
pub mod preview_gl;
9
14
pub mod project;
15
pub mod render_job;
16
pub mod session;
17
pub mod still;
@@ -1,9 +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;12
pub mod preview;13
pub mod preview_gl;9
pub mod project;14
pub mod project;15
pub mod render_job;16
pub mod session;17
pub mod still;@@ -1,7 +1,6 @@
1
1
//! Desktop shell for Paperdoll Renderer project files.
2
2
//!
3
//! The window creates and saves a `.ppd` project directory. It does not import
4
//! models and it does not render paperdolls.
3
//! The window edits a `.ppd` project and shows a live skinned glTF preview.
5
4
6
5
use std::process::ExitCode;
7
6
@@ -12,6 +11,32 @@ fn main() -> ExitCode {
12
11
let args: Vec<String> = std::env::args().skip(1).collect();
13
12
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
14
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
}
15
40
Ok(CliRequest::PrintHelp) => {
16
41
println!("{}", help_text());
17
42
ExitCode::SUCCESS
@@ -20,27 +45,43 @@ fn main() -> ExitCode {
20
45
println!("{}", report());
21
46
ExitCode::SUCCESS
22
47
}
23
Ok(CliRequest::OpenWindow) => {
24
if let Some(message) = window_preflight() {
25
eprintln!("paperdoll-renderer: {message}");
26
return ExitCode::from(1);
27
}
28
match run_window() {
29
Ok(()) => ExitCode::SUCCESS,
30
Err(err) => {
31
eprintln!("paperdoll-renderer: het projectvenster openen is mislukt: {err}");
32
ExitCode::from(1)
33
}
34
}
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)
35
56
}
36
57
Err(CliError::UnknownArgument(arg)) => {
37
58
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");
38
eprintln!("Import en render zijn niet gebouwd. Gebruik --help.");
59
eprintln!("Gebruik --help.");
39
60
ExitCode::from(2)
40
61
}
41
62
}
42
63
}
43
64
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
}
72
if let Some(message) = window_preflight() {
73
eprintln!("paperdoll-renderer: {message}");
74
return ExitCode::from(1);
75
}
76
match run_window(fixtures, path) {
77
Ok(()) => ExitCode::SUCCESS,
78
Err(err) => {
79
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");
80
ExitCode::from(1)
81
}
82
}
83
}
84
44
85
fn window_preflight() -> Option<String> {
45
86
#[cfg(target_os = "linux")]
46
87
{
@@ -59,17 +100,32 @@ fn window_preflight() -> Option<String> {
59
100
}
60
101
}
61
102
62
fn run_window() -> 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();
63
105
let options = eframe::NativeOptions {
64
106
viewport: eframe::egui::ViewportBuilder::default()
65
107
.with_title("Paperdoll Renderer")
66
.with_inner_size([980.0, 680.0])
67
.with_min_inner_size([720.0, 480.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
}),
68
115
..Default::default()
69
116
};
70
117
eframe::run_native(
71
118
"Paperdoll Renderer",
72
119
options,
73
Box::new(|_creation| Ok(Box::<EditorApp>::default())),
120
Box::new(move |_creation| {
121
let mut app = EditorApp::default();
122
if fixtures {
123
app.open_fixture_project();
124
}
125
if let Some(path) = path {
126
app.open_path(path);
127
}
128
Ok(Box::new(app))
129
}),
74
130
)
75
131
}
@@ -1,7 +1,6 @@1
//! Desktop shell for Paperdoll Renderer project files.1
//! Desktop shell for Paperdoll Renderer project files.2
//!2
//!3
//! The window creates and saves a `.ppd` project directory. It does not import3
//! The window edits a `.ppd` project and shows a live skinned glTF preview.4
//! models and it does not render paperdolls.5
4
6
use std::process::ExitCode;5
use std::process::ExitCode;7
6
@@ -12,6 +11,32 @@ fn main() -> ExitCode {12
let args: Vec<String> = std::env::args().skip(1).collect();11
let args: Vec<String> = std::env::args().skip(1).collect();13
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();12
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();14
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
}15
Ok(CliRequest::PrintHelp) => {40
Ok(CliRequest::PrintHelp) => {16
println!("{}", help_text());41
println!("{}", help_text());17
ExitCode::SUCCESS42
ExitCode::SUCCESS@@ -20,27 +45,43 @@ fn main() -> ExitCode {20
println!("{}", report());45
println!("{}", report());21
ExitCode::SUCCESS46
ExitCode::SUCCESS22
}47
}23
Ok(CliRequest::OpenWindow) => {48
Ok(CliRequest::PreviewFixtures) => open_window(true, None),24
if let Some(message) = window_preflight() {49
Ok(CliRequest::OpenWindow) => open_window(false, None),25
eprintln!("paperdoll-renderer: {message}");50
Ok(CliRequest::OpenProject(path)) => {26
return ExitCode::from(1);51
open_window(false, Some(std::path::PathBuf::from(path)))27
}52
}28
match run_window() {53
Err(CliError::Usage(message)) => {29
Ok(()) => ExitCode::SUCCESS,54
eprintln!("paperdoll-renderer: {message}");30
Err(err) => {55
ExitCode::from(2)31
eprintln!("paperdoll-renderer: het projectvenster openen is mislukt: {err}");32
ExitCode::from(1)33
}34
}35
}56
}36
Err(CliError::UnknownArgument(arg)) => {57
Err(CliError::UnknownArgument(arg)) => {37
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");58
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");38
eprintln!("Import en render zijn niet gebouwd. Gebruik --help.");59
eprintln!("Gebruik --help.");39
ExitCode::from(2)60
ExitCode::from(2)40
}61
}41
}62
}42
}63
}43
64
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
}72
if let Some(message) = window_preflight() {73
eprintln!("paperdoll-renderer: {message}");74
return ExitCode::from(1);75
}76
match run_window(fixtures, path) {77
Ok(()) => ExitCode::SUCCESS,78
Err(err) => {79
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");80
ExitCode::from(1)81
}82
}83
}84
44
fn window_preflight() -> Option<String> {85
fn window_preflight() -> Option<String> {45
#[cfg(target_os = "linux")]86
#[cfg(target_os = "linux")]46
{87
{@@ -59,17 +100,32 @@ fn window_preflight() -> Option<String> {59
}100
}60
}101
}61
102
62
fn run_window() -> 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();63
let options = eframe::NativeOptions {105
let options = eframe::NativeOptions {64
viewport: eframe::egui::ViewportBuilder::default()106
viewport: eframe::egui::ViewportBuilder::default()65
.with_title("Paperdoll Renderer")107
.with_title("Paperdoll Renderer")66
.with_inner_size([980.0, 680.0])108
.with_inner_size(paperdoll_renderer::session::START_WINDOW)67
.with_min_inner_size([720.0, 480.0]),109
.with_min_inner_size([420.0, 220.0])110
.with_icon(eframe::egui::IconData {111
rgba,112
width,113
height,114
}),68
..Default::default()115
..Default::default()69
};116
};70
eframe::run_native(117
eframe::run_native(71
"Paperdoll Renderer",118
"Paperdoll Renderer",72
options,119
options,73
Box::new(|_creation| Ok(Box::<EditorApp>::default())),120
Box::new(move |_creation| {121
let mut app = EditorApp::default();122
if fixtures {123
app.open_fixture_project();124
}125
if let Some(path) = path {126
app.open_path(path);127
}128
Ok(Box::new(app))129
}),74
)130
)75
}131
}@@ -0,0 +1,767 @@
1
//! CPU skeleton sampling and orbit camera for the live glTF preview.
2
//!
3
//! This is not the paperdoll export. The export camera is orthographic and
4
//! still out of scope. The interactive camera can snap to the same three
5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6
//! so yaw 0 places the camera on -Z, looking at the body.
7
8
use std::path::Path;
9
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
11
pub const PAPERDOLL_YAW_COUNT: usize = 12;
12
pub const MAX_JOINTS: usize = 128;
13
14
pub type Mat4 = [f32; 16];
15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
pub enum PreviewSex {
18
Male,
19
Female,
20
}
21
22
#[derive(Debug, Clone)]
23
pub struct PreviewControls {
24
pub sex: PreviewSex,
25
pub clip_name: String,
26
pub time: f32,
27
pub playing: bool,
28
pub yaw_deg: f32,
29
pub pitch_deg: f32,
30
pub distance: f32,
31
}
32
33
impl Default for PreviewControls {
34
fn default() -> Self {
35
Self {
36
sex: PreviewSex::Male,
37
clip_name: "walk".to_string(),
38
time: 0.0,
39
playing: true,
40
yaw_deg: 0.0,
41
pitch_deg: 0.0,
42
distance: 3.4,
43
}
44
}
45
}
46
47
impl PreviewControls {
48
pub fn drag(&mut self, dx_px: f32, dy_px: f32) {
49
self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);
50
self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);
51
}
52
53
pub fn zoom(&mut self, scroll_y: f32) {
54
let factor = (-scroll_y * 0.0015).exp();
55
self.distance = (self.distance * factor).clamp(0.8, 12.0);
56
}
57
58
pub fn snap_yaw_index(&mut self, index: i32) {
59
let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;
60
self.yaw_deg = wrapped * 30.0;
61
}
62
63
pub fn snap_pitch_row(&mut self, row: i32) {
64
let row = row.clamp(0, 2) as usize;
65
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66
}
67
68
pub fn advance(&mut self, dt: f32, duration: f32) {
69
if self.playing && duration > 0.0 {
70
self.time = wrap_time(self.time + dt, duration);
71
}
72
}
73
74
pub fn select_clip(&mut self, name: &str) {
75
if self.clip_name != name {
76
self.clip_name = name.to_string();
77
self.time = 0.0;
78
}
79
}
80
}
81
82
#[derive(Debug, Clone)]
83
pub struct PreviewCharacter {
84
pub nodes: Vec<SceneNode>,
85
pub joint_nodes: Vec<usize>,
86
pub joint_names: Vec<String>,
87
pub inverse_binds: Vec<Mat4>,
88
pub positions: Vec<[f32; 3]>,
89
pub normals: Vec<[f32; 3]>,
90
pub joints: Vec<[u16; 4]>,
91
pub weights: Vec<[f32; 4]>,
92
pub indices: Vec<u32>,
93
pub base_color: [f32; 4],
94
pub clips: Vec<PreviewClip>,
95
}
96
97
#[derive(Debug, Clone)]
98
pub struct SceneNode {
99
pub name: String,
100
pub parent: Option<usize>,
101
pub translation: [f32; 3],
102
pub rotation: [f32; 4],
103
pub scale: [f32; 3],
104
}
105
106
#[derive(Debug, Clone)]
107
pub struct PreviewClip {
108
pub name: String,
109
pub duration: f32,
110
pub channels: Vec<AnimChannel>,
111
}
112
113
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114
pub enum ChannelProperty {
115
Translation,
116
Rotation,
117
Scale,
118
}
119
120
#[derive(Debug, Clone)]
121
pub struct AnimChannel {
122
pub node: usize,
123
pub property: ChannelProperty,
124
pub times: Vec<f32>,
125
pub values: Vec<f32>,
126
}
127
128
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
129
let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;
130
let mut nodes = Vec::new();
131
let mut parents = vec![None; document.nodes().len()];
132
for node in document.nodes() {
133
for child in node.children() {
134
parents[child.index()] = Some(node.index());
135
}
136
}
137
for node in document.nodes() {
138
let (translation, rotation, scale) = node.transform().decomposed();
139
nodes.push(SceneNode {
140
name: node.name().unwrap_or("").to_string(),
141
parent: parents[node.index()],
142
translation,
143
rotation,
144
scale,
145
});
146
}
147
let skin = document
148
.skins()
149
.next()
150
.ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;
151
if skin.joints().len() > MAX_JOINTS {
152
return Err(format!(
153
"het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
154
skin.joints().len()
155
));
156
}
157
let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
158
let joint_names: Vec<String> = joint_nodes
159
.iter()
160
.map(|index| nodes[*index].name.clone())
161
.collect();
162
let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
163
let inverse_binds = reader
164
.read_inverse_bind_matrices()
165
.map(|iter| iter.map(columns_to_mat).collect())
166
.unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
167
if inverse_binds.len() != joint_nodes.len() {
168
return Err("inverse-bindmatrices en joints verschillen".to_string());
169
}
170
let mut positions = Vec::new();
171
let mut normals = Vec::new();
172
let mut joints = Vec::new();
173
let mut weights = Vec::new();
174
let mut indices = Vec::new();
175
let mut base_color = [0.7, 0.7, 0.75, 1.0];
176
if let Some(mesh) = document.meshes().next() {
177
for primitive in mesh.primitives() {
178
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179
let prim_positions: Vec<[f32; 3]> = reader
180
.read_positions()
181
.map(|iter| iter.collect())
182
.unwrap_or_default();
183
let prim_normals: Vec<[f32; 3]> = reader
184
.read_normals()
185
.map(|iter| iter.collect())
186
.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);
187
let prim_joints: Vec<[u16; 4]> = reader
188
.read_joints(0)
189
.map(|iter| iter.into_u16().collect())
190
.unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);
191
let prim_weights: Vec<[f32; 4]> = reader
192
.read_weights(0)
193
.map(|iter| iter.into_f32().collect())
194
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
195
let base = positions.len() as u32;
196
if let Some(read_indices) = reader.read_indices() {
197
indices.extend(read_indices.into_u32().map(|index| index + base));
198
} else {
199
indices.extend(base..base + prim_positions.len() as u32);
200
}
201
positions.extend(prim_positions);
202
normals.extend(prim_normals);
203
joints.extend(prim_joints);
204
weights.extend(prim_weights);
205
base_color = primitive
206
.material()
207
.pbr_metallic_roughness()
208
.base_color_factor();
209
}
210
}
211
if positions.is_empty() || indices.len() < 3 {
212
return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
213
}
214
let mut clips = Vec::new();
215
for animation in document.animations() {
216
let mut channels = Vec::new();
217
let mut duration = 0.0_f32;
218
for channel in animation.channels() {
219
let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
220
let times: Vec<f32> = reader
221
.read_inputs()
222
.map(|iter| iter.collect())
223
.unwrap_or_default();
224
if let Some(last) = times.last() {
225
duration = duration.max(*last);
226
}
227
let Some(outputs) = reader.read_outputs() else {
228
continue;
229
};
230
let (property, values) = match outputs {
231
gltf::animation::util::ReadOutputs::Translations(values) => {
232
(ChannelProperty::Translation, values.flatten().collect())
233
}
234
gltf::animation::util::ReadOutputs::Scales(values) => {
235
(ChannelProperty::Scale, values.flatten().collect())
236
}
237
gltf::animation::util::ReadOutputs::Rotations(values) => (
238
ChannelProperty::Rotation,
239
values.into_f32().flatten().collect(),
240
),
241
gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
242
};
243
channels.push(AnimChannel {
244
node: channel.target().node().index(),
245
property,
246
times,
247
values,
248
});
249
}
250
clips.push(PreviewClip {
251
name: animation.name().unwrap_or("naamloos").to_string(),
252
duration,
253
channels,
254
});
255
}
256
Ok(PreviewCharacter {
257
nodes,
258
joint_nodes,
259
joint_names,
260
inverse_binds,
261
positions,
262
normals,
263
joints,
264
weights,
265
indices,
266
base_color,
267
clips,
268
})
269
}
270
271
pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
272
let locals = sample_local_matrices(character, clip_name, time);
273
let worlds = world_matrices(&character.nodes, &locals);
274
character
275
.joint_nodes
276
.iter()
277
.enumerate()
278
.map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
279
.collect()
280
}
281
282
pub fn joint_world_translation(
283
character: &PreviewCharacter,
284
clip_name: &str,
285
time: f32,
286
joint_name: &str,
287
) -> Option<[f32; 3]> {
288
let joint = character
289
.joint_names
290
.iter()
291
.position(|name| name == joint_name)?;
292
let node = character.joint_nodes[joint];
293
let locals = sample_local_matrices(character, clip_name, time);
294
let worlds = world_matrices(&character.nodes, &locals);
295
Some([worlds[node][12], worlds[node][13], worlds[node][14]])
296
}
297
298
pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
299
character
300
.clips
301
.iter()
302
.find(|clip| {
303
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
304
})
305
.map(|clip| clip.duration)
306
.unwrap_or(0.0)
307
}
308
309
pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
310
let yaw = yaw_deg.to_radians();
311
let pitch = pitch_deg.to_radians();
312
[
313
-distance * pitch.cos() * yaw.sin(),
314
distance * pitch.sin(),
315
-distance * pitch.cos() * yaw.cos(),
316
]
317
}
318
319
pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
320
let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
321
for (index, yaw) in yaws.iter_mut().enumerate() {
322
*yaw = index as f32 * 30.0;
323
}
324
yaws
325
}
326
327
fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
328
let mut translation: Vec<[f32; 3]> = character
329
.nodes
330
.iter()
331
.map(|node| node.translation)
332
.collect();
333
let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
334
let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
335
if let Some(clip) = character.clips.iter().find(|clip| {
336
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
337
}) {
338
let time = wrap_time(time, clip.duration);
339
for channel in &clip.channels {
340
if channel.node >= character.nodes.len() || channel.times.is_empty() {
341
continue;
342
}
343
let (left, right, blend) = key_blend(&channel.times, time);
344
match channel.property {
345
ChannelProperty::Translation => {
346
translation[channel.node] = lerp3(
347
key3(&channel.values, left),
348
key3(&channel.values, right),
349
blend,
350
);
351
}
352
ChannelProperty::Scale => {
353
scale[channel.node] = lerp3(
354
key3(&channel.values, left),
355
key3(&channel.values, right),
356
blend,
357
);
358
}
359
ChannelProperty::Rotation => {
360
rotation[channel.node] = slerp(
361
key4(&channel.values, left),
362
key4(&channel.values, right),
363
blend,
364
);
365
}
366
}
367
}
368
}
369
translation
370
.into_iter()
371
.zip(rotation)
372
.zip(scale)
373
.map(|((translation, rotation), scale)| trs(translation, rotation, scale))
374
.collect()
375
}
376
377
fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
378
fn world(
379
index: usize,
380
nodes: &[SceneNode],
381
locals: &[Mat4],
382
worlds: &mut [Option<Mat4>],
383
depth: usize,
384
) -> Mat4 {
385
if let Some(matrix) = worlds[index] {
386
return matrix;
387
}
388
if depth > nodes.len() {
389
return locals[index];
390
}
391
let matrix = match nodes[index].parent {
392
Some(parent) => mul(
393
world(parent, nodes, locals, worlds, depth + 1),
394
locals[index],
395
),
396
None => locals[index],
397
};
398
worlds[index] = Some(matrix);
399
matrix
400
}
401
let mut cached = vec![None; nodes.len()];
402
(0..nodes.len())
403
.map(|index| world(index, nodes, locals, &mut cached, 0))
404
.collect()
405
}
406
407
fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
408
if times.len() == 1 || time <= times[0] {
409
return (0, 0, 0.0);
410
}
411
if time >= times[times.len() - 1] {
412
let last = times.len() - 1;
413
return (last, last, 0.0);
414
}
415
let mut index = 0;
416
while index + 1 < times.len() && times[index + 1] < time {
417
index += 1;
418
}
419
let span = times[index + 1] - times[index];
420
let blend = if span.abs() < 1e-6 {
421
0.0
422
} else {
423
(time - times[index]) / span
424
};
425
(index, index + 1, blend)
426
}
427
428
fn key3(values: &[f32], index: usize) -> [f32; 3] {
429
let start = index * 3;
430
[
431
values.get(start).copied().unwrap_or(0.0),
432
values.get(start + 1).copied().unwrap_or(0.0),
433
values.get(start + 2).copied().unwrap_or(0.0),
434
]
435
}
436
437
fn key4(values: &[f32], index: usize) -> [f32; 4] {
438
let start = index * 4;
439
[
440
values.get(start).copied().unwrap_or(0.0),
441
values.get(start + 1).copied().unwrap_or(0.0),
442
values.get(start + 2).copied().unwrap_or(0.0),
443
values.get(start + 3).copied().unwrap_or(1.0),
444
]
445
}
446
447
fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
448
[
449
a[0] + (b[0] - a[0]) * t,
450
a[1] + (b[1] - a[1]) * t,
451
a[2] + (b[2] - a[2]) * t,
452
]
453
}
454
455
fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
456
let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
457
if dot < 0.0 {
458
dot = -dot;
459
b = [-b[0], -b[1], -b[2], -b[3]];
460
}
461
if dot > 0.9995 {
462
return normalize4([
463
a[0] + (b[0] - a[0]) * t,
464
a[1] + (b[1] - a[1]) * t,
465
a[2] + (b[2] - a[2]) * t,
466
a[3] + (b[3] - a[3]) * t,
467
]);
468
}
469
let theta = dot.clamp(-1.0, 1.0).acos();
470
let sin_theta = theta.sin();
471
let w1 = ((1.0 - t) * theta).sin() / sin_theta;
472
let w2 = (t * theta).sin() / sin_theta;
473
[
474
a[0] * w1 + b[0] * w2,
475
a[1] * w1 + b[1] * w2,
476
a[2] * w1 + b[2] * w2,
477
a[3] * w1 + b[3] * w2,
478
]
479
}
480
481
fn normalize4(value: [f32; 4]) -> [f32; 4] {
482
let length =
483
(value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
484
.sqrt();
485
if length < 1e-8 {
486
[0.0, 0.0, 0.0, 1.0]
487
} else {
488
[
489
value[0] / length,
490
value[1] / length,
491
value[2] / length,
492
value[3] / length,
493
]
494
}
495
}
496
497
fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
498
let mut matrix = quat_to_mat(normalize4(rotation));
499
matrix[0] *= scale[0];
500
matrix[1] *= scale[0];
501
matrix[2] *= scale[0];
502
matrix[4] *= scale[1];
503
matrix[5] *= scale[1];
504
matrix[6] *= scale[1];
505
matrix[8] *= scale[2];
506
matrix[9] *= scale[2];
507
matrix[10] *= scale[2];
508
matrix[12] = translation[0];
509
matrix[13] = translation[1];
510
matrix[14] = translation[2];
511
matrix
512
}
513
514
fn quat_to_mat(q: [f32; 4]) -> Mat4 {
515
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
516
let xx = x * x;
517
let yy = y * y;
518
let zz = z * z;
519
let xy = x * y;
520
let xz = x * z;
521
let yz = y * z;
522
let wx = w * x;
523
let wy = w * y;
524
let wz = w * z;
525
[
526
1.0 - 2.0 * (yy + zz),
527
2.0 * (xy + wz),
528
2.0 * (xz - wy),
529
0.0,
530
2.0 * (xy - wz),
531
1.0 - 2.0 * (xx + zz),
532
2.0 * (yz + wx),
533
0.0,
534
2.0 * (xz + wy),
535
2.0 * (yz - wx),
536
1.0 - 2.0 * (xx + yy),
537
0.0,
538
0.0,
539
0.0,
540
0.0,
541
1.0,
542
]
543
}
544
545
pub fn identity() -> Mat4 {
546
[
547
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
548
]
549
}
550
551
pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
552
let mut out = [0.0; 16];
553
for column in 0..4 {
554
for row in 0..4 {
555
out[column * 4 + row] = left[row] * right[column * 4]
556
+ left[4 + row] * right[column * 4 + 1]
557
+ left[8 + row] * right[column * 4 + 2]
558
+ left[12 + row] * right[column * 4 + 3];
559
}
560
}
561
out
562
}
563
564
fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
565
let mut matrix = [0.0; 16];
566
for column in 0..4 {
567
for row in 0..4 {
568
matrix[column * 4 + row] = columns[column][row];
569
}
570
}
571
matrix
572
}
573
574
pub fn wrap_time(time: f32, duration: f32) -> f32 {
575
if duration <= 1e-5 {
576
return 0.0;
577
}
578
let mut wrapped = time % duration;
579
if wrapped < 0.0 {
580
wrapped += duration;
581
}
582
wrapped
583
}
584
585
fn wrap_deg(mut degrees: f32) -> f32 {
586
degrees %= 360.0;
587
if degrees < 0.0 {
588
degrees += 360.0;
589
}
590
degrees
591
}
592
593
pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
594
let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
595
let up = [0.0, 1.0, 0.0];
596
let mut side = normalize3(cross(forward, up));
597
if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
598
side = [1.0, 0.0, 0.0];
599
}
600
let up = cross(side, forward);
601
[
602
side[0],
603
up[0],
604
-forward[0],
605
0.0,
606
side[1],
607
up[1],
608
-forward[1],
609
0.0,
610
side[2],
611
up[2],
612
-forward[2],
613
0.0,
614
-dot(side, eye),
615
-dot(up, eye),
616
dot(forward, eye),
617
1.0,
618
]
619
}
620
621
pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
622
let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
623
let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
624
let nf = 1.0 / (near - far);
625
[
626
f / aspect,
627
0.0,
628
0.0,
629
0.0,
630
0.0,
631
f,
632
0.0,
633
0.0,
634
0.0,
635
0.0,
636
(far + near) * nf,
637
-1.0,
638
0.0,
639
0.0,
640
2.0 * far * near * nf,
641
0.0,
642
]
643
}
644
645
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
646
[
647
a[1] * b[2] - a[2] * b[1],
648
a[2] * b[0] - a[0] * b[2],
649
a[0] * b[1] - a[1] * b[0],
650
]
651
}
652
653
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
654
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
655
}
656
657
fn normalize3(value: [f32; 3]) -> [f32; 3] {
658
let length = dot(value, value).sqrt();
659
if length < 1e-8 {
660
[0.0, 0.0, 1.0]
661
} else {
662
[value[0] / length, value[1] / length, value[2] / length]
663
}
664
}
665
666
#[cfg(test)]
667
mod tests {
668
use super::*;
669
670
fn male() -> PreviewCharacter {
671
load_gltf_file(
672
&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
673
)
674
.unwrap()
675
}
676
677
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
678
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
679
dot(d, d).sqrt()
680
}
681
682
#[test]
683
fn walk_and_attack_move_the_matching_bones() {
684
let character = male();
685
assert!(character.clips.iter().any(|clip| clip.name == "walk"));
686
assert!(character.clips.iter().any(|clip| clip.name == "attack"));
687
assert_eq!(character.joint_names.len(), 19);
688
assert!(character.indices.len() >= 3);
689
assert!(character.positions.len() >= 19 * 8);
690
let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
691
let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
692
assert!(
693
distance(leg_a, leg_b) > 0.05,
694
"walk should move the lower leg, delta {}",
695
distance(leg_a, leg_b)
696
);
697
let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
698
let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
699
assert!(
700
distance(arm_a, arm_b) > 0.05,
701
"attack should move the right hand, delta {}",
702
distance(arm_a, arm_b)
703
);
704
let matrices = sample_skin_matrices(&character, "walk", 0.2);
705
assert_eq!(matrices.len(), 19);
706
assert!(matrices.iter().flatten().all(|value| value.is_finite()));
707
}
708
709
#[test]
710
fn female_fixture_has_the_same_clip_names() {
711
let character = load_gltf_file(
712
&Path::new(env!("CARGO_MANIFEST_DIR"))
713
.join("tests/fixtures/bodies/test-body-female.glb"),
714
)
715
.unwrap();
716
assert!(character.clips.iter().any(|clip| clip.name == "walk"));
717
assert!(character.clips.iter().any(|clip| clip.name == "attack"));
718
let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
719
let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
720
assert!(distance(a, b) > 0.05);
721
}
722
723
#[test]
724
fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
725
let front = eye_offset(0.0, 0.0, 4.0);
726
assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
727
assert!(front[0].abs() < 0.05);
728
let mut seen = Vec::new();
729
for yaw in paperdoll_yaws_deg() {
730
let eye = eye_offset(yaw, 0.0, 4.0);
731
assert!(seen
732
.iter()
733
.all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
734
seen.push(eye);
735
}
736
assert_eq!(seen.len(), 12);
737
let low = eye_offset(0.0, -30.0, 4.0);
738
let high = eye_offset(0.0, 30.0, 4.0);
739
assert!(low[1] < 0.0 && high[1] > 0.0);
740
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);
741
}
742
743
#[test]
744
fn controls_snap_wrap_and_keep_a_clip_change() {
745
let mut controls = PreviewControls::default();
746
controls.drag(-100.0, 40.0);
747
assert!(controls.yaw_deg > 40.0);
748
assert!(controls.pitch_deg > 10.0);
749
controls.zoom(400.0);
750
assert!(controls.distance < 3.4);
751
controls.snap_yaw_index(-1);
752
assert!((controls.yaw_deg - 330.0).abs() < 0.01);
753
controls.snap_pitch_row(99);
754
assert!((controls.pitch_deg - 30.0).abs() < 0.01);
755
controls.snap_pitch_row(-4);
756
assert!((controls.pitch_deg + 30.0).abs() < 0.01);
757
controls.time = 0.4;
758
controls.select_clip("attack");
759
assert_eq!(controls.clip_name, "attack");
760
assert_eq!(controls.time, 0.0);
761
controls.time = 1.2;
762
controls.advance(0.0, 1.0);
763
controls.playing = true;
764
controls.advance(0.3, 1.0);
765
assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
766
}
767
}
@@ -0,0 +1,767 @@1
//! CPU skeleton sampling and orbit camera for the live glTF preview.2
//!3
//! This is not the paperdoll export. The export camera is orthographic and4
//! still out of scope. The interactive camera can snap to the same three5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,6
//! so yaw 0 places the camera on -Z, looking at the body.7
8
use std::path::Path;9
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];11
pub const PAPERDOLL_YAW_COUNT: usize = 12;12
pub const MAX_JOINTS: usize = 128;13
14
pub type Mat4 = [f32; 16];15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]17
pub enum PreviewSex {18
Male,19
Female,20
}21
22
#[derive(Debug, Clone)]23
pub struct PreviewControls {24
pub sex: PreviewSex,25
pub clip_name: String,26
pub time: f32,27
pub playing: bool,28
pub yaw_deg: f32,29
pub pitch_deg: f32,30
pub distance: f32,31
}32
33
impl Default for PreviewControls {34
fn default() -> Self {35
Self {36
sex: PreviewSex::Male,37
clip_name: "walk".to_string(),38
time: 0.0,39
playing: true,40
yaw_deg: 0.0,41
pitch_deg: 0.0,42
distance: 3.4,43
}44
}45
}46
47
impl PreviewControls {48
pub fn drag(&mut self, dx_px: f32, dy_px: f32) {49
self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);50
self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);51
}52
53
pub fn zoom(&mut self, scroll_y: f32) {54
let factor = (-scroll_y * 0.0015).exp();55
self.distance = (self.distance * factor).clamp(0.8, 12.0);56
}57
58
pub fn snap_yaw_index(&mut self, index: i32) {59
let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;60
self.yaw_deg = wrapped * 30.0;61
}62
63
pub fn snap_pitch_row(&mut self, row: i32) {64
let row = row.clamp(0, 2) as usize;65
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];66
}67
68
pub fn advance(&mut self, dt: f32, duration: f32) {69
if self.playing && duration > 0.0 {70
self.time = wrap_time(self.time + dt, duration);71
}72
}73
74
pub fn select_clip(&mut self, name: &str) {75
if self.clip_name != name {76
self.clip_name = name.to_string();77
self.time = 0.0;78
}79
}80
}81
82
#[derive(Debug, Clone)]83
pub struct PreviewCharacter {84
pub nodes: Vec<SceneNode>,85
pub joint_nodes: Vec<usize>,86
pub joint_names: Vec<String>,87
pub inverse_binds: Vec<Mat4>,88
pub positions: Vec<[f32; 3]>,89
pub normals: Vec<[f32; 3]>,90
pub joints: Vec<[u16; 4]>,91
pub weights: Vec<[f32; 4]>,92
pub indices: Vec<u32>,93
pub base_color: [f32; 4],94
pub clips: Vec<PreviewClip>,95
}96
97
#[derive(Debug, Clone)]98
pub struct SceneNode {99
pub name: String,100
pub parent: Option<usize>,101
pub translation: [f32; 3],102
pub rotation: [f32; 4],103
pub scale: [f32; 3],104
}105
106
#[derive(Debug, Clone)]107
pub struct PreviewClip {108
pub name: String,109
pub duration: f32,110
pub channels: Vec<AnimChannel>,111
}112
113
#[derive(Debug, Clone, Copy, PartialEq, Eq)]114
pub enum ChannelProperty {115
Translation,116
Rotation,117
Scale,118
}119
120
#[derive(Debug, Clone)]121
pub struct AnimChannel {122
pub node: usize,123
pub property: ChannelProperty,124
pub times: Vec<f32>,125
pub values: Vec<f32>,126
}127
128
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {129
let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;130
let mut nodes = Vec::new();131
let mut parents = vec![None; document.nodes().len()];132
for node in document.nodes() {133
for child in node.children() {134
parents[child.index()] = Some(node.index());135
}136
}137
for node in document.nodes() {138
let (translation, rotation, scale) = node.transform().decomposed();139
nodes.push(SceneNode {140
name: node.name().unwrap_or("").to_string(),141
parent: parents[node.index()],142
translation,143
rotation,144
scale,145
});146
}147
let skin = document148
.skins()149
.next()150
.ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;151
if skin.joints().len() > MAX_JOINTS {152
return Err(format!(153
"het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",154
skin.joints().len()155
));156
}157
let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();158
let joint_names: Vec<String> = joint_nodes159
.iter()160
.map(|index| nodes[*index].name.clone())161
.collect();162
let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));163
let inverse_binds = reader164
.read_inverse_bind_matrices()165
.map(|iter| iter.map(columns_to_mat).collect())166
.unwrap_or_else(|| vec![identity(); joint_nodes.len()]);167
if inverse_binds.len() != joint_nodes.len() {168
return Err("inverse-bindmatrices en joints verschillen".to_string());169
}170
let mut positions = Vec::new();171
let mut normals = Vec::new();172
let mut joints = Vec::new();173
let mut weights = Vec::new();174
let mut indices = Vec::new();175
let mut base_color = [0.7, 0.7, 0.75, 1.0];176
if let Some(mesh) = document.meshes().next() {177
for primitive in mesh.primitives() {178
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));179
let prim_positions: Vec<[f32; 3]> = reader180
.read_positions()181
.map(|iter| iter.collect())182
.unwrap_or_default();183
let prim_normals: Vec<[f32; 3]> = reader184
.read_normals()185
.map(|iter| iter.collect())186
.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);187
let prim_joints: Vec<[u16; 4]> = reader188
.read_joints(0)189
.map(|iter| iter.into_u16().collect())190
.unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);191
let prim_weights: Vec<[f32; 4]> = reader192
.read_weights(0)193
.map(|iter| iter.into_f32().collect())194
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);195
let base = positions.len() as u32;196
if let Some(read_indices) = reader.read_indices() {197
indices.extend(read_indices.into_u32().map(|index| index + base));198
} else {199
indices.extend(base..base + prim_positions.len() as u32);200
}201
positions.extend(prim_positions);202
normals.extend(prim_normals);203
joints.extend(prim_joints);204
weights.extend(prim_weights);205
base_color = primitive206
.material()207
.pbr_metallic_roughness()208
.base_color_factor();209
}210
}211
if positions.is_empty() || indices.len() < 3 {212
return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());213
}214
let mut clips = Vec::new();215
for animation in document.animations() {216
let mut channels = Vec::new();217
let mut duration = 0.0_f32;218
for channel in animation.channels() {219
let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));220
let times: Vec<f32> = reader221
.read_inputs()222
.map(|iter| iter.collect())223
.unwrap_or_default();224
if let Some(last) = times.last() {225
duration = duration.max(*last);226
}227
let Some(outputs) = reader.read_outputs() else {228
continue;229
};230
let (property, values) = match outputs {231
gltf::animation::util::ReadOutputs::Translations(values) => {232
(ChannelProperty::Translation, values.flatten().collect())233
}234
gltf::animation::util::ReadOutputs::Scales(values) => {235
(ChannelProperty::Scale, values.flatten().collect())236
}237
gltf::animation::util::ReadOutputs::Rotations(values) => (238
ChannelProperty::Rotation,239
values.into_f32().flatten().collect(),240
),241
gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,242
};243
channels.push(AnimChannel {244
node: channel.target().node().index(),245
property,246
times,247
values,248
});249
}250
clips.push(PreviewClip {251
name: animation.name().unwrap_or("naamloos").to_string(),252
duration,253
channels,254
});255
}256
Ok(PreviewCharacter {257
nodes,258
joint_nodes,259
joint_names,260
inverse_binds,261
positions,262
normals,263
joints,264
weights,265
indices,266
base_color,267
clips,268
})269
}270
271
pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {272
let locals = sample_local_matrices(character, clip_name, time);273
let worlds = world_matrices(&character.nodes, &locals);274
character275
.joint_nodes276
.iter()277
.enumerate()278
.map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))279
.collect()280
}281
282
pub fn joint_world_translation(283
character: &PreviewCharacter,284
clip_name: &str,285
time: f32,286
joint_name: &str,287
) -> Option<[f32; 3]> {288
let joint = character289
.joint_names290
.iter()291
.position(|name| name == joint_name)?;292
let node = character.joint_nodes[joint];293
let locals = sample_local_matrices(character, clip_name, time);294
let worlds = world_matrices(&character.nodes, &locals);295
Some([worlds[node][12], worlds[node][13], worlds[node][14]])296
}297
298
pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {299
character300
.clips301
.iter()302
.find(|clip| {303
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())304
})305
.map(|clip| clip.duration)306
.unwrap_or(0.0)307
}308
309
pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {310
let yaw = yaw_deg.to_radians();311
let pitch = pitch_deg.to_radians();312
[313
-distance * pitch.cos() * yaw.sin(),314
distance * pitch.sin(),315
-distance * pitch.cos() * yaw.cos(),316
]317
}318
319
pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {320
let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];321
for (index, yaw) in yaws.iter_mut().enumerate() {322
*yaw = index as f32 * 30.0;323
}324
yaws325
}326
327
fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {328
let mut translation: Vec<[f32; 3]> = character329
.nodes330
.iter()331
.map(|node| node.translation)332
.collect();333
let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();334
let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();335
if let Some(clip) = character.clips.iter().find(|clip| {336
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())337
}) {338
let time = wrap_time(time, clip.duration);339
for channel in &clip.channels {340
if channel.node >= character.nodes.len() || channel.times.is_empty() {341
continue;342
}343
let (left, right, blend) = key_blend(&channel.times, time);344
match channel.property {345
ChannelProperty::Translation => {346
translation[channel.node] = lerp3(347
key3(&channel.values, left),348
key3(&channel.values, right),349
blend,350
);351
}352
ChannelProperty::Scale => {353
scale[channel.node] = lerp3(354
key3(&channel.values, left),355
key3(&channel.values, right),356
blend,357
);358
}359
ChannelProperty::Rotation => {360
rotation[channel.node] = slerp(361
key4(&channel.values, left),362
key4(&channel.values, right),363
blend,364
);365
}366
}367
}368
}369
translation370
.into_iter()371
.zip(rotation)372
.zip(scale)373
.map(|((translation, rotation), scale)| trs(translation, rotation, scale))374
.collect()375
}376
377
fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {378
fn world(379
index: usize,380
nodes: &[SceneNode],381
locals: &[Mat4],382
worlds: &mut [Option<Mat4>],383
depth: usize,384
) -> Mat4 {385
if let Some(matrix) = worlds[index] {386
return matrix;387
}388
if depth > nodes.len() {389
return locals[index];390
}391
let matrix = match nodes[index].parent {392
Some(parent) => mul(393
world(parent, nodes, locals, worlds, depth + 1),394
locals[index],395
),396
None => locals[index],397
};398
worlds[index] = Some(matrix);399
matrix400
}401
let mut cached = vec![None; nodes.len()];402
(0..nodes.len())403
.map(|index| world(index, nodes, locals, &mut cached, 0))404
.collect()405
}406
407
fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {408
if times.len() == 1 || time <= times[0] {409
return (0, 0, 0.0);410
}411
if time >= times[times.len() - 1] {412
let last = times.len() - 1;413
return (last, last, 0.0);414
}415
let mut index = 0;416
while index + 1 < times.len() && times[index + 1] < time {417
index += 1;418
}419
let span = times[index + 1] - times[index];420
let blend = if span.abs() < 1e-6 {421
0.0422
} else {423
(time - times[index]) / span424
};425
(index, index + 1, blend)426
}427
428
fn key3(values: &[f32], index: usize) -> [f32; 3] {429
let start = index * 3;430
[431
values.get(start).copied().unwrap_or(0.0),432
values.get(start + 1).copied().unwrap_or(0.0),433
values.get(start + 2).copied().unwrap_or(0.0),434
]435
}436
437
fn key4(values: &[f32], index: usize) -> [f32; 4] {438
let start = index * 4;439
[440
values.get(start).copied().unwrap_or(0.0),441
values.get(start + 1).copied().unwrap_or(0.0),442
values.get(start + 2).copied().unwrap_or(0.0),443
values.get(start + 3).copied().unwrap_or(1.0),444
]445
}446
447
fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {448
[449
a[0] + (b[0] - a[0]) * t,450
a[1] + (b[1] - a[1]) * t,451
a[2] + (b[2] - a[2]) * t,452
]453
}454
455
fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {456
let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];457
if dot < 0.0 {458
dot = -dot;459
b = [-b[0], -b[1], -b[2], -b[3]];460
}461
if dot > 0.9995 {462
return normalize4([463
a[0] + (b[0] - a[0]) * t,464
a[1] + (b[1] - a[1]) * t,465
a[2] + (b[2] - a[2]) * t,466
a[3] + (b[3] - a[3]) * t,467
]);468
}469
let theta = dot.clamp(-1.0, 1.0).acos();470
let sin_theta = theta.sin();471
let w1 = ((1.0 - t) * theta).sin() / sin_theta;472
let w2 = (t * theta).sin() / sin_theta;473
[474
a[0] * w1 + b[0] * w2,475
a[1] * w1 + b[1] * w2,476
a[2] * w1 + b[2] * w2,477
a[3] * w1 + b[3] * w2,478
]479
}480
481
fn normalize4(value: [f32; 4]) -> [f32; 4] {482
let length =483
(value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])484
.sqrt();485
if length < 1e-8 {486
[0.0, 0.0, 0.0, 1.0]487
} else {488
[489
value[0] / length,490
value[1] / length,491
value[2] / length,492
value[3] / length,493
]494
}495
}496
497
fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {498
let mut matrix = quat_to_mat(normalize4(rotation));499
matrix[0] *= scale[0];500
matrix[1] *= scale[0];501
matrix[2] *= scale[0];502
matrix[4] *= scale[1];503
matrix[5] *= scale[1];504
matrix[6] *= scale[1];505
matrix[8] *= scale[2];506
matrix[9] *= scale[2];507
matrix[10] *= scale[2];508
matrix[12] = translation[0];509
matrix[13] = translation[1];510
matrix[14] = translation[2];511
matrix512
}513
514
fn quat_to_mat(q: [f32; 4]) -> Mat4 {515
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);516
let xx = x * x;517
let yy = y * y;518
let zz = z * z;519
let xy = x * y;520
let xz = x * z;521
let yz = y * z;522
let wx = w * x;523
let wy = w * y;524
let wz = w * z;525
[526
1.0 - 2.0 * (yy + zz),527
2.0 * (xy + wz),528
2.0 * (xz - wy),529
0.0,530
2.0 * (xy - wz),531
1.0 - 2.0 * (xx + zz),532
2.0 * (yz + wx),533
0.0,534
2.0 * (xz + wy),535
2.0 * (yz - wx),536
1.0 - 2.0 * (xx + yy),537
0.0,538
0.0,539
0.0,540
0.0,541
1.0,542
]543
}544
545
pub fn identity() -> Mat4 {546
[547
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,548
]549
}550
551
pub fn mul(left: Mat4, right: Mat4) -> Mat4 {552
let mut out = [0.0; 16];553
for column in 0..4 {554
for row in 0..4 {555
out[column * 4 + row] = left[row] * right[column * 4]556
+ left[4 + row] * right[column * 4 + 1]557
+ left[8 + row] * right[column * 4 + 2]558
+ left[12 + row] * right[column * 4 + 3];559
}560
}561
out562
}563
564
fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {565
let mut matrix = [0.0; 16];566
for column in 0..4 {567
for row in 0..4 {568
matrix[column * 4 + row] = columns[column][row];569
}570
}571
matrix572
}573
574
pub fn wrap_time(time: f32, duration: f32) -> f32 {575
if duration <= 1e-5 {576
return 0.0;577
}578
let mut wrapped = time % duration;579
if wrapped < 0.0 {580
wrapped += duration;581
}582
wrapped583
}584
585
fn wrap_deg(mut degrees: f32) -> f32 {586
degrees %= 360.0;587
if degrees < 0.0 {588
degrees += 360.0;589
}590
degrees591
}592
593
pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {594
let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);595
let up = [0.0, 1.0, 0.0];596
let mut side = normalize3(cross(forward, up));597
if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {598
side = [1.0, 0.0, 0.0];599
}600
let up = cross(side, forward);601
[602
side[0],603
up[0],604
-forward[0],605
0.0,606
side[1],607
up[1],608
-forward[1],609
0.0,610
side[2],611
up[2],612
-forward[2],613
0.0,614
-dot(side, eye),615
-dot(up, eye),616
dot(forward, eye),617
1.0,618
]619
}620
621
pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {622
let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();623
let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };624
let nf = 1.0 / (near - far);625
[626
f / aspect,627
0.0,628
0.0,629
0.0,630
0.0,631
f,632
0.0,633
0.0,634
0.0,635
0.0,636
(far + near) * nf,637
-1.0,638
0.0,639
0.0,640
2.0 * far * near * nf,641
0.0,642
]643
}644
645
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {646
[647
a[1] * b[2] - a[2] * b[1],648
a[2] * b[0] - a[0] * b[2],649
a[0] * b[1] - a[1] * b[0],650
]651
}652
653
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {654
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]655
}656
657
fn normalize3(value: [f32; 3]) -> [f32; 3] {658
let length = dot(value, value).sqrt();659
if length < 1e-8 {660
[0.0, 0.0, 1.0]661
} else {662
[value[0] / length, value[1] / length, value[2] / length]663
}664
}665
666
#[cfg(test)]667
mod tests {668
use super::*;669
670
fn male() -> PreviewCharacter {671
load_gltf_file(672
&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),673
)674
.unwrap()675
}676
677
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {678
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];679
dot(d, d).sqrt()680
}681
682
#[test]683
fn walk_and_attack_move_the_matching_bones() {684
let character = male();685
assert!(character.clips.iter().any(|clip| clip.name == "walk"));686
assert!(character.clips.iter().any(|clip| clip.name == "attack"));687
assert_eq!(character.joint_names.len(), 19);688
assert!(character.indices.len() >= 3);689
assert!(character.positions.len() >= 19 * 8);690
let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();691
let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();692
assert!(693
distance(leg_a, leg_b) > 0.05,694
"walk should move the lower leg, delta {}",695
distance(leg_a, leg_b)696
);697
let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();698
let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();699
assert!(700
distance(arm_a, arm_b) > 0.05,701
"attack should move the right hand, delta {}",702
distance(arm_a, arm_b)703
);704
let matrices = sample_skin_matrices(&character, "walk", 0.2);705
assert_eq!(matrices.len(), 19);706
assert!(matrices.iter().flatten().all(|value| value.is_finite()));707
}708
709
#[test]710
fn female_fixture_has_the_same_clip_names() {711
let character = load_gltf_file(712
&Path::new(env!("CARGO_MANIFEST_DIR"))713
.join("tests/fixtures/bodies/test-body-female.glb"),714
)715
.unwrap();716
assert!(character.clips.iter().any(|clip| clip.name == "walk"));717
assert!(character.clips.iter().any(|clip| clip.name == "attack"));718
let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();719
let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();720
assert!(distance(a, b) > 0.05);721
}722
723
#[test]724
fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {725
let front = eye_offset(0.0, 0.0, 4.0);726
assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");727
assert!(front[0].abs() < 0.05);728
let mut seen = Vec::new();729
for yaw in paperdoll_yaws_deg() {730
let eye = eye_offset(yaw, 0.0, 4.0);731
assert!(seen732
.iter()733
.all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));734
seen.push(eye);735
}736
assert_eq!(seen.len(), 12);737
let low = eye_offset(0.0, -30.0, 4.0);738
let high = eye_offset(0.0, 30.0, 4.0);739
assert!(low[1] < 0.0 && high[1] > 0.0);740
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);741
}742
743
#[test]744
fn controls_snap_wrap_and_keep_a_clip_change() {745
let mut controls = PreviewControls::default();746
controls.drag(-100.0, 40.0);747
assert!(controls.yaw_deg > 40.0);748
assert!(controls.pitch_deg > 10.0);749
controls.zoom(400.0);750
assert!(controls.distance < 3.4);751
controls.snap_yaw_index(-1);752
assert!((controls.yaw_deg - 330.0).abs() < 0.01);753
controls.snap_pitch_row(99);754
assert!((controls.pitch_deg - 30.0).abs() < 0.01);755
controls.snap_pitch_row(-4);756
assert!((controls.pitch_deg + 30.0).abs() < 0.01);757
controls.time = 0.4;758
controls.select_clip("attack");759
assert_eq!(controls.clip_name, "attack");760
assert_eq!(controls.time, 0.0);761
controls.time = 1.2;762
controls.advance(0.0, 1.0);763
controls.playing = true;764
controls.advance(0.3, 1.0);765
assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);766
}767
}@@ -0,0 +1,398 @@
1
//! OpenGL viewport for one skinned glTF body.
2
//!
3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4
//! diffuse/depth/normal export.
5
6
use std::sync::{Arc, Mutex};
7
8
use eframe::egui;
9
use egui_glow::CallbackFn;
10
use glow::HasContext;
11
12
use crate::preview::{
13
clip_duration, eye_offset, mul, perspective, sample_skin_matrices, view_matrix,
14
PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,
15
};
16
17
const MAX_BONES: usize = crate::preview::MAX_JOINTS;
18
19
pub struct Viewport {
20
cache: Arc<Mutex<GpuCache>>,
21
}
22
23
impl Default for Viewport {
24
fn default() -> Self {
25
Self::new()
26
}
27
}
28
29
impl Viewport {
30
pub fn new() -> Self {
31
Self {
32
cache: Arc::new(Mutex::new(GpuCache::default())),
33
}
34
}
35
36
pub fn show(
37
&self,
38
ui: &mut egui::Ui,
39
controls: &mut PreviewControls,
40
character: Option<&PreviewCharacter>,
41
) {
42
ui.horizontal_wrapped(|ui| {
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
}
61
}
62
if ui
63
.selectable_label(
64
controls.playing,
65
if controls.playing { "Pauze" } else { "Speel" },
66
)
67
.clicked()
68
{
69
controls.playing = !controls.playing;
70
}
71
if ui.button("Yaw −").clicked() {
72
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);
73
}
74
if ui.button("Yaw +").clicked() {
75
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);
76
}
77
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {
78
if ui.button(format!("pitch {pitch:.0}")).clicked() {
79
controls.snap_pitch_row(index as i32);
80
}
81
}
82
ui.label(format!(
83
"yaw {:.0}° / {} pitch {:.0}°",
84
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg
85
));
86
});
87
ui.label(
88
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",
89
);
90
let available = ui.available_rect_before_wrap();
91
let rect = egui::Rect::from_min_size(
92
available.min,
93
egui::vec2(available.width().max(240.0), available.height().max(240.0)),
94
);
95
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
96
if response.dragged() {
97
controls.drag(response.drag_delta().x, response.drag_delta().y);
98
}
99
let scroll = ui.input(|input| input.smooth_scroll_delta.y);
100
if response.hovered() && scroll.abs() > 0.0 {
101
controls.zoom(scroll);
102
}
103
let Some(character) = character else {
104
ui.painter()
105
.rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28));
106
ui.painter().text(
107
rect.center(),
108
egui::Align2::CENTER_CENTER,
109
"Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",
110
egui::FontId::proportional(14.0),
111
egui::Color32::from_rgb(210, 210, 210),
112
);
113
return;
114
};
115
let duration = clip_duration(character, &controls.clip_name);
116
let dt = ui.input(|input| input.stable_dt);
117
controls.advance(dt, duration);
118
ui.ctx().request_repaint();
119
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
120
let aspect = rect.width() / rect.height().max(1.0);
121
let target = [0.0, 0.9, 0.0];
122
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
123
let eye = [
124
target[0] + offset[0],
125
target[1] + offset[1],
126
target[2] + offset[2],
127
];
128
let view_proj = mul(
129
perspective(38.0, aspect, 0.05, 40.0),
130
view_matrix(eye, target),
131
);
132
{
133
let mut cache = self.cache.lock().expect("preview gpu lock");
134
cache.pending = Some(PendingFrame {
135
generation: character_generation(character),
136
positions: character.positions.clone(),
137
normals: character.normals.clone(),
138
joints: character.joints.clone(),
139
weights: character.weights.clone(),
140
indices: character.indices.clone(),
141
bones,
142
view_proj,
143
color: character.base_color,
144
});
145
}
146
let cache = Arc::clone(&self.cache);
147
let callback = CallbackFn::new(move |info, painter| {
148
if let Ok(mut cache) = cache.lock() {
149
unsafe { cache.paint(painter.gl(), &info) };
150
}
151
});
152
ui.painter().add(egui::PaintCallback {
153
rect,
154
callback: Arc::new(callback),
155
});
156
}
157
}
158
159
fn character_generation(character: &PreviewCharacter) -> u64 {
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()
174
}
175
176
#[derive(Clone)]
177
struct PendingFrame {
178
generation: u64,
179
positions: Vec<[f32; 3]>,
180
normals: Vec<[f32; 3]>,
181
joints: Vec<[u16; 4]>,
182
weights: Vec<[f32; 4]>,
183
indices: Vec<u32>,
184
bones: Vec<crate::preview::Mat4>,
185
view_proj: crate::preview::Mat4,
186
color: [f32; 4],
187
}
188
189
#[derive(Default)]
190
struct GpuCache {
191
program: Option<glow::Program>,
192
vao: Option<glow::VertexArray>,
193
vbo: Option<glow::Buffer>,
194
ebo: Option<glow::Buffer>,
195
uploaded: u64,
196
index_count: i32,
197
pending: Option<PendingFrame>,
198
error: Option<String>,
199
}
200
201
impl GpuCache {
202
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {
203
let Some(frame) = self.pending.clone() else {
204
return;
205
};
206
if let Err(error) = self.ensure_program(gl) {
207
self.error = Some(error);
208
return;
209
}
210
if self.uploaded != frame.generation {
211
if let Err(error) = self.upload(gl, &frame) {
212
self.error = Some(error);
213
return;
214
}
215
}
216
let Some(program) = self.program else {
217
return;
218
};
219
let viewport = info.viewport_in_pixels();
220
if viewport.width_px <= 0 || viewport.height_px <= 0 {
221
return;
222
}
223
gl.enable(glow::SCISSOR_TEST);
224
gl.scissor(
225
viewport.left_px,
226
viewport.from_bottom_px,
227
viewport.width_px,
228
viewport.height_px,
229
);
230
gl.viewport(
231
viewport.left_px,
232
viewport.from_bottom_px,
233
viewport.width_px,
234
viewport.height_px,
235
);
236
gl.enable(glow::DEPTH_TEST);
237
gl.depth_func(glow::LESS);
238
gl.disable(glow::BLEND);
239
gl.clear_color(0.08, 0.09, 0.12, 1.0);
240
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
241
gl.use_program(Some(program));
242
let view = gl.get_uniform_location(program, "u_view_proj");
243
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
244
let color = gl.get_uniform_location(program, "u_color");
245
gl.uniform_4_f32(
246
color.as_ref(),
247
frame.color[0],
248
frame.color[1],
249
frame.color[2],
250
frame.color[3],
251
);
252
let mut bones = vec![crate::preview::identity(); MAX_BONES];
253
for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {
254
bones[index] = *bone;
255
}
256
let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();
257
let bones_loc = gl.get_uniform_location(program, "u_bones");
258
gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
259
gl.bind_vertex_array(self.vao);
260
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
261
gl.bind_vertex_array(None);
262
gl.disable(glow::DEPTH_TEST);
263
gl.disable(glow::SCISSOR_TEST);
264
gl.enable(glow::BLEND);
265
}
266
267
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
268
if self.program.is_some() {
269
return Ok(());
270
}
271
let program = link(gl, VERTEX, FRAGMENT)?;
272
let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;
273
let vbo = gl.create_buffer().map_err(|err| err.to_string())?;
274
let ebo = gl.create_buffer().map_err(|err| err.to_string())?;
275
self.program = Some(program);
276
self.vao = Some(vao);
277
self.vbo = Some(vbo);
278
self.ebo = Some(ebo);
279
Ok(())
280
}
281
282
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
283
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);
284
for index in 0..frame.positions.len() {
285
let position = frame.positions[index];
286
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
287
let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);
288
let weight = frame
289
.weights
290
.get(index)
291
.copied()
292
.unwrap_or([1.0, 0.0, 0.0, 0.0]);
293
interleaved.extend_from_slice(&position);
294
interleaved.extend_from_slice(&normal);
295
interleaved.extend([
296
joint[0] as f32,
297
joint[1] as f32,
298
joint[2] as f32,
299
joint[3] as f32,
300
]);
301
interleaved.extend_from_slice(&weight);
302
}
303
gl.bind_vertex_array(self.vao);
304
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
305
gl.buffer_data_u8_slice(
306
glow::ARRAY_BUFFER,
307
bytemuck_f32(&interleaved),
308
glow::DYNAMIC_DRAW,
309
);
310
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
311
gl.buffer_data_u8_slice(
312
glow::ELEMENT_ARRAY_BUFFER,
313
bytemuck_u32(&frame.indices),
314
glow::DYNAMIC_DRAW,
315
);
316
let stride = 14 * 4;
317
enable_float(gl, 0, 3, stride, 0);
318
enable_float(gl, 1, 3, stride, 3 * 4);
319
enable_float(gl, 2, 4, stride, 6 * 4);
320
enable_float(gl, 3, 4, stride, 10 * 4);
321
self.uploaded = frame.generation;
322
self.index_count = frame.indices.len() as i32;
323
Ok(())
324
}
325
}
326
327
unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
328
gl.enable_vertex_attrib_array(index);
329
gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
330
}
331
332
fn bytemuck_f32(values: &[f32]) -> &[u8] {
333
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
334
}
335
336
fn bytemuck_u32(values: &[u32]) -> &[u8] {
337
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
338
}
339
340
unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {
341
let program = gl.create_program().map_err(|err| err.to_string())?;
342
let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;
343
let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;
344
gl.attach_shader(program, vs);
345
gl.attach_shader(program, fs);
346
gl.link_program(program);
347
if !gl.get_program_link_status(program) {
348
return Err(gl.get_program_info_log(program));
349
}
350
gl.delete_shader(vs);
351
gl.delete_shader(fs);
352
Ok(program)
353
}
354
355
unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {
356
let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;
357
gl.shader_source(shader, source);
358
gl.compile_shader(shader);
359
if !gl.get_shader_compile_status(shader) {
360
return Err(gl.get_shader_info_log(shader));
361
}
362
Ok(shader)
363
}
364
365
const VERTEX: &str = r#"#version 330
366
layout(location = 0) in vec3 a_pos;
367
layout(location = 1) in vec3 a_normal;
368
layout(location = 2) in vec4 a_joints;
369
layout(location = 3) in vec4 a_weights;
370
uniform mat4 u_view_proj;
371
uniform mat4 u_bones[128];
372
out vec3 v_normal;
373
void main() {
374
int j0 = int(a_joints.x);
375
int j1 = int(a_joints.y);
376
int j2 = int(a_joints.z);
377
int j3 = int(a_joints.w);
378
mat4 skin = a_weights.x * u_bones[j0]
379
+ a_weights.y * u_bones[j1]
380
+ a_weights.z * u_bones[j2]
381
+ a_weights.w * u_bones[j3];
382
vec4 world = skin * vec4(a_pos, 1.0);
383
v_normal = mat3(skin) * a_normal;
384
gl_Position = u_view_proj * world;
385
}
386
"#;
387
388
const FRAGMENT: &str = r#"#version 330
389
in vec3 v_normal;
390
uniform vec4 u_color;
391
out vec4 frag;
392
void main() {
393
vec3 n = normalize(v_normal);
394
vec3 light = normalize(vec3(0.35, 0.85, 0.4));
395
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
396
frag = vec4(u_color.rgb * shade, 1.0);
397
}
398
"#;
@@ -0,0 +1,398 @@1
//! OpenGL viewport for one skinned glTF body.2
//!3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll4
//! diffuse/depth/normal export.5
6
use std::sync::{Arc, Mutex};7
8
use eframe::egui;9
use egui_glow::CallbackFn;10
use glow::HasContext;11
12
use crate::preview::{13
clip_duration, eye_offset, mul, perspective, sample_skin_matrices, view_matrix,14
PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,15
};16
17
const MAX_BONES: usize = crate::preview::MAX_JOINTS;18
19
pub struct Viewport {20
cache: Arc<Mutex<GpuCache>>,21
}22
23
impl Default for Viewport {24
fn default() -> Self {25
Self::new()26
}27
}28
29
impl Viewport {30
pub fn new() -> Self {31
Self {32
cache: Arc::new(Mutex::new(GpuCache::default())),33
}34
}35
36
pub fn show(37
&self,38
ui: &mut egui::Ui,39
controls: &mut PreviewControls,40
character: Option<&PreviewCharacter>,41
) {42
ui.horizontal_wrapped(|ui| {43
if let Some(character) = character {44
if !character45
.clips46
.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 ui55
.selectable_label(controls.clip_name == clip.name, &clip.name)56
.clicked()57
{58
controls.select_clip(&clip.name);59
}60
}61
}62
if ui63
.selectable_label(64
controls.playing,65
if controls.playing { "Pauze" } else { "Speel" },66
)67
.clicked()68
{69
controls.playing = !controls.playing;70
}71
if ui.button("Yaw −").clicked() {72
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);73
}74
if ui.button("Yaw +").clicked() {75
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);76
}77
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {78
if ui.button(format!("pitch {pitch:.0}")).clicked() {79
controls.snap_pitch_row(index as i32);80
}81
}82
ui.label(format!(83
"yaw {:.0}° / {} pitch {:.0}°",84
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg85
));86
});87
ui.label(88
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",89
);90
let available = ui.available_rect_before_wrap();91
let rect = egui::Rect::from_min_size(92
available.min,93
egui::vec2(available.width().max(240.0), available.height().max(240.0)),94
);95
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());96
if response.dragged() {97
controls.drag(response.drag_delta().x, response.drag_delta().y);98
}99
let scroll = ui.input(|input| input.smooth_scroll_delta.y);100
if response.hovered() && scroll.abs() > 0.0 {101
controls.zoom(scroll);102
}103
let Some(character) = character else {104
ui.painter()105
.rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28));106
ui.painter().text(107
rect.center(),108
egui::Align2::CENTER_CENTER,109
"Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",110
egui::FontId::proportional(14.0),111
egui::Color32::from_rgb(210, 210, 210),112
);113
return;114
};115
let duration = clip_duration(character, &controls.clip_name);116
let dt = ui.input(|input| input.stable_dt);117
controls.advance(dt, duration);118
ui.ctx().request_repaint();119
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);120
let aspect = rect.width() / rect.height().max(1.0);121
let target = [0.0, 0.9, 0.0];122
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);123
let eye = [124
target[0] + offset[0],125
target[1] + offset[1],126
target[2] + offset[2],127
];128
let view_proj = mul(129
perspective(38.0, aspect, 0.05, 40.0),130
view_matrix(eye, target),131
);132
{133
let mut cache = self.cache.lock().expect("preview gpu lock");134
cache.pending = Some(PendingFrame {135
generation: character_generation(character),136
positions: character.positions.clone(),137
normals: character.normals.clone(),138
joints: character.joints.clone(),139
weights: character.weights.clone(),140
indices: character.indices.clone(),141
bones,142
view_proj,143
color: character.base_color,144
});145
}146
let cache = Arc::clone(&self.cache);147
let callback = CallbackFn::new(move |info, painter| {148
if let Ok(mut cache) = cache.lock() {149
unsafe { cache.paint(painter.gl(), &info) };150
}151
});152
ui.painter().add(egui::PaintCallback {153
rect,154
callback: Arc::new(callback),155
});156
}157
}158
159
fn character_generation(character: &PreviewCharacter) -> u64 {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()174
}175
176
#[derive(Clone)]177
struct PendingFrame {178
generation: u64,179
positions: Vec<[f32; 3]>,180
normals: Vec<[f32; 3]>,181
joints: Vec<[u16; 4]>,182
weights: Vec<[f32; 4]>,183
indices: Vec<u32>,184
bones: Vec<crate::preview::Mat4>,185
view_proj: crate::preview::Mat4,186
color: [f32; 4],187
}188
189
#[derive(Default)]190
struct GpuCache {191
program: Option<glow::Program>,192
vao: Option<glow::VertexArray>,193
vbo: Option<glow::Buffer>,194
ebo: Option<glow::Buffer>,195
uploaded: u64,196
index_count: i32,197
pending: Option<PendingFrame>,198
error: Option<String>,199
}200
201
impl GpuCache {202
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {203
let Some(frame) = self.pending.clone() else {204
return;205
};206
if let Err(error) = self.ensure_program(gl) {207
self.error = Some(error);208
return;209
}210
if self.uploaded != frame.generation {211
if let Err(error) = self.upload(gl, &frame) {212
self.error = Some(error);213
return;214
}215
}216
let Some(program) = self.program else {217
return;218
};219
let viewport = info.viewport_in_pixels();220
if viewport.width_px <= 0 || viewport.height_px <= 0 {221
return;222
}223
gl.enable(glow::SCISSOR_TEST);224
gl.scissor(225
viewport.left_px,226
viewport.from_bottom_px,227
viewport.width_px,228
viewport.height_px,229
);230
gl.viewport(231
viewport.left_px,232
viewport.from_bottom_px,233
viewport.width_px,234
viewport.height_px,235
);236
gl.enable(glow::DEPTH_TEST);237
gl.depth_func(glow::LESS);238
gl.disable(glow::BLEND);239
gl.clear_color(0.08, 0.09, 0.12, 1.0);240
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);241
gl.use_program(Some(program));242
let view = gl.get_uniform_location(program, "u_view_proj");243
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);244
let color = gl.get_uniform_location(program, "u_color");245
gl.uniform_4_f32(246
color.as_ref(),247
frame.color[0],248
frame.color[1],249
frame.color[2],250
frame.color[3],251
);252
let mut bones = vec![crate::preview::identity(); MAX_BONES];253
for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {254
bones[index] = *bone;255
}256
let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();257
let bones_loc = gl.get_uniform_location(program, "u_bones");258
gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);259
gl.bind_vertex_array(self.vao);260
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);261
gl.bind_vertex_array(None);262
gl.disable(glow::DEPTH_TEST);263
gl.disable(glow::SCISSOR_TEST);264
gl.enable(glow::BLEND);265
}266
267
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {268
if self.program.is_some() {269
return Ok(());270
}271
let program = link(gl, VERTEX, FRAGMENT)?;272
let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;273
let vbo = gl.create_buffer().map_err(|err| err.to_string())?;274
let ebo = gl.create_buffer().map_err(|err| err.to_string())?;275
self.program = Some(program);276
self.vao = Some(vao);277
self.vbo = Some(vbo);278
self.ebo = Some(ebo);279
Ok(())280
}281
282
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {283
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);284
for index in 0..frame.positions.len() {285
let position = frame.positions[index];286
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);287
let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);288
let weight = frame289
.weights290
.get(index)291
.copied()292
.unwrap_or([1.0, 0.0, 0.0, 0.0]);293
interleaved.extend_from_slice(&position);294
interleaved.extend_from_slice(&normal);295
interleaved.extend([296
joint[0] as f32,297
joint[1] as f32,298
joint[2] as f32,299
joint[3] as f32,300
]);301
interleaved.extend_from_slice(&weight);302
}303
gl.bind_vertex_array(self.vao);304
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);305
gl.buffer_data_u8_slice(306
glow::ARRAY_BUFFER,307
bytemuck_f32(&interleaved),308
glow::DYNAMIC_DRAW,309
);310
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);311
gl.buffer_data_u8_slice(312
glow::ELEMENT_ARRAY_BUFFER,313
bytemuck_u32(&frame.indices),314
glow::DYNAMIC_DRAW,315
);316
let stride = 14 * 4;317
enable_float(gl, 0, 3, stride, 0);318
enable_float(gl, 1, 3, stride, 3 * 4);319
enable_float(gl, 2, 4, stride, 6 * 4);320
enable_float(gl, 3, 4, stride, 10 * 4);321
self.uploaded = frame.generation;322
self.index_count = frame.indices.len() as i32;323
Ok(())324
}325
}326
327
unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {328
gl.enable_vertex_attrib_array(index);329
gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);330
}331
332
fn bytemuck_f32(values: &[f32]) -> &[u8] {333
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }334
}335
336
fn bytemuck_u32(values: &[u32]) -> &[u8] {337
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }338
}339
340
unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {341
let program = gl.create_program().map_err(|err| err.to_string())?;342
let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;343
let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;344
gl.attach_shader(program, vs);345
gl.attach_shader(program, fs);346
gl.link_program(program);347
if !gl.get_program_link_status(program) {348
return Err(gl.get_program_info_log(program));349
}350
gl.delete_shader(vs);351
gl.delete_shader(fs);352
Ok(program)353
}354
355
unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {356
let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;357
gl.shader_source(shader, source);358
gl.compile_shader(shader);359
if !gl.get_shader_compile_status(shader) {360
return Err(gl.get_shader_info_log(shader));361
}362
Ok(shader)363
}364
365
const VERTEX: &str = r#"#version 330366
layout(location = 0) in vec3 a_pos;367
layout(location = 1) in vec3 a_normal;368
layout(location = 2) in vec4 a_joints;369
layout(location = 3) in vec4 a_weights;370
uniform mat4 u_view_proj;371
uniform mat4 u_bones[128];372
out vec3 v_normal;373
void main() {374
int j0 = int(a_joints.x);375
int j1 = int(a_joints.y);376
int j2 = int(a_joints.z);377
int j3 = int(a_joints.w);378
mat4 skin = a_weights.x * u_bones[j0]379
+ a_weights.y * u_bones[j1]380
+ a_weights.z * u_bones[j2]381
+ a_weights.w * u_bones[j3];382
vec4 world = skin * vec4(a_pos, 1.0);383
v_normal = mat3(skin) * a_normal;384
gl_Position = u_view_proj * world;385
}386
"#;387
388
const FRAGMENT: &str = r#"#version 330389
in vec3 v_normal;390
uniform vec4 u_color;391
out vec4 frag;392
void main() {393
vec3 n = normalize(v_normal);394
vec3 light = normalize(vec3(0.35, 0.85, 0.4));395
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);396
frag = vec4(u_color.rgb * shade, 1.0);397
}398
"#;@@ -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
}