BlueGit

golden-age paperdoll-renderer

merge: live preview and project editor recovery checkpoint

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
60e63750e022b8fcb7449f6bf32e073d72316b33
Pipeline
Passed
45 files changed +5108 −593
.gitattributes +1 −0
@@ -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 binary
1*.glb binary
2*.fbx binary
2*.fbx binary
3*.png binary
3*.png binary
4*.ico binary
Cargo.lock +650 −14
@@ -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]]
113name = "ashpd"
114version = "0.11.1"
115source = "registry+https://github.com/rust-lang/crates.io-index"
116checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
117dependencies = [
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]]
135name = "async-broadcast"
136version = "0.7.2"
137source = "registry+https://github.com/rust-lang/crates.io-index"
138checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
139dependencies = [
140 "event-listener",
141 "event-listener-strategy",
142 "futures-core",
143 "pin-project-lite",
144]
145
146[[package]]
147name = "async-channel"
148version = "2.5.0"
149source = "registry+https://github.com/rust-lang/crates.io-index"
150checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
151dependencies = [
152 "concurrent-queue",
153 "event-listener-strategy",
154 "futures-core",
155 "pin-project-lite",
156]
157
158[[package]]
159name = "async-executor"
160version = "1.14.1"
161source = "registry+https://github.com/rust-lang/crates.io-index"
162checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37"
163dependencies = [
164 "async-task",
165 "concurrent-queue",
166 "fastrand",
167 "futures-lite",
168 "pin-project-lite",
169 "slab",
170]
171
172[[package]]
173name = "async-fs"
174version = "2.2.0"
175source = "registry+https://github.com/rust-lang/crates.io-index"
176checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
177dependencies = [
178 "async-lock",
179 "blocking",
180 "futures-lite",
181]
182
183[[package]]
184name = "async-io"
185version = "2.6.0"
186source = "registry+https://github.com/rust-lang/crates.io-index"
187checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
188dependencies = [
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]]
202name = "async-lock"
203version = "3.5.0"
204source = "registry+https://github.com/rust-lang/crates.io-index"
205checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f"
206dependencies = [
207 "event-listener",
208 "event-listener-strategy",
209 "pin-project-lite",
210]
211
212[[package]]
213name = "async-net"
214version = "2.0.0"
215source = "registry+https://github.com/rust-lang/crates.io-index"
216checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
217dependencies = [
218 "async-io",
219 "blocking",
220 "futures-lite",
221]
222
223[[package]]
224name = "async-process"
225version = "2.5.0"
226source = "registry+https://github.com/rust-lang/crates.io-index"
227checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
228dependencies = [
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]]
242name = "async-recursion"
243version = "1.2.0"
244source = "registry+https://github.com/rust-lang/crates.io-index"
245checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
246dependencies = [
247 "proc-macro2",
248 "quote",
249 "syn 3.0.9",
250]
251
252[[package]]
253name = "async-signal"
254version = "0.2.14"
255source = "registry+https://github.com/rust-lang/crates.io-index"
256checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
257dependencies = [
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]]
271name = "async-task"
272version = "4.7.1"
273source = "registry+https://github.com/rust-lang/crates.io-index"
274checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
275
276[[package]]
277name = "async-trait"
278version = "0.1.92"
279source = "registry+https://github.com/rust-lang/crates.io-index"
280checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
281dependencies = [
282 "proc-macro2",
283 "quote",
284 "syn 3.0.9",
285]
286
112[[package]]
287[[package]]
113name = "atomic-waker"
288name = "atomic-waker"
114version = "1.1.2"
289version = "1.1.2"
@@ -169,6 +344,28 @@ dependencies = [
169 "objc2 0.5.3",
344 "objc2 0.5.3",
170]
345]
171
346
347[[package]]
348name = "block2"
349version = "0.6.2"
350source = "registry+https://github.com/rust-lang/crates.io-index"
351checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
352dependencies = [
353 "objc2 0.6.5",
354]
355
356[[package]]
357name = "blocking"
358version = "1.7.0"
359source = "registry+https://github.com/rust-lang/crates.io-index"
360checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
361dependencies = [
362 "async-channel",
363 "async-task",
364 "futures-io",
365 "futures-lite",
366 "piper",
367]
368
172[[package]]
369[[package]]
173name = "bumpalo"
370name = "bumpalo"
174version = "3.20.3"
371version = "3.20.3"
@@ -445,6 +642,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
445checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
642checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
446dependencies = [
643dependencies = [
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]]
812name = "endi"
813version = "1.1.1"
814source = "registry+https://github.com/rust-lang/crates.io-index"
815checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
816
817[[package]]
818name = "enumflags2"
819version = "0.7.12"
820source = "registry+https://github.com/rust-lang/crates.io-index"
821checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
822dependencies = [
823 "enumflags2_derive",
824 "serde",
825]
826
827[[package]]
828name = "enumflags2_derive"
829version = "0.7.12"
830source = "registry+https://github.com/rust-lang/crates.io-index"
831checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
832dependencies = [
833 "proc-macro2",
834 "quote",
835 "syn 2.0.119",
836]
837
612[[package]]
838[[package]]
613name = "epaint"
839name = "epaint"
614version = "0.29.1"
840version = "0.29.1"
@@ -654,6 +880,32 @@ version = "3.4.0"
654source = "registry+https://github.com/rust-lang/crates.io-index"
880source = "registry+https://github.com/rust-lang/crates.io-index"
655checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
881checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
656
882
883[[package]]
884name = "event-listener"
885version = "5.4.2"
886source = "registry+https://github.com/rust-lang/crates.io-index"
887checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
888dependencies = [
889 "parking",
890 "pin-project-lite",
891]
892
893[[package]]
894name = "event-listener-strategy"
895version = "0.5.4"
896source = "registry+https://github.com/rust-lang/crates.io-index"
897checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
898dependencies = [
899 "event-listener",
900 "pin-project-lite",
901]
902
903[[package]]
904name = "fastrand"
905version = "2.5.0"
906source = "registry+https://github.com/rust-lang/crates.io-index"
907checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
908
657[[package]]
909[[package]]
658name = "fdeflate"
910name = "fdeflate"
659version = "0.3.7"
911version = "0.3.7"
@@ -722,12 +974,51 @@ dependencies = [
722 "percent-encoding",
974 "percent-encoding",
723]
975]
724
976
977[[package]]
978name = "futures-channel"
979version = "0.3.34"
980source = "registry+https://github.com/rust-lang/crates.io-index"
981checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
982dependencies = [
983 "futures-core",
984]
985
725[[package]]
986[[package]]
726name = "futures-core"
987name = "futures-core"
727version = "0.3.34"
988version = "0.3.34"
728source = "registry+https://github.com/rust-lang/crates.io-index"
989source = "registry+https://github.com/rust-lang/crates.io-index"
729checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
990checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
730
991
992[[package]]
993name = "futures-io"
994version = "0.3.34"
995source = "registry+https://github.com/rust-lang/crates.io-index"
996checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
997
998[[package]]
999name = "futures-lite"
1000version = "2.6.1"
1001source = "registry+https://github.com/rust-lang/crates.io-index"
1002checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
1003dependencies = [
1004 "fastrand",
1005 "futures-core",
1006 "futures-io",
1007 "parking",
1008 "pin-project-lite",
1009]
1010
1011[[package]]
1012name = "futures-macro"
1013version = "0.3.34"
1014source = "registry+https://github.com/rust-lang/crates.io-index"
1015checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1016dependencies = [
1017 "proc-macro2",
1018 "quote",
1019 "syn 3.0.9",
1020]
1021
731[[package]]
1022[[package]]
732name = "futures-task"
1023name = "futures-task"
733version = "0.3.34"
1024version = "0.3.34"
@@ -741,7 +1032,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
741checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
1032checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
742dependencies = [
1033dependencies = [
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"
1007source = "registry+https://github.com/rust-lang/crates.io-index"
1301source = "registry+https://github.com/rust-lang/crates.io-index"
1008checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1302checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1009
1303
1304[[package]]
1305name = "hex"
1306version = "0.4.3"
1307source = "registry+https://github.com/rust-lang/crates.io-index"
1308checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1309
1010[[package]]
1310[[package]]
1011name = "hexf-parse"
1311name = "hexf-parse"
1012version = "0.2.1"
1312version = "0.2.1"
@@ -1539,7 +1839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1539checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
1839checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
1540dependencies = [
1840dependencies = [
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"
1555checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
1855checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
1556dependencies = [
1856dependencies = [
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"
1568checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
1869checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
1569dependencies = [
1870dependencies = [
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"
1580source = "registry+https://github.com/rust-lang/crates.io-index"
1881source = "registry+https://github.com/rust-lang/crates.io-index"
1581checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
1882checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
1582dependencies = [
1883dependencies = [
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"
1592checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
1893checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
1593dependencies = [
1894dependencies = [
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"
1627source = "registry+https://github.com/rust-lang/crates.io-index"
1928source = "registry+https://github.com/rust-lang/crates.io-index"
1628checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
1929checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
1629dependencies = [
1930dependencies = [
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"
1639source = "registry+https://github.com/rust-lang/crates.io-index"
1940source = "registry+https://github.com/rust-lang/crates.io-index"
1640checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
1941checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
1641dependencies = [
1942dependencies = [
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"
1658checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
1959checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
1659dependencies = [
1960dependencies = [
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"
1692source = "registry+https://github.com/rust-lang/crates.io-index"
1993source = "registry+https://github.com/rust-lang/crates.io-index"
1693checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
1994checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
1694dependencies = [
1995dependencies = [
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"
1705checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
2006checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
1706dependencies = [
2007dependencies = [
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"
1717checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
2018checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
1718dependencies = [
2019dependencies = [
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"
1740checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
2041checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
1741dependencies = [
2042dependencies = [
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"
1760source = "registry+https://github.com/rust-lang/crates.io-index"
2061source = "registry+https://github.com/rust-lang/crates.io-index"
1761checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
2062checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
1762dependencies = [
2063dependencies = [
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"
1772checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
2073checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
1773dependencies = [
2074dependencies = [
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]]
2099name = "ordered-stream"
2100version = "0.2.0"
2101source = "registry+https://github.com/rust-lang/crates.io-index"
2102checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
2103dependencies = [
2104 "futures-core",
2105 "pin-project-lite",
2106]
2107
1797[[package]]
2108[[package]]
1798name = "owned_ttf_parser"
2109name = "owned_ttf_parser"
1799version = "0.25.1"
2110version = "0.25.1"
@@ -1808,14 +2119,26 @@ name = "paperdoll-renderer"
1808version = "0.1.0"
2119version = "0.1.0"
1809dependencies = [
2120dependencies = [
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]]
2137name = "parking"
2138version = "2.2.1"
2139source = "registry+https://github.com/rust-lang/crates.io-index"
2140checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2141
1819[[package]]
2142[[package]]
1820name = "parking_lot"
2143name = "parking_lot"
1821version = "0.12.5"
2144version = "0.12.5"
@@ -1877,6 +2200,17 @@ version = "0.2.17"
1877source = "registry+https://github.com/rust-lang/crates.io-index"
2200source = "registry+https://github.com/rust-lang/crates.io-index"
1878checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2201checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1879
2202
2203[[package]]
2204name = "piper"
2205version = "0.2.5"
2206source = "registry+https://github.com/rust-lang/crates.io-index"
2207checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
2208dependencies = [
2209 "atomic-waker",
2210 "fastrand",
2211 "futures-io",
2212]
2213
1880[[package]]
2214[[package]]
1881name = "pkg-config"
2215name = "pkg-config"
1882version = "0.3.34"
2216version = "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]]
2254name = "pollster"
2255version = "0.4.0"
2256source = "registry+https://github.com/rust-lang/crates.io-index"
2257checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
2258
1919[[package]]
2259[[package]]
1920name = "potential_utf"
2260name = "potential_utf"
1921version = "0.1.6"
2261version = "0.1.6"
@@ -1925,6 +2265,15 @@ dependencies = [
1925 "zerovec",
2265 "zerovec",
1926]
2266]
1927
2267
2268[[package]]
2269name = "ppv-lite86"
2270version = "0.2.21"
2271source = "registry+https://github.com/rust-lang/crates.io-index"
2272checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2273dependencies = [
2274 "zerocopy",
2275]
2276
1928[[package]]
2277[[package]]
1929name = "presser"
2278name = "presser"
1930version = "0.3.1"
2279version = "0.3.1"
@@ -1991,6 +2340,35 @@ version = "6.0.0"
1991source = "registry+https://github.com/rust-lang/crates.io-index"
2340source = "registry+https://github.com/rust-lang/crates.io-index"
1992checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2341checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1993
2342
2343[[package]]
2344name = "rand"
2345version = "0.9.5"
2346source = "registry+https://github.com/rust-lang/crates.io-index"
2347checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2348dependencies = [
2349 "rand_chacha",
2350 "rand_core",
2351]
2352
2353[[package]]
2354name = "rand_chacha"
2355version = "0.9.0"
2356source = "registry+https://github.com/rust-lang/crates.io-index"
2357checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2358dependencies = [
2359 "ppv-lite86",
2360 "rand_core",
2361]
2362
2363[[package]]
2364name = "rand_core"
2365version = "0.9.5"
2366source = "registry+https://github.com/rust-lang/crates.io-index"
2367checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2368dependencies = [
2369 "getrandom 0.3.4",
2370]
2371
1994[[package]]
2372[[package]]
1995name = "raw-window-handle"
2373name = "raw-window-handle"
1996version = "0.6.2"
2374version = "0.6.2"
@@ -2030,6 +2408,30 @@ version = "1.1.0"
2030source = "registry+https://github.com/rust-lang/crates.io-index"
2408source = "registry+https://github.com/rust-lang/crates.io-index"
2031checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2409checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2032
2410
2411[[package]]
2412name = "rfd"
2413version = "0.15.4"
2414source = "registry+https://github.com/rust-lang/crates.io-index"
2415checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
2416dependencies = [
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]]
2034name = "rustc-hash"
2436name = "rustc-hash"
2035version = "1.1.0"
2437version = "1.1.0"
@@ -2153,12 +2555,42 @@ dependencies = [
2153 "zmij",
2555 "zmij",
2154]
2556]
2155
2557
2558[[package]]
2559name = "serde_repr"
2560version = "0.1.21"
2561source = "registry+https://github.com/rust-lang/crates.io-index"
2562checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
2563dependencies = [
2564 "proc-macro2",
2565 "quote",
2566 "syn 3.0.9",
2567]
2568
2569[[package]]
2570name = "serde_spanned"
2571version = "1.1.3"
2572source = "registry+https://github.com/rust-lang/crates.io-index"
2573checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
2574dependencies = [
2575 "serde_core",
2576]
2577
2156[[package]]
2578[[package]]
2157name = "shlex"
2579name = "shlex"
2158version = "2.0.1"
2580version = "2.0.1"
2159source = "registry+https://github.com/rust-lang/crates.io-index"
2581source = "registry+https://github.com/rust-lang/crates.io-index"
2160checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2582checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2161
2583
2584[[package]]
2585name = "signal-hook-registry"
2586version = "1.4.8"
2587source = "registry+https://github.com/rust-lang/crates.io-index"
2588checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
2589dependencies = [
2590 "errno",
2591 "libc",
2592]
2593
2162[[package]]
2594[[package]]
2163name = "simd-adler32"
2595name = "simd-adler32"
2164version = "0.3.10"
2596version = "0.3.10"
@@ -2339,6 +2771,19 @@ dependencies = [
2339 "syn 3.0.9",
2771 "syn 3.0.9",
2340]
2772]
2341
2773
2774[[package]]
2775name = "tempfile"
2776version = "3.27.0"
2777source = "registry+https://github.com/rust-lang/crates.io-index"
2778checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2779dependencies = [
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]]
2343name = "termcolor"
2788name = "termcolor"
2344version = "1.4.1"
2789version = "1.4.1"
@@ -2416,6 +2861,21 @@ dependencies = [
2416 "pin-project-lite",
2861 "pin-project-lite",
2417]
2862]
2418
2863
2864[[package]]
2865name = "toml"
2866version = "1.2.1+spec-1.1.0"
2867source = "registry+https://github.com/rust-lang/crates.io-index"
2868checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
2869dependencies = [
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]]
2420name = "toml_datetime"
2880name = "toml_datetime"
2421version = "1.1.3+spec-1.1.0"
2881version = "1.1.3+spec-1.1.0"
@@ -2446,6 +2906,12 @@ dependencies = [
2446 "winnow",
2906 "winnow",
2447]
2907]
2448
2908
2909[[package]]
2910name = "toml_writer"
2911version = "1.2.0+spec-1.1.0"
2912source = "registry+https://github.com/rust-lang/crates.io-index"
2913checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
2914
2449[[package]]
2915[[package]]
2450name = "tracing"
2916name = "tracing"
2451version = "0.1.44"
2917version = "0.1.44"
@@ -2454,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2454dependencies = [
2920dependencies = [
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]]
2928name = "tracing-attributes"
2929version = "0.1.31"
2930source = "registry+https://github.com/rust-lang/crates.io-index"
2931checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2932dependencies = [
2933 "proc-macro2",
2934 "quote",
2935 "syn 2.0.119",
2936]
2937
2460[[package]]
2938[[package]]
2461name = "tracing-core"
2939name = "tracing-core"
2462version = "0.1.36"
2940version = "0.1.36"
2463source = "registry+https://github.com/rust-lang/crates.io-index"
2941source = "registry+https://github.com/rust-lang/crates.io-index"
2464checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2942checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2943dependencies = [
2944 "once_cell",
2945]
2465
2946
2466[[package]]
2947[[package]]
2467name = "ttf-parser"
2948name = "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]]
2963name = "uds_windows"
2964version = "1.2.1"
2965source = "registry+https://github.com/rust-lang/crates.io-index"
2966checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
2967dependencies = [
2968 "memoffset",
2969 "tempfile",
2970 "windows-sys 0.61.2",
2971]
2972
2481[[package]]
2973[[package]]
2482name = "ufbx"
2974name = "ufbx"
2483version = "0.11.5"
2975version = "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"
2544source = "registry+https://github.com/rust-lang/crates.io-index"
3037source = "registry+https://github.com/rust-lang/crates.io-index"
2545checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
3038checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
2546
3039
3040[[package]]
3041name = "uuid"
3042version = "1.29.0"
3043source = "registry+https://github.com/rust-lang/crates.io-index"
3044checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
3045dependencies = [
3046 "js-sys",
3047 "serde_core",
3048 "wasm-bindgen",
3049]
3050
2547[[package]]
3051[[package]]
2548name = "version_check"
3052name = "version_check"
2549version = "0.9.5"
3053version = "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]]
3617name = "winreg"
3618version = "0.55.0"
3619source = "registry+https://github.com/rust-lang/crates.io-index"
3620checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
3621dependencies = [
3622 "cfg-if",
3623 "windows-sys 0.59.0",
3624]
3625
3626[[package]]
3627name = "winresource"
3628version = "0.1.34"
3629source = "registry+https://github.com/rust-lang/crates.io-index"
3630checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
3631dependencies = [
3632 "toml",
3633 "version_check",
3634]
3635
3112[[package]]
3636[[package]]
3113name = "wit-bindgen"
3637name = "wit-bindgen"
3114version = "0.57.1"
3638version = "0.57.1"
@@ -3207,6 +3731,76 @@ dependencies = [
3207 "synstructure",
3731 "synstructure",
3208]
3732]
3209
3733
3734[[package]]
3735name = "zbus"
3736version = "5.19.0"
3737source = "registry+https://github.com/rust-lang/crates.io-index"
3738checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
3739dependencies = [
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]]
3770name = "zbus_macros"
3771version = "5.19.0"
3772source = "registry+https://github.com/rust-lang/crates.io-index"
3773checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
3774dependencies = [
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]]
3785name = "zbus_names"
3786version = "4.3.5"
3787source = "registry+https://github.com/rust-lang/crates.io-index"
3788checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
3789dependencies = [
3790 "serde",
3791 "winnow",
3792 "zvariant",
3793]
3794
3795[[package]]
3796name = "zcheapstr"
3797version = "1.2.0"
3798source = "registry+https://github.com/rust-lang/crates.io-index"
3799checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"
3800dependencies = [
3801 "serde",
3802]
3803
3210[[package]]
3804[[package]]
3211name = "zerocopy"
3805name = "zerocopy"
3212version = "0.8.63"
3806version = "0.8.63"
@@ -3307,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3307dependencies = [
3901dependencies = [
3308 "zune-core",
3902 "zune-core",
3309]
3903]
3904
3905[[package]]
3906name = "zvariant"
3907version = "5.15.1"
3908source = "registry+https://github.com/rust-lang/crates.io-index"
3909checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
3910dependencies = [
3911 "endi",
3912 "enumflags2",
3913 "serde",
3914 "url",
3915 "winnow",
3916 "zcheapstr",
3917 "zvariant_derive",
3918 "zvariant_utils",
3919]
3920
3921[[package]]
3922name = "zvariant_derive"
3923version = "5.15.1"
3924source = "registry+https://github.com/rust-lang/crates.io-index"
3925checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
3926dependencies = [
3927 "proc-macro-crate",
3928 "proc-macro2",
3929 "quote",
3930 "syn 3.0.9",
3931 "zvariant_utils",
3932]
3933
3934[[package]]
3935name = "zvariant_utils"
3936version = "4.2.0"
3937source = "registry+https://github.com/rust-lang/crates.io-index"
3938checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
3939dependencies = [
3940 "proc-macro2",
3941 "quote",
3942 "serde",
3943 "syn 3.0.9",
3944 "winnow",
3945]
Cargo.toml +11 −1
@@ -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"
4edition = "2021"
4edition = "2021"
5license = "MIT"
5license = "MIT"
6publish = false
6publish = false
7description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
7description = "Paperdoll Renderer with project files, model import and a live glTF preview."
8
8
9[dependencies]
9[dependencies]
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11egui_glow = "0.29.1"
12glow = "0.14.2"
11gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
13gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
12libloading = "0.8"
14libloading = "0.8"
15png = "0.18"
16rfd = "0.15.4"
13serde = { version = "1", features = ["derive"] }
17serde = { version = "1", features = ["derive"] }
14serde_json = "1"
18serde_json = "1"
15tobj = "4.0.3"
19tobj = "4.0.3"
16ufbx = "0.11.5"
20ufbx = "0.11.5"
21
22[build-dependencies]
23winresource = "0.1.34"
24
25[target."cfg(windows)".dependencies]
26winreg = "0.55"
README.md +31 −27
@@ -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 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
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 Renderer
1# Paperdoll Renderer
2
2
3Native 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).
3Desktopeditor 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`)
7Rust 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 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
13
8
14```bash
9```bash
15cargo test --locked
16cargo run -- --status
17cargo run
10cargo run
11cargo run -- "/pad met spaties/character.ppd"
12cargo run -- --preview-fixture
13cargo 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
18Het 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:
20De 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
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
27python3 scripts/generate_test_bodies.py
23
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![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31
31
32## Wat deze build niet doet
32## Equipment en opslaan
33
33
34OBJ+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.
34Vanuit 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
36Bij 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
38De 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
40Dat 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`.
44Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.
43
45
44Pakketten lokaal, zonder publiceren:
46## Tests
45
47
46```bash
48```bash
47bash scripts/package/deb.sh
49cargo fmt --check
48bash scripts/package/appimage.sh
50cargo check --workspace --all-targets --locked
51cargo clippy --workspace --all-targets --locked -- -D warnings
52cargo test --workspace --locked
49```
53```
50
54
51Op 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.
55De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
assets/icons/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

blue-ci-cd.yml +1 −1
@@ -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 -qq
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 - rustup target add x86_64-pc-windows-gnu
140 - rustup target add x86_64-pc-windows-gnu
141 - export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
141 - export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
142 - export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
142 - export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
build.rs added +13 −0
@@ -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 @@
1fn 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}
design.md +23 −4
@@ -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 f
63
63
64### Import implementation status
64### Import implementation status
65
65
66The 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.
66The 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
68This 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.
68This 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
70FBX 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).
70FBX 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 write
141
141
142UI 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.
142UI 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
144Live 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.
144Live 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
146Binary 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.
146Binary 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
158Each 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.
158Each 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
160After 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
162The 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
164Live 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
160CI 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.
166CI 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
162Tests 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.
168Tests 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
168Verified 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.
174Verified 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
178These 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
187Current 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 gates
189## 12. Incremental feature plan and acceptance gates
171
190
1720. 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.
1910. 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.
docs/screenshots/body-live.png added Binary file +0 −0

The contents of this binary file are not shown.

docs/screenshots/startup.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/arch/PKGBUILD +14 −4
@@ -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 @@
3pkgname=paperdoll-renderer
3pkgname=paperdoll-renderer
4pkgver=0.1.0
4pkgver=0.1.0
5pkgrel=1
5pkgrel=1
6pkgdesc='Foundation shell for the Paperdoll Renderer. Import and render are not included.'
6pkgdesc='Paperdoll Renderer with project files, model import and a live glTF preview.'
7arch=('x86_64')
7arch=('x86_64')
8url='https://git.timfalken.com/golden-age/paperdoll-renderer'
8url='https://git.timfalken.com/golden-age/paperdoll-renderer'
9license=('MIT')
9license=('MIT')
10depends=('gcc-libs' 'glibc' 'libxcb' 'libx11' 'libxkbcommon' 'libglvnd')
10depends=('gcc-libs' 'glibc' 'libxcb' 'libx11' 'libxkbcommon' 'libxkbcommon-x11' 'libglvnd' 'shared-mime-info' 'desktop-file-utils' 'xdg-desktop-portal' 'zenity')
11makedepends=('cargo' 'pkgconf')
11makedepends=('cargo' 'pkgconf')
12options=('!debug' '!lto')
12options=('!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 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 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}
packaging/linux/hicolor/128x128/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/16x16/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/22x22/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/24x24/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/32x32/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/48x48/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/512x512/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/hicolor/64x64/apps/paperdoll-renderer.png added Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/icon-64.png deleted Binary file +0 −0

The contents of this binary file are not shown.

packaging/linux/paperdoll-project.xml added +7 −0
@@ -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>
packaging/linux/paperdoll-renderer.desktop +4 −3
@@ -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]
2Type=Application
2Type=Application
3Name=Paperdoll Renderer
3Name=Paperdoll Renderer
4GenericName=Paperdoll foundation shell
4GenericName=Paperdoll editor
5Comment=Statusvenster van de Paperdoll Renderer-projectbasis. Import en render zijn niet gebouwd.
5Comment=Project, modelimport en een live 3D-preview van een glTF-lichaam.
6Exec=paperdoll-renderer
6Exec=paperdoll-renderer %f
7Icon=paperdoll-renderer
7Icon=paperdoll-renderer
8Terminal=false
8Terminal=false
9Categories=Graphics;
9Categories=Graphics;
10StartupNotify=true
10StartupNotify=true
11MimeType=application/x-paperdoll-project;
packaging/linux/paperdoll-renderer.metainfo.xml added +16 −0
@@ -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>
packaging/windows/paperdoll-renderer.ico added Binary file +0 −0

The contents of this binary file are not shown.

scripts/ci/verify-packages.sh +3 −0
@@ -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" ]]; then
65 echo "Windows zip starts with an ELF header" >&2
65 echo "Windows zip starts with an ELF header" >&2
66 exit 1
66 exit 1
67fi
67fi
68if command -v python3 >/dev/null 2>&1; then
69 python3 scripts/package/pe_has_icon.py "$windows"
70fi
68
71
69sha256sum "$appimage" "$deb" "$arch" "$windows" | tee dist/SHA256SUMS
72sha256sum "$appimage" "$deb" "$arch" "$windows" | tee dist/SHA256SUMS
70echo "package artifacts verified"
73echo "package artifacts verified"
scripts/package/appimage.sh +9 −4
@@ -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 bash
1#!/usr/bin/env bash
2# 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.
4set -euo pipefail
4set -euo pipefail
5
5
@@ -22,16 +22,21 @@ if [[ ! -f "$tool" ]] || [[ "$(sha256sum "$tool" | awk '{print $1}')" != "$expec
22fi
22fi
23
23
24stage="$(mktemp -d)"
24stage="$(mktemp -d)"
25chmod 755 "$stage"
25trap 'rm -rf "$stage"' EXIT
26trap 'rm -rf "$stage"' EXIT
26mkdir -p \
27mkdir -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"
30install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"
31install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"
31install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/paperdoll-renderer.desktop"
32install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/paperdoll-renderer.desktop"
32install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"
33install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"
33install -m 644 "$root/packaging/linux/icon-64.png" "$stage/paperdoll-renderer.png"
34install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.metainfo.xml"
34install -m 644 "$root/packaging/linux/icon-64.png" "$stage/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"
35install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.appdata.xml"
36install -m 644 "$root/packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png" "$stage/paperdoll-renderer.png"
37install -m 644 "$root/packaging/linux/hicolor/256x256/apps/paperdoll-renderer.png" "$stage/.DirIcon"
38install -Dm644 "$root/packaging/linux/paperdoll-project.xml" "$stage/usr/share/mime/packages/paperdoll-project.xml"
39install_hicolor_icons "$root" "$stage/usr/share/icons/hicolor"
35install -m 644 "$root/LICENSE" "$stage/LICENSE"
40install -m 644 "$root/LICENSE" "$stage/LICENSE"
36cat > "$stage/AppRun" << 'EOF'
41cat > "$stage/AppRun" << 'EOF'
37#!/bin/sh
42#!/bin/sh
scripts/package/common.sh +11 −0
@@ -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
11install_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}
scripts/package/deb.sh +23 −7
@@ -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 bash
1#!/usr/bin/env bash
2# Build a Debian/Ubuntu package of the foundation shell.
2# Build a Debian/Ubuntu package.
3set -euo pipefail
3set -euo pipefail
4
4
5root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -9,18 +9,22 @@ version="$(bash "$root/scripts/ci/package-version.sh")"
9build_linux_release "$root"
9build_linux_release "$root"
10
10
11stage="$(mktemp -d)"
11stage="$(mktemp -d)"
12chmod 755 "$stage"
12trap 'rm -rf "$stage"' EXIT
13trap 'rm -rf "$stage"' EXIT
13mkdir -p \
14mkdir -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
21install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"
22install -m 755 "$root/target/release/paperdoll-renderer" "$stage/usr/bin/paperdoll-renderer"
22install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"
23install -m 644 "$root/packaging/linux/paperdoll-renderer.desktop" "$stage/usr/share/applications/paperdoll-renderer.desktop"
23install -m 644 "$root/packaging/linux/icon-64.png" "$stage/usr/share/icons/hicolor/64x64/apps/paperdoll-renderer.png"
24install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.metainfo.xml"
25install -m 644 "$root/packaging/linux/paperdoll-renderer.metainfo.xml" "$stage/usr/share/metainfo/paperdoll-renderer.appdata.xml"
26install -Dm644 "$root/packaging/linux/paperdoll-project.xml" "$stage/usr/share/mime/packages/paperdoll-project.xml"
27install_hicolor_icons "$root" "$stage/usr/share/icons/hicolor"
24install -m 644 "$root/LICENSE" "$stage/usr/share/licenses/paperdoll-renderer/LICENSE"
28install -m 644 "$root/LICENSE" "$stage/usr/share/licenses/paperdoll-renderer/LICENSE"
25install -m 644 "$root/LICENSE" "$stage/usr/share/doc/paperdoll-renderer/copyright"
29install -m 644 "$root/LICENSE" "$stage/usr/share/doc/paperdoll-renderer/copyright"
26
30
@@ -33,12 +37,24 @@ Section: graphics
33Priority: optional
37Priority: optional
34Architecture: amd64
38Architecture: amd64
35Maintainer: Tim Falken
39Maintainer: Tim Falken
36Depends: libc6, libgcc-s1, libstdc++6, libxcb1, libx11-6, libxkbcommon0, libxkbcommon-x11-0, libgl1
40Depends: libc6, libgcc-s1, libstdc++6, libxcb1, libx11-6, libxkbcommon0, libxkbcommon-x11-0, libgl1, shared-mime-info, desktop-file-utils, xdg-desktop-portal, zenity
37Description: Paperdoll Renderer foundation shell
41Description: Paperdoll Renderer
38 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.
40EOF
43EOF
41
44
45cat > "$stage/DEBIAN/postinst" << 'POST'
46#!/bin/sh
47set -e
48update-mime-database /usr/share/mime
49update-desktop-database /usr/share/applications
50POST
51cat > "$stage/DEBIAN/postrm" << 'POST'
52#!/bin/sh
53set -e
54if command -v update-mime-database >/dev/null; then update-mime-database /usr/share/mime; fi
55if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/share/applications; fi
56POST
57chmod 755 "$stage/DEBIAN/postinst" "$stage/DEBIAN/postrm"
42mkdir -p "$root/dist"
58mkdir -p "$root/dist"
43dpkg-deb --root-owner-group --build "$stage" "$root/dist/paperdoll-renderer_${version}_amd64.deb"
59dpkg-deb --root-owner-group --build "$stage" "$root/dist/paperdoll-renderer_${version}_amd64.deb"
44cp -f "$root/dist/paperdoll-renderer_${version}_amd64.deb" "$root/dist/paperdoll-renderer_amd64.deb"
60cp -f "$root/dist/paperdoll-renderer_${version}_amd64.deb" "$root/dist/paperdoll-renderer_amd64.deb"
scripts/package/pe_has_icon.py added +79 −0
@@ -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 python3
2"""Require RT_ICON and RT_GROUP_ICON inside a PE, not a sibling .ico file."""
3
4import struct
5import sys
6import zipfile
7
8RT_ICON = 3
9RT_GROUP_ICON = 14
10
11
12def 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
60def 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
78if __name__ == "__main__":
79 main()
scripts/package/windows.sh +6 −2
@@ -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 bash
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# 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.
5set -euo pipefail
5set -euo pipefail
@@ -31,7 +31,8 @@ stage="$(mktemp -d)"
31trap 'rm -rf "$stage"' EXIT
31trap 'rm -rf "$stage"' EXIT
32install -m 644 "$exe" "$stage/paperdoll-renderer.exe"
32install -m 644 "$exe" "$stage/paperdoll-renderer.exe"
33install -m 644 "$root/LICENSE" "$stage/LICENSE.txt"
33install -m 644 "$root/LICENSE" "$stage/LICENSE.txt"
34printf '%s\n' "Paperdoll Renderer ${version}" "Foundation shell. Import and render are not included." \
34printf '%s\n' "Paperdoll Renderer ${version}" \
35 "Projectbestanden, modelimport en een live glTF-preview." \
35 > "$stage/README.txt"
36 > "$stage/README.txt"
36
37
37mkdir -p "$root/dist"
38mkdir -p "$root/dist"
@@ -44,4 +45,7 @@ rm -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \
44)
45)
45cp -f "$root/dist/paperdoll-renderer-${version}-windows-x86_64.zip" \
46cp -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"
48python3 "$root/scripts/package/pe_has_icon.py" "$exe"
49python3 "$root/scripts/package/pe_has_icon.py" \
50 "$root/dist/paperdoll-renderer-windows-x86_64.zip"
47echo "wrote dist/paperdoll-renderer-windows-x86_64.zip"
51echo "wrote dist/paperdoll-renderer-windows-x86_64.zip"
src/dialogs.rs added +162 −0
@@ -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 backend
4//! that returns before the choice is ready reports [`DialogPoll::Pending`]
5//! and the editor keeps asking.
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DialogPurpose {
11 NewProject,
12 LoadProject,
13 Body(crate::session::Sex),
14 EquipmentModel(usize),
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum DialogPoll {
19 Idle,
20 Pending,
21 Ready {
22 purpose: DialogPurpose,
23 path: Option<PathBuf>,
24 },
25}
26
27pub 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)]
34pub struct ScriptedDialogs {
35 queued: Option<DialogPurpose>,
36 answers: Vec<Option<PathBuf>>,
37}
38
39impl ScriptedDialogs {
40 pub fn push(&mut self, path: Option<PathBuf>) {
41 self.answers.push(path);
42 }
43}
44
45impl 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
63pub struct SystemDialogs {
64 queued: Option<DialogPurpose>,
65 pending: Option<std::sync::mpsc::Receiver<(DialogPurpose, Option<PathBuf>)>>,
66}
67
68impl SystemDialogs {
69 pub fn new() -> Self {
70 Self {
71 queued: None,
72 pending: None,
73 }
74 }
75}
76
77impl Default for SystemDialogs {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl 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
117fn 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
136pub 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)]
145mod 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}
src/editor.rs +692 −461
@@ -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
3use std::path::PathBuf;
3use std::path::PathBuf;
4use std::sync::mpsc::Receiver;
4
5
5use eframe::egui;
6use eframe::egui;
6
7
7use crate::import::{import_project_model, ImportedModel, ModelFormat};
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8use crate::project::{
9use crate::import::{import_project_model, ImportedModel};
9 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
11use crate::preview_gl::Viewport;
11};
12use crate::project::open_project;
13use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
14use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
12
15
13pub struct EditorApp {
16pub 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
33impl EditorApp {
34impl 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 = self
107 if let Some(opened) = &self.session.opened {
103 .opened
108 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) = self
226
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 opened
257 self.session.fail(error.to_string());
219 .manifest_path
258 }
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.session
277 .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 = root
245 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
348impl Default for EditorApp {
349 fn default() -> Self {
350 Self::new(Box::new(SystemDialogs::new()))
351 }
352}
353
268impl eframe::App for EditorApp {
354impl 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_close
285 });
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_GREEN
290 } else {
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")
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
408impl 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
308impl 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 = self
358 egui::TextEdit::singleline(&mut self.female)
486 .session
359 .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_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) {
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 = opened
542 self.controls.sex = match sex {
423 .manifest_path
543 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 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 };
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
475fn 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 material
629 let mut open = None;
503 .base_color_texture
630 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.height
641 }
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 = 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() {
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 ui
527 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_count
533 ));
534 }
535 }
701 }
536 for notice in &model.notices {
702 if ui
537 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
547fn parent_relative(relative: &str) -> String {
713#[cfg(test)]
548 match relative.rfind('/') {
714mod 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
554fn 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 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
563impl 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_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}
src/file_assoc.rs added +209 −0
@@ -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 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
7use std::path::{Component, Path, PathBuf};
8
9use crate::project::MANIFEST_NAME;
10
11pub const MIME_TYPE: &str = "application/x-paperdoll-project";
12pub const PROG_ID: &str = "Paperdoll.Project";
13
14pub fn file_open_command(exe: &Path) -> String {
15 format!("\"{}\" \"%1\"", exe.display())
16}
17
18pub 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
42fn 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)]
59pub 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")]
99pub 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
120pub 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
135pub 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)]
160mod 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}
src/foundation.rs +92 −42
@@ -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)]
28pub enum CliRequest {
28pub 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 one
37/// 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)]
37pub enum CliError {
40pub enum CliError {
38 UnknownArgument(String),
41 UnknownArgument(String),
42 Usage(String),
39}
43}
40
44
41const CAPABILITIES: [Capability; 8] = [
45const CAPABILITIES: [Capability; 8] = [
@@ -58,11 +62,11 @@ pub fn availability(capability: Capability) -> Availability {
58 Capability::ProjectShell
62 Capability::ProjectShell
59 | Capability::ObjMtlTextures
63 | Capability::ObjMtlTextures
60 | Capability::GltfGlb
64 | Capability::GltfGlb
61 | Capability::Fbx => Availability::Present,
65 | Capability::Fbx
62 Capability::SkeletalAnimation
66 | Capability::SkeletalAnimation => Availability::Present,
63 | Capability::AttachmentEditor
67 Capability::AttachmentEditor | Capability::PaperdollRender | Capability::Preview2d => {
64 | Capability::PaperdollRender
68 Availability::NotBuilt
65 | 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
109pub fn dutch_summary() -> &'static str {
113pub 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.
114pub fn report() -> String {
118pub 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
139pub fn missing_library_message(missing: &[&str]) -> String {
143pub 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
159pub fn help_text() -> &'static str {
163pub fn help_text() -> &'static str {
160 "\
164 "\
161Paperdoll Renderer — modelimport
165Paperdoll Renderer
162
166
163Gebruik:
167Gebruik:
164 paperdoll-renderer open het projectvenster
168 paperdoll-renderer startscherm
165 paperdoll-renderer --status print de status en sluit
169 paperdoll-renderer pad/naar/character.ppd open dat project
166 paperdoll-renderer --help toon deze tekst
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
168Een 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
171pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
178pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
172where
179where
173 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::Present
244 Availability::Present
206 );
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::Present
250 );
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}
src/icon.rs added +248 −0
@@ -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 are
4//! scaled from it; the drawing is not replaced.
5
6use std::io::Cursor;
7
8/// Tim Falken's original icon, unchanged.
9pub const SOURCE_PNG: &[u8] = include_bytes!("../assets/icons/paperdoll-renderer.png");
10
11/// Freedesktop hicolor sizes installed by the Linux packages.
12pub 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.
18pub 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.
24pub 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
30pub 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
51fn 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.
68fn 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
94fn to_byte(value: f32) -> u8 {
95 (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
96}
97
98fn 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
114fn 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
130fn 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
139fn 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
166fn 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
175fn 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)]
188mod 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}
src/lib.rs +11 −3
@@ -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
6pub mod dialogs;
6pub mod editor;
7pub mod editor;
8pub mod file_assoc;
7pub mod foundation;
9pub mod foundation;
10pub mod icon;
8pub mod import;
11pub mod import;
12pub mod preview;
13pub mod preview_gl;
9pub mod project;
14pub mod project;
15pub mod render_job;
16pub mod session;
17pub mod still;
src/main.rs +75 −19
@@ -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 import
3//! The window edits a `.ppd` project and shows a live skinned glTF preview.
4//! models and it does not render paperdolls.
5
4
6use std::process::ExitCode;
5use 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 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 Ok(CliRequest::PrintHelp) => {
40 Ok(CliRequest::PrintHelp) => {
16 println!("{}", help_text());
41 println!("{}", help_text());
17 ExitCode::SUCCESS
42 ExitCode::SUCCESS
@@ -20,27 +45,43 @@ fn main() -> ExitCode {
20 println!("{}", report());
45 println!("{}", report());
21 ExitCode::SUCCESS
46 ExitCode::SUCCESS
22 }
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
65fn 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
44fn window_preflight() -> Option<String> {
85fn 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
62fn run_window() -> eframe::Result<()> {
103fn 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}
src/preview.rs added +767 −0
@@ -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 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
8use std::path::Path;
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
11pub const PAPERDOLL_YAW_COUNT: usize = 12;
12pub const MAX_JOINTS: usize = 128;
13
14pub type Mat4 = [f32; 16];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PreviewSex {
18 Male,
19 Female,
20}
21
22#[derive(Debug, Clone)]
23pub 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
33impl 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
47impl 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)]
83pub 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)]
98pub 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)]
107pub struct PreviewClip {
108 pub name: String,
109 pub duration: f32,
110 pub channels: Vec<AnimChannel>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ChannelProperty {
115 Translation,
116 Rotation,
117 Scale,
118}
119
120#[derive(Debug, Clone)]
121pub struct AnimChannel {
122 pub node: usize,
123 pub property: ChannelProperty,
124 pub times: Vec<f32>,
125 pub values: Vec<f32>,
126}
127
128pub 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
271pub 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
282pub 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
298pub 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
309pub 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
319pub 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
327fn 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
377fn 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
407fn 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
428fn 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
437fn 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
447fn 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
455fn 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
481fn 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
497fn 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
514fn 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
545pub 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
551pub 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
564fn 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
574pub 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
585fn wrap_deg(mut degrees: f32) -> f32 {
586 degrees %= 360.0;
587 if degrees < 0.0 {
588 degrees += 360.0;
589 }
590 degrees
591}
592
593pub 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
621pub 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
645fn 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
653fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
654 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
655}
656
657fn 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)]
667mod 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}
src/preview_gl.rs added +398 −0
@@ -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 paperdoll
4//! diffuse/depth/normal export.
5
6use std::sync::{Arc, Mutex};
7
8use eframe::egui;
9use egui_glow::CallbackFn;
10use glow::HasContext;
11
12use 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
17const MAX_BONES: usize = crate::preview::MAX_JOINTS;
18
19pub struct Viewport {
20 cache: Arc<Mutex<GpuCache>>,
21}
22
23impl Default for Viewport {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl 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
159fn 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)]
177struct 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)]
190struct 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
201impl 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
327unsafe 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
332fn bytemuck_f32(values: &[f32]) -> &[u8] {
333 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
334}
335
336fn bytemuck_u32(values: &[u32]) -> &[u8] {
337 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
338}
339
340unsafe 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
355unsafe 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
365const VERTEX: &str = r#"#version 330
366layout(location = 0) in vec3 a_pos;
367layout(location = 1) in vec3 a_normal;
368layout(location = 2) in vec4 a_joints;
369layout(location = 3) in vec4 a_weights;
370uniform mat4 u_view_proj;
371uniform mat4 u_bones[128];
372out vec3 v_normal;
373void 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
388const FRAGMENT: &str = r#"#version 330
389in vec3 v_normal;
390uniform vec4 u_color;
391out vec4 frag;
392void 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"#;
src/project.rs +1 −1
@@ -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
555fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
555pub(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!(
src/render_job.rs added +511 −0
@@ -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 executes
5//! and counts only its two real color passes. The GUI may coalesce events.
6
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{self, Receiver};
9use std::thread::{self, JoinHandle};
10
11use crate::preview::{
12 paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,
13};
14use crate::still::raster_body;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PassKind {
18 HighresImage,
19 DetailMap,
20 TargetResolution,
21 Diffuse,
22 Depth,
23 Normal,
24 Shiny,
25 RimHighlight,
26}
27
28impl 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
47pub 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)]
59pub struct ViewAngle {
60 pub pitch_deg: f32,
61 pub yaw_deg: f32,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct PassStep {
66 pub pitch_deg: f32,
67 pub yaw_deg: f32,
68 pub kind: PassKind,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ImageBuf {
73 pub width: u32,
74 pub height: u32,
75 pub rgba: Vec<u8>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Progress {
80 pub completed: u32,
81 pub total: u32,
82 pub label: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub 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)]
105pub 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
113impl 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
173pub 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
186pub 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
200pub 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.
212pub 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
231pub 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
241impl 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
250pub 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
359pub struct RenderThread {
360 pub rx: Receiver<RenderEvent>,
361 pub handle: JoinHandle<()>,
362}
363
364pub 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
374fn 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)]
401mod 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}
src/session.rs added +711 −0
@@ -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 one
4//! step down from Body. Chosen files are copied into the project so a later
5//! save stays inside the directory.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::project::{
13 create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile,
14 MANIFEST_NAME,
15};
16
17const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb");
18const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb");
19
20pub const MALE_ASSET: &str = "assets/body/test-body-male.glb";
21pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb";
22
23pub const START_WINDOW: [f32; 2] = [480.0, 280.0];
24pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Sex {
28 Male,
29 Female,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Screen {
34 Start,
35 Bodies,
36 Body(Sex),
37 EquipmentList,
38 Equipment(usize),
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Intent {
43 Quit,
44 PickNew,
45 PickLoad,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Choice {
50 Save,
51 Discard,
52 Cancel,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AfterChoice {
57 Stay,
58 SaveThen(Intent),
59 Go(Intent),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63struct EquipmentFile {
64 name: String,
65 model: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub 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)]
76pub 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
88impl 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
349impl Default for Session {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355pub 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
377pub 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
415fn 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
430fn 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
437fn io_at(path: &Path, message: &str) -> ProjectError {
438 ProjectError::Io {
439 path: path.display().to_string(),
440 message: message.to_string(),
441 }
442}
443
444fn 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
486fn 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
546fn 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
561fn 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
569pub fn manifest_in(root: &Path) -> PathBuf {
570 root.join(MANIFEST_NAME)
571}
572
573#[cfg(test)]
574mod 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}
src/still.rs added +196 −0
@@ -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 pass
2//! the render button can finish today.
3
4use crate::preview::{
5 eye_offset, mul, perspective, sample_skin_matrices, view_matrix, Mat4, PreviewCharacter,
6 PreviewControls,
7};
8
9pub 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
85struct Projected {
86 x: f32,
87 y: f32,
88 z: f32,
89 on_screen: bool,
90}
91
92fn 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
106fn 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
116fn 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
120fn 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
144fn 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)]
177mod 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}
tests/app_icon.rs added +133 −0
@@ -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
3use std::fs;
4use std::io::Cursor;
5use std::path::PathBuf;
6
7use paperdoll_renderer::icon::{decode_rgba, hicolor_rgba, HICOLOR_SIZES};
8
9fn root() -> PathBuf {
10 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
11}
12
13fn 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
24fn 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
43fn 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
68fn read(path: &str) -> String {
69 fs::read_to_string(root().join(path)).unwrap_or_else(|err| panic!("{path}: {err}"))
70}
71
72#[test]
73fn 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]
107fn 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}