BlueGit

golden-age paperdoll-renderer

feat: restore live preview and build the project editor workflow

Recover the verified cloud preview checkpoint and salvage the interrupted icon and navigation edits. Add native project/model pickers, safe unsaved-change handling, body/equipment navigation and configuration, Open With integration, and a two-pass color still renderer. Record all requested production-render stages in the design without claiming unimplemented maps.

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
aea8edb4c4bff4691b81f7a184a758de9d4d9003
Parent
56489777
44 files changed +3901 −634
.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 +648 −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"
@@ -1812,12 +2123,22 @@ dependencies = [
1812 2123 "glow 0.14.2",
1813 2124 "gltf",
1814 2125 "libloading",
2126 "png",
2127 "rfd",
1815 2128 "serde",
1816 2129 "serde_json",
1817 2130 "tobj",
1818 2131 "ufbx",
2132 "winreg",
2133 "winresource",
1819 2134 ]
1820 2135
2136 [[package]]
2137 name = "parking"
2138 version = "2.2.1"
2139 source = "registry+https://github.com/rust-lang/crates.io-index"
2140 checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2141
1821 2142 [[package]]
1822 2143 name = "parking_lot"
1823 2144 version = "0.12.5"
@@ -1879,6 +2200,17 @@ version = "0.2.17"
1879 2200 source = "registry+https://github.com/rust-lang/crates.io-index"
1880 2201 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1881 2202
2203 [[package]]
2204 name = "piper"
2205 version = "0.2.5"
2206 source = "registry+https://github.com/rust-lang/crates.io-index"
2207 checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
2208 dependencies = [
2209 "atomic-waker",
2210 "fastrand",
2211 "futures-io",
2212 ]
2213
1882 2214 [[package]]
1883 2215 name = "pkg-config"
1884 2216 version = "0.3.34"
@@ -1918,6 +2250,12 @@ dependencies = [
1918 2250 "windows-sys 0.61.2",
1919 2251 ]
1920 2252
2253 [[package]]
2254 name = "pollster"
2255 version = "0.4.0"
2256 source = "registry+https://github.com/rust-lang/crates.io-index"
2257 checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
2258
1921 2259 [[package]]
1922 2260 name = "potential_utf"
1923 2261 version = "0.1.6"
@@ -1927,6 +2265,15 @@ dependencies = [
1927 2265 "zerovec",
1928 2266 ]
1929 2267
2268 [[package]]
2269 name = "ppv-lite86"
2270 version = "0.2.21"
2271 source = "registry+https://github.com/rust-lang/crates.io-index"
2272 checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2273 dependencies = [
2274 "zerocopy",
2275 ]
2276
1930 2277 [[package]]
1931 2278 name = "presser"
1932 2279 version = "0.3.1"
@@ -1993,6 +2340,35 @@ version = "6.0.0"
1993 2340 source = "registry+https://github.com/rust-lang/crates.io-index"
1994 2341 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1995 2342
2343 [[package]]
2344 name = "rand"
2345 version = "0.9.5"
2346 source = "registry+https://github.com/rust-lang/crates.io-index"
2347 checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2348 dependencies = [
2349 "rand_chacha",
2350 "rand_core",
2351 ]
2352
2353 [[package]]
2354 name = "rand_chacha"
2355 version = "0.9.0"
2356 source = "registry+https://github.com/rust-lang/crates.io-index"
2357 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2358 dependencies = [
2359 "ppv-lite86",
2360 "rand_core",
2361 ]
2362
2363 [[package]]
2364 name = "rand_core"
2365 version = "0.9.5"
2366 source = "registry+https://github.com/rust-lang/crates.io-index"
2367 checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2368 dependencies = [
2369 "getrandom 0.3.4",
2370 ]
2371
1996 2372 [[package]]
1997 2373 name = "raw-window-handle"
1998 2374 version = "0.6.2"
@@ -2032,6 +2408,30 @@ version = "1.1.0"
2032 2408 source = "registry+https://github.com/rust-lang/crates.io-index"
2033 2409 checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2034 2410
2411 [[package]]
2412 name = "rfd"
2413 version = "0.15.4"
2414 source = "registry+https://github.com/rust-lang/crates.io-index"
2415 checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
2416 dependencies = [
2417 "ashpd",
2418 "block2 0.6.2",
2419 "dispatch2",
2420 "js-sys",
2421 "log",
2422 "objc2 0.6.5",
2423 "objc2-app-kit 0.3.2",
2424 "objc2-core-foundation",
2425 "objc2-foundation 0.3.2",
2426 "pollster",
2427 "raw-window-handle",
2428 "urlencoding",
2429 "wasm-bindgen",
2430 "wasm-bindgen-futures",
2431 "web-sys",
2432 "windows-sys 0.59.0",
2433 ]
2434
2035 2435 [[package]]
2036 2436 name = "rustc-hash"
2037 2437 version = "1.1.0"
@@ -2155,12 +2555,42 @@ dependencies = [
2155 2555 "zmij",
2156 2556 ]
2157 2557
2558 [[package]]
2559 name = "serde_repr"
2560 version = "0.1.21"
2561 source = "registry+https://github.com/rust-lang/crates.io-index"
2562 checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
2563 dependencies = [
2564 "proc-macro2",
2565 "quote",
2566 "syn 3.0.9",
2567 ]
2568
2569 [[package]]
2570 name = "serde_spanned"
2571 version = "1.1.3"
2572 source = "registry+https://github.com/rust-lang/crates.io-index"
2573 checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
2574 dependencies = [
2575 "serde_core",
2576 ]
2577
2158 2578 [[package]]
2159 2579 name = "shlex"
2160 2580 version = "2.0.1"
2161 2581 source = "registry+https://github.com/rust-lang/crates.io-index"
2162 2582 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2163 2583
2584 [[package]]
2585 name = "signal-hook-registry"
2586 version = "1.4.8"
2587 source = "registry+https://github.com/rust-lang/crates.io-index"
2588 checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
2589 dependencies = [
2590 "errno",
2591 "libc",
2592 ]
2593
2164 2594 [[package]]
2165 2595 name = "simd-adler32"
2166 2596 version = "0.3.10"
@@ -2341,6 +2771,19 @@ dependencies = [
2341 2771 "syn 3.0.9",
2342 2772 ]
2343 2773
2774 [[package]]
2775 name = "tempfile"
2776 version = "3.27.0"
2777 source = "registry+https://github.com/rust-lang/crates.io-index"
2778 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2779 dependencies = [
2780 "fastrand",
2781 "getrandom 0.4.3",
2782 "once_cell",
2783 "rustix 1.1.6",
2784 "windows-sys 0.61.2",
2785 ]
2786
2344 2787 [[package]]
2345 2788 name = "termcolor"
2346 2789 version = "1.4.1"
@@ -2418,6 +2861,21 @@ dependencies = [
2418 2861 "pin-project-lite",
2419 2862 ]
2420 2863
2864 [[package]]
2865 name = "toml"
2866 version = "1.2.1+spec-1.1.0"
2867 source = "registry+https://github.com/rust-lang/crates.io-index"
2868 checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
2869 dependencies = [
2870 "indexmap",
2871 "serde_core",
2872 "serde_spanned",
2873 "toml_datetime",
2874 "toml_parser",
2875 "toml_writer",
2876 "winnow",
2877 ]
2878
2421 2879 [[package]]
2422 2880 name = "toml_datetime"
2423 2881 version = "1.1.3+spec-1.1.0"
@@ -2448,6 +2906,12 @@ dependencies = [
2448 2906 "winnow",
2449 2907 ]
2450 2908
2909 [[package]]
2910 name = "toml_writer"
2911 version = "1.2.0+spec-1.1.0"
2912 source = "registry+https://github.com/rust-lang/crates.io-index"
2913 checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
2914
2451 2915 [[package]]
2452 2916 name = "tracing"
2453 2917 version = "0.1.44"
@@ -2456,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2456 2920 dependencies = [
2457 2921 "log",
2458 2922 "pin-project-lite",
2923 "tracing-attributes",
2459 2924 "tracing-core",
2460 2925 ]
2461 2926
2927 [[package]]
2928 name = "tracing-attributes"
2929 version = "0.1.31"
2930 source = "registry+https://github.com/rust-lang/crates.io-index"
2931 checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2932 dependencies = [
2933 "proc-macro2",
2934 "quote",
2935 "syn 2.0.119",
2936 ]
2937
2462 2938 [[package]]
2463 2939 name = "tracing-core"
2464 2940 version = "0.1.36"
2465 2941 source = "registry+https://github.com/rust-lang/crates.io-index"
2466 2942 checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2943 dependencies = [
2944 "once_cell",
2945 ]
2467 2946
2468 2947 [[package]]
2469 2948 name = "ttf-parser"
@@ -2480,6 +2959,17 @@ dependencies = [
2480 2959 "rustc-hash 2.1.3",
2481 2960 ]
2482 2961
2962 [[package]]
2963 name = "uds_windows"
2964 version = "1.2.1"
2965 source = "registry+https://github.com/rust-lang/crates.io-index"
2966 checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
2967 dependencies = [
2968 "memoffset",
2969 "tempfile",
2970 "windows-sys 0.61.2",
2971 ]
2972
2483 2973 [[package]]
2484 2974 name = "ufbx"
2485 2975 version = "0.11.5"
@@ -2523,6 +3013,7 @@ dependencies = [
2523 3013 "idna",
2524 3014 "percent-encoding",
2525 3015 "serde",
3016 "serde_derive",
2526 3017 ]
2527 3018
2528 3019 [[package]]
@@ -2546,6 +3037,17 @@ version = "1.0.0"
2546 3037 source = "registry+https://github.com/rust-lang/crates.io-index"
2547 3038 checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
2548 3039
3040 [[package]]
3041 name = "uuid"
3042 version = "1.29.0"
3043 source = "registry+https://github.com/rust-lang/crates.io-index"
3044 checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
3045 dependencies = [
3046 "js-sys",
3047 "serde_core",
3048 "wasm-bindgen",
3049 ]
3050
2549 3051 [[package]]
2550 3052 name = "version_check"
2551 3053 version = "0.9.5"
@@ -3061,7 +3563,7 @@ dependencies = [
3061 3563 "android-activity",
3062 3564 "atomic-waker",
3063 3565 "bitflags 2.13.2",
3064 "block2",
3566 "block2 0.5.1",
3065 3567 "bytemuck",
3066 3568 "calloop 0.13.0",
3067 3569 "cfg_aliases 0.2.2",
@@ -3111,6 +3613,26 @@ dependencies = [
3111 3613 "memchr",
3112 3614 ]
3113 3615
3616 [[package]]
3617 name = "winreg"
3618 version = "0.55.0"
3619 source = "registry+https://github.com/rust-lang/crates.io-index"
3620 checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
3621 dependencies = [
3622 "cfg-if",
3623 "windows-sys 0.59.0",
3624 ]
3625
3626 [[package]]
3627 name = "winresource"
3628 version = "0.1.34"
3629 source = "registry+https://github.com/rust-lang/crates.io-index"
3630 checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
3631 dependencies = [
3632 "toml",
3633 "version_check",
3634 ]
3635
3114 3636 [[package]]
3115 3637 name = "wit-bindgen"
3116 3638 version = "0.57.1"
@@ -3209,6 +3731,76 @@ dependencies = [
3209 3731 "synstructure",
3210 3732 ]
3211 3733
3734 [[package]]
3735 name = "zbus"
3736 version = "5.19.0"
3737 source = "registry+https://github.com/rust-lang/crates.io-index"
3738 checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
3739 dependencies = [
3740 "async-broadcast",
3741 "async-executor",
3742 "async-io",
3743 "async-lock",
3744 "async-process",
3745 "async-recursion",
3746 "async-task",
3747 "async-trait",
3748 "blocking",
3749 "enumflags2",
3750 "event-listener",
3751 "futures-core",
3752 "futures-lite",
3753 "hex",
3754 "libc",
3755 "ordered-stream",
3756 "rustix 1.1.6",
3757 "serde",
3758 "serde_repr",
3759 "tracing",
3760 "uds_windows",
3761 "uuid",
3762 "windows-sys 0.61.2",
3763 "winnow",
3764 "zbus_macros",
3765 "zbus_names",
3766 "zvariant",
3767 ]
3768
3769 [[package]]
3770 name = "zbus_macros"
3771 version = "5.19.0"
3772 source = "registry+https://github.com/rust-lang/crates.io-index"
3773 checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
3774 dependencies = [
3775 "proc-macro-crate",
3776 "proc-macro2",
3777 "quote",
3778 "syn 3.0.9",
3779 "zbus_names",
3780 "zvariant",
3781 "zvariant_utils",
3782 ]
3783
3784 [[package]]
3785 name = "zbus_names"
3786 version = "4.3.5"
3787 source = "registry+https://github.com/rust-lang/crates.io-index"
3788 checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
3789 dependencies = [
3790 "serde",
3791 "winnow",
3792 "zvariant",
3793 ]
3794
3795 [[package]]
3796 name = "zcheapstr"
3797 version = "1.2.0"
3798 source = "registry+https://github.com/rust-lang/crates.io-index"
3799 checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"
3800 dependencies = [
3801 "serde",
3802 ]
3803
3212 3804 [[package]]
3213 3805 name = "zerocopy"
3214 3806 version = "0.8.63"
@@ -3309,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3309 3901 dependencies = [
3310 3902 "zune-core",
3311 3903 ]
3904
3905 [[package]]
3906 name = "zvariant"
3907 version = "5.15.1"
3908 source = "registry+https://github.com/rust-lang/crates.io-index"
3909 checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
3910 dependencies = [
3911 "endi",
3912 "enumflags2",
3913 "serde",
3914 "url",
3915 "winnow",
3916 "zcheapstr",
3917 "zvariant_derive",
3918 "zvariant_utils",
3919 ]
3920
3921 [[package]]
3922 name = "zvariant_derive"
3923 version = "5.15.1"
3924 source = "registry+https://github.com/rust-lang/crates.io-index"
3925 checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
3926 dependencies = [
3927 "proc-macro-crate",
3928 "proc-macro2",
3929 "quote",
3930 "syn 3.0.9",
3931 "zvariant_utils",
3932 ]
3933
3934 [[package]]
3935 name = "zvariant_utils"
3936 version = "4.2.0"
3937 source = "registry+https://github.com/rust-lang/crates.io-index"
3938 checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
3939 dependencies = [
3940 "proc-macro2",
3941 "quote",
3942 "serde",
3943 "syn 3.0.9",
3944 "winnow",
3945 ]
@@ -109,6 +109,181 @@ dependencies = [
109 "libloading",
109 "libloading",
110]
110]
111
111
112[[package]]
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"
@@ -1812,12 +2123,22 @@ dependencies = [
1812 "glow 0.14.2",
2123 "glow 0.14.2",
1813 "gltf",
2124 "gltf",
1814 "libloading",
2125 "libloading",
2126 "png",
2127 "rfd",
1815 "serde",
2128 "serde",
1816 "serde_json",
2129 "serde_json",
1817 "tobj",
2130 "tobj",
1818 "ufbx",
2131 "ufbx",
2132 "winreg",
2133 "winresource",
1819]
2134]
1820
2135
2136[[package]]
2137name = "parking"
2138version = "2.2.1"
2139source = "registry+https://github.com/rust-lang/crates.io-index"
2140checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2141
1821[[package]]
2142[[package]]
1822name = "parking_lot"
2143name = "parking_lot"
1823version = "0.12.5"
2144version = "0.12.5"
@@ -1879,6 +2200,17 @@ version = "0.2.17"
1879source = "registry+https://github.com/rust-lang/crates.io-index"
2200source = "registry+https://github.com/rust-lang/crates.io-index"
1880checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2201checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1881
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
1882[[package]]
2214[[package]]
1883name = "pkg-config"
2215name = "pkg-config"
1884version = "0.3.34"
2216version = "0.3.34"
@@ -1918,6 +2250,12 @@ dependencies = [
1918 "windows-sys 0.61.2",
2250 "windows-sys 0.61.2",
1919]
2251]
1920
2252
2253[[package]]
2254name = "pollster"
2255version = "0.4.0"
2256source = "registry+https://github.com/rust-lang/crates.io-index"
2257checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
2258
1921[[package]]
2259[[package]]
1922name = "potential_utf"
2260name = "potential_utf"
1923version = "0.1.6"
2261version = "0.1.6"
@@ -1927,6 +2265,15 @@ dependencies = [
1927 "zerovec",
2265 "zerovec",
1928]
2266]
1929
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
1930[[package]]
2277[[package]]
1931name = "presser"
2278name = "presser"
1932version = "0.3.1"
2279version = "0.3.1"
@@ -1993,6 +2340,35 @@ version = "6.0.0"
1993source = "registry+https://github.com/rust-lang/crates.io-index"
2340source = "registry+https://github.com/rust-lang/crates.io-index"
1994checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2341checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1995
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
1996[[package]]
2372[[package]]
1997name = "raw-window-handle"
2373name = "raw-window-handle"
1998version = "0.6.2"
2374version = "0.6.2"
@@ -2032,6 +2408,30 @@ version = "1.1.0"
2032source = "registry+https://github.com/rust-lang/crates.io-index"
2408source = "registry+https://github.com/rust-lang/crates.io-index"
2033checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2409checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2034
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
2035[[package]]
2435[[package]]
2036name = "rustc-hash"
2436name = "rustc-hash"
2037version = "1.1.0"
2437version = "1.1.0"
@@ -2155,12 +2555,42 @@ dependencies = [
2155 "zmij",
2555 "zmij",
2156]
2556]
2157
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
2158[[package]]
2578[[package]]
2159name = "shlex"
2579name = "shlex"
2160version = "2.0.1"
2580version = "2.0.1"
2161source = "registry+https://github.com/rust-lang/crates.io-index"
2581source = "registry+https://github.com/rust-lang/crates.io-index"
2162checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2582checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2163
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
2164[[package]]
2594[[package]]
2165name = "simd-adler32"
2595name = "simd-adler32"
2166version = "0.3.10"
2596version = "0.3.10"
@@ -2341,6 +2771,19 @@ dependencies = [
2341 "syn 3.0.9",
2771 "syn 3.0.9",
2342]
2772]
2343
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
2344[[package]]
2787[[package]]
2345name = "termcolor"
2788name = "termcolor"
2346version = "1.4.1"
2789version = "1.4.1"
@@ -2418,6 +2861,21 @@ dependencies = [
2418 "pin-project-lite",
2861 "pin-project-lite",
2419]
2862]
2420
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
2421[[package]]
2879[[package]]
2422name = "toml_datetime"
2880name = "toml_datetime"
2423version = "1.1.3+spec-1.1.0"
2881version = "1.1.3+spec-1.1.0"
@@ -2448,6 +2906,12 @@ dependencies = [
2448 "winnow",
2906 "winnow",
2449]
2907]
2450
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
2451[[package]]
2915[[package]]
2452name = "tracing"
2916name = "tracing"
2453version = "0.1.44"
2917version = "0.1.44"
@@ -2456,14 +2920,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2456dependencies = [
2920dependencies = [
2457 "log",
2921 "log",
2458 "pin-project-lite",
2922 "pin-project-lite",
2923 "tracing-attributes",
2459 "tracing-core",
2924 "tracing-core",
2460]
2925]
2461
2926
2927[[package]]
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
2462[[package]]
2938[[package]]
2463name = "tracing-core"
2939name = "tracing-core"
2464version = "0.1.36"
2940version = "0.1.36"
2465source = "registry+https://github.com/rust-lang/crates.io-index"
2941source = "registry+https://github.com/rust-lang/crates.io-index"
2466checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2942checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2943dependencies = [
2944 "once_cell",
2945]
2467
2946
2468[[package]]
2947[[package]]
2469name = "ttf-parser"
2948name = "ttf-parser"
@@ -2480,6 +2959,17 @@ dependencies = [
2480 "rustc-hash 2.1.3",
2959 "rustc-hash 2.1.3",
2481]
2960]
2482
2961
2962[[package]]
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
2483[[package]]
2973[[package]]
2484name = "ufbx"
2974name = "ufbx"
2485version = "0.11.5"
2975version = "0.11.5"
@@ -2523,6 +3013,7 @@ dependencies = [
2523 "idna",
3013 "idna",
2524 "percent-encoding",
3014 "percent-encoding",
2525 "serde",
3015 "serde",
3016 "serde_derive",
2526]
3017]
2527
3018
2528[[package]]
3019[[package]]
@@ -2546,6 +3037,17 @@ version = "1.0.0"
2546source = "registry+https://github.com/rust-lang/crates.io-index"
3037source = "registry+https://github.com/rust-lang/crates.io-index"
2547checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
3038checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
2548
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
2549[[package]]
3051[[package]]
2550name = "version_check"
3052name = "version_check"
2551version = "0.9.5"
3053version = "0.9.5"
@@ -3061,7 +3563,7 @@ dependencies = [
3061 "android-activity",
3563 "android-activity",
3062 "atomic-waker",
3564 "atomic-waker",
3063 "bitflags 2.13.2",
3565 "bitflags 2.13.2",
3064 "block2",
3566 "block2 0.5.1",
3065 "bytemuck",
3567 "bytemuck",
3066 "calloop 0.13.0",
3568 "calloop 0.13.0",
3067 "cfg_aliases 0.2.2",
3569 "cfg_aliases 0.2.2",
@@ -3111,6 +3613,26 @@ dependencies = [
3111 "memchr",
3613 "memchr",
3112]
3614]
3113
3615
3616[[package]]
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
3114[[package]]
3636[[package]]
3115name = "wit-bindgen"
3637name = "wit-bindgen"
3116version = "0.57.1"
3638version = "0.57.1"
@@ -3209,6 +3731,76 @@ dependencies = [
3209 "synstructure",
3731 "synstructure",
3210]
3732]
3211
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
3212[[package]]
3804[[package]]
3213name = "zerocopy"
3805name = "zerocopy"
3214version = "0.8.63"
3806version = "0.8.63"
@@ -3309,3 +3901,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3309dependencies = [
3901dependencies = [
3310 "zune-core",
3902 "zune-core",
3311]
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 +9 −1
@@ -4,7 +4,7 @@ version = "0.1.0"
4 4 edition = "2021"
5 5 license = "MIT"
6 6 publish = false
7 description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
7 description = "Paperdoll Renderer with project files, model import and a live glTF preview."
8 8
9 9 [dependencies]
10 10 eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
@@ -12,7 +12,15 @@ egui_glow = "0.29.1"
12 12 glow = "0.14.2"
13 13 gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
14 14 libloading = "0.8"
15 png = "0.18"
16 rfd = "0.15.4"
15 17 serde = { version = "1", features = ["derive"] }
16 18 serde_json = "1"
17 19 tobj = "4.0.3"
18 20 ufbx = "0.11.5"
21
22 [build-dependencies]
23 winresource = "0.1.34"
24
25 [target."cfg(windows)".dependencies]
26 winreg = "0.55"
@@ -4,7 +4,7 @@ version = "0.1.0"
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"] }
@@ -12,7 +12,15 @@ egui_glow = "0.29.1"
12glow = "0.14.2"
12glow = "0.14.2"
13gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
13gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
14libloading = "0.8"
14libloading = "0.8"
15png = "0.18"
16rfd = "0.15.4"
15serde = { version = "1", features = ["derive"] }
17serde = { version = "1", features = ["derive"] }
16serde_json = "1"
18serde_json = "1"
17tobj = "4.0.3"
19tobj = "4.0.3"
18ufbx = "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 +30 −15
@@ -1,40 +1,55 @@
1 1 # Paperdoll Renderer
2 2
3 Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.
3 Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
4 4
5 Rust 1.96.0 staat in `rust-toolchain.toml`. Voor het venster: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`.
5 ## Starten
6
7 Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity.
6 8
7 9 ```bash
8 10 cargo run
11 cargo run -- "/pad met spaties/character.ppd"
9 12 cargo run -- --preview-fixture
10 13 cargo run -- --status
11 cargo test --locked
12 14 ```
13 15
14 `--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.
16 ## Project en bodies
17
18 Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
19
20 De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL.
21
22 **Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23
24 - Sleep om te orbiten, scroll om te zoomen.
25 - Kies een animatieclip en gebruik **Pauze / Speel**.
26 - **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27 - **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. **Live 3D** keert terug naar het model.
28 - Stel breedte, hoogte en fps in de Bodies-weergave in.
29
30 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
15 31
16 ## Project
32 ## Equipment en opslaan
17 33
18 **Nieuw** maakt een map met `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. **Open** leest een `.ppd`. **Opslaan** schrijft het bestand via een tijdelijk buurbestand.
34 Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**. De configuratie bewaart een unieke naam (kleine letters, cijfers, `-` en `_`) en een gekozen model. **Verwijder** verwijdert de definitie uit de projectlijst. **Opslaan** bewaart het project en equipment-configuraties in `presets/`.
19 35
20 In het bestand staan de projectnaam, de paden van het mannelijke en vrouwelijke model, de paden van equipment-presets, de uitvoermap, en breedte, hoogte en fps. Paden zijn relatief aan de projectmap. Een leeg geslachtsslot blijft leeg. **Lees man** en **Lees vrouw** tonen meshes, materialen, textures, joints en clips van glTF/GLB, FBX of OBJ+MTL.
36 Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
21 37
22 ## 3D-preview
38 ## Openen met
23 39
24 Het middelste vlak tekent een geskind glTF/GLB-lichaam.
40 - **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).
41 - **Debian/Arch:** de pakketten installeren de `.ppd`-MIME-koppeling, desktopstarter en iconen. Kies de editor in **Openen met**.
42 - **Linux AppImage/losse binary:** bewaar het bestand op zijn vaste plek en voer `"/pad/Paperdoll.AppImage" --install-desktop` uit. Hiervoor zijn `shared-mime-info` en `desktop-file-utils` nodig. Daarna staat de editor in **Openen met**. Herhaal dit na verplaatsen van de AppImage.
25 43
26 - **Laad testlichamen** zet de man en de vrouw uit de fixture in de view.
27 - **3D van project** gebruikt de `.glb`- of `.gltf`-paden van man en vrouw.
28 - **Man** en **Vrouw** wisselen het lichaam. **walk** en **attack** wisselen de clip. **Pauze** / **Speel** zet de animatie stil of laat haar lopen.
29 - Sleep in het vlak om te draaien. Scroll om te zoomen.
30 - **Yaw −** en **Yaw +** stappen langs twaalf hoeken van 30°. **pitch -30**, **pitch 0** en **pitch 30** zetten de drie hoogtes. Yaw 0 kijkt op de voorkant; de testlichamen wijzen naar −Z.
44 Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.
31 45
32 46 ## Tests
33 47
34 48 ```bash
35 49 cargo fmt --check
50 cargo check --workspace --all-targets --locked
36 51 cargo clippy --workspace --all-targets --locked -- -D warnings
37 52 cargo test --workspace --locked
38 53 ```
39 54
40 De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-bestand van de man komt uit `scripts/convert_test_body_to_fbx.py` met Blender.
55 De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
@@ -1,40 +1,55 @@
1# Paperdoll Renderer
1# Paperdoll Renderer
2
2
3Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.
3Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
4
4
5Rust 1.96.0 staat in `rust-toolchain.toml`. Voor het venster: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`.
5## Starten
6
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.
6
8
7```bash
9```bash
8cargo run
10cargo run
11cargo run -- "/pad met spaties/character.ppd"
9cargo run -- --preview-fixture
12cargo run -- --preview-fixture
10cargo run -- --status
13cargo run -- --status
11cargo test --locked
12```
14```
13
15
14`--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.
16## Project en bodies
17
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.
19
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.
21
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23
24- Sleep om te orbiten, scroll om te zoomen.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
26- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. **Live 3D** keert terug naar het model.
28- Stel breedte, hoogte en fps in de Bodies-weergave in.
29
30![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
15
31
16## Project
32## Equipment en opslaan
17
33
18**Nieuw** maakt een map met `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. **Open** leest een `.ppd`. **Opslaan** schrijft het bestand via een tijdelijk buurbestand.
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/`.
19
35
20In het bestand staan de projectnaam, de paden van het mannelijke en vrouwelijke model, de paden van equipment-presets, de uitvoermap, en breedte, hoogte en fps. Paden zijn relatief aan de projectmap. Een leeg geslachtsslot blijft leeg. **Lees man** en **Lees vrouw** tonen meshes, materialen, textures, joints en clips van glTF/GLB, FBX of OBJ+MTL.
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.
21
37
22## 3D-preview
38## Openen met
23
39
24Het middelste vlak tekent een geskind glTF/GLB-lichaam.
40- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).
41- **Debian/Arch:** de pakketten installeren de `.ppd`-MIME-koppeling, desktopstarter en iconen. Kies de editor in **Openen met**.
42- **Linux AppImage/losse binary:** bewaar het bestand op zijn vaste plek en voer `"/pad/Paperdoll.AppImage" --install-desktop` uit. Hiervoor zijn `shared-mime-info` en `desktop-file-utils` nodig. Daarna staat de editor in **Openen met**. Herhaal dit na verplaatsen van de AppImage.
25
43
26- **Laad testlichamen** zet de man en de vrouw uit de fixture in de view.
44Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon.
27- **3D van project** gebruikt de `.glb`- of `.gltf`-paden van man en vrouw.
28- **Man** en **Vrouw** wisselen het lichaam. **walk** en **attack** wisselen de clip. **Pauze** / **Speel** zet de animatie stil of laat haar lopen.
29- Sleep in het vlak om te draaien. Scroll om te zoomen.
30- **Yaw −** en **Yaw +** stappen langs twaalf hoeken van 30°. **pitch -30**, **pitch 0** en **pitch 30** zetten de drie hoogtes. Yaw 0 kijkt op de voorkant; de testlichamen wijzen naar −Z.
31
45
32## Tests
46## Tests
33
47
34```bash
48```bash
35cargo fmt --check
49cargo fmt --check
50cargo check --workspace --all-targets --locked
36cargo clippy --workspace --all-targets --locked -- -D warnings
51cargo clippy --workspace --all-targets --locked -- -D warnings
37cargo test --workspace --locked
52cargo test --workspace --locked
38```
53```
39
54
40De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-bestand van de man komt uit `scripts/convert_test_body_to_fbx.py` met Blender.
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 +16 −1
@@ -159,7 +159,9 @@ Each feature starts on its own `feature/...` branch from current master. Impleme
159 159
160 160 After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.
161 161
162 Live 3D preview, as built: the center of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
162 The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
163
164 Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
163 165
164 166 CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
165 167
@@ -171,6 +173,19 @@ Release publication uses documented BlueGit release/artifact API and job-scoped
171 173
172 174 Verified BlueGit behaviour, inspected 6 November 2026, is recorded in section 15. The configured tag pipeline calls that API. With the API as it exists today, publishing the same tag that started the pipeline is rejected. That limit is part of the design record, not a claim that a release was created.
173 175
176 ## 11a. Tim’s desktop workflow requirements (6 November 2026)
177
178 These requirements are durable even where the implementation is staged. They supersede the earlier generic workspace layout.
179
180 - Launch without a project shows a small start window with **Project laden** and **Nieuw project**. Both invoke an OS-native picker: choose an existing `.ppd` file or a new project directory. Cancellation keeps the current project and edits unchanged. A new project prepares the CC0 male/female fixtures in `assets/body/`; existing assets are never silently overwritten.
181 - The first project view is **Bodies**, with male/female model slots and native asset-pick buttons. Navigation is **Bodies → body → Equipment → equipment configuration**. A body has the live orbit/zoom mesh preview, animation controls, a **Render** action and an **Equipment** action. Equipment list entries open their configuration; **Nieuw** creates a local definition. Keep body context when going back. Remove the old right-hand folder explorer entirely.
182 - Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.
183 - `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.
184 - Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
185 - During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor** to fill the preview box, then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
186
187 Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
188
174 189 ## 12. Incremental feature plan and acceptance gates
175 190
176 191 0. Foundation/design/CI: group and repo existence checks; private repo; single detailed design.md with verbatim appendix; minimal honest compileable Rust shell; locked dependencies/toolchain; genuine two-parent setup merge; tests and verified CI schema. No claim that rendering/import works. Test packaging scripts locally where possible without publishing.
@@ -159,7 +159,9 @@ Each feature starts on its own `feature/...` branch from current master. Impleme
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.
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
161
162Live 3D preview, as built: the center of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
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.
163
165
164CI 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.
165
167
@@ -171,6 +173,19 @@ Release publication uses documented BlueGit release/artifact API and job-scoped
171
173
172Verified 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.
173
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
174## 12. Incremental feature plan and acceptance gates
189## 12. Incremental feature plan and acceptance gates
175
190
1760. 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 +676 −520
@@ -1,642 +1,798 @@
1 //! Project window. It edits a `.ppd` manifest and does not import or render.
1 //! Project window: start screen, body, equipment, and the render queue.
2 2
3 3 use std::path::PathBuf;
4 use std::sync::mpsc::Receiver;
4 5
5 6 use eframe::egui;
6 7
7 use crate::import::{import_project_model, ImportedModel, ModelFormat};
8 use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9 use crate::import::{import_project_model, ImportedModel};
8 10 use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
9 11 use crate::preview_gl::Viewport;
10 use crate::project::{
11 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
12 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
13 };
12 use crate::project::open_project;
13 use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
14 use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
14 15
15 16 pub struct EditorApp {
16 root_input: String,
17 open_input: String,
18 opened: Option<OpenedProject>,
19 name: String,
20 male: String,
21 female: String,
22 presets: Vec<String>,
23 preset_input: String,
24 output: String,
25 width: String,
26 height: String,
27 fps: String,
28 browser: String,
29 male_import: Option<Result<ImportedModel, String>>,
30 female_import: Option<Result<ImportedModel, String>>,
31 preview_controls: PreviewControls,
17 session: Session,
18 dialogs: Box<dyn Dialogs>,
19 controls: PreviewControls,
32 20 preview_male: Option<PreviewCharacter>,
33 21 preview_female: Option<PreviewCharacter>,
34 22 viewport: Viewport,
35 notice: String,
36 notice_ok: bool,
23 inventories: [Option<Result<ImportedModel, String>>; 2],
24 render_rx: Option<Receiver<RenderEvent>>,
25 render_ui: RenderUiState,
26 applied_shell: Option<[f32; 2]>,
27 width_text: String,
28 height_text: String,
29 fps_text: String,
30 allow_close: bool,
31 dialog_pending: bool,
37 32 }
38 33
39 34 impl EditorApp {
40 pub fn new() -> Self {
35 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
41 36 Self {
42 root_input: String::new(),
43 open_input: String::new(),
44 opened: None,
45 name: "Nieuw project".to_string(),
46 male: String::new(),
47 female: String::new(),
48 presets: Vec::new(),
49 preset_input: String::new(),
50 output: "renders".to_string(),
51 width: "128".to_string(),
52 height: "128".to_string(),
53 fps: "12".to_string(),
54 browser: String::new(),
55 male_import: None,
56 female_import: None,
57 preview_controls: PreviewControls::default(),
37 session: Session::new(),
38 dialogs,
39 controls: PreviewControls::default(),
58 40 preview_male: None,
59 41 preview_female: None,
60 42 viewport: Viewport::new(),
61 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
62 notice_ok: true,
43 inventories: [None, None],
44 render_rx: None,
45 render_ui: RenderUiState::idle(),
46 applied_shell: None,
47 width_text: "128".to_string(),
48 height_text: "128".to_string(),
49 fps_text: "12".to_string(),
50 allow_close: false,
51 dialog_pending: false,
63 52 }
64 53 }
65 54
66 fn load(&mut self, opened: OpenedProject, notice: &str) {
67 self.name = opened.document.name.clone();
68 self.male = opened.document.male_model.clone().unwrap_or_default();
69 self.female = opened.document.female_model.clone().unwrap_or_default();
70 self.presets = opened.document.equipment_presets.clone();
71 self.output = opened.document.output_directory.clone();
72 self.width = opened.document.render.target_width.to_string();
73 self.height = opened.document.render.target_height.to_string();
74 self.fps = opened.document.render.fps.to_string();
75 self.browser.clear();
76 self.male_import = None;
77 self.female_import = None;
78 self.opened = Some(opened.clone());
79 let male = self.male.clone();
80 let female = self.female.clone();
81 if !male.trim().is_empty() {
82 self.inspect(true);
83 }
84 if !female.trim().is_empty() {
85 self.inspect(false);
86 }
87 self.refresh_preview_from_project();
88 if male.trim().is_empty() && female.trim().is_empty() {
89 if opened.disk_issues.is_empty() {
90 self.ok(notice);
91 } else {
92 self.fail_text(opened.disk_issues.join("\n"));
55 pub fn open_path(&mut self, path: PathBuf) {
56 let candidate = std::env::current_dir()
57 .map_err(|e| e.to_string())
58 .and_then(|base| {
59 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
60 });
61 let path = match candidate {
62 Ok(path) => path,
63 Err(error) => {
64 self.session.fail(error);
65 return;
66 }
67 };
68 match open_project(&path) {
69 Ok(opened) => {
70 self.session.install_loaded(opened);
71 self.reset_render();
72 self.sync_fields();
73 self.reload_bodies();
74 }
75 Err(error) => {
76 self.session.fail(error.to_string());
93 77 }
94 78 }
95 79 }
96 80
97 pub fn load_fixture_bodies(&mut self) {
98 let root =
99 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies");
100 match load_gltf_file(&root.join("test-body-male.glb")) {
101 Ok(character) => self.preview_male = Some(character),
102 Err(error) => self.fail_text(format!("testman: {error}")),
103 }
104 match load_gltf_file(&root.join("test-body-female.glb")) {
105 Ok(character) => self.preview_female = Some(character),
106 Err(error) => self.fail_text(format!("testvrouw: {error}")),
107 }
108 if self.preview_male.is_some() {
109 self.preview_controls.sex = PreviewSex::Male;
110 self.preview_controls.select_clip("walk");
111 self.ok("Testlichamen staan in de 3D-preview.");
112 }
113 }
114
115 fn refresh_preview_from_project(&mut self) {
116 let Some(root) = self.opened.as_ref().and_then(|opened| {
117 opened
118 .manifest_path
119 .parent()
120 .map(std::path::Path::to_path_buf)
121 }) else {
81 pub fn open_fixture_project(&mut self) {
82 let root = std::env::temp_dir().join(format!(
83 "paperdoll-renderer-fixture-{}-{}",
84 std::process::id(),
85 std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_nanos()
89 ));
90 if let Err(error) = self.session.install_new_project(&root) {
91 self.session.fail(error.to_string());
122 92 return;
123 };
124 self.preview_male = load_preview_path(&root, &self.male);
125 self.preview_female = load_preview_path(&root, &self.female);
93 }
94 self.sync_fields();
95 self.reload_bodies();
96 self.session.show_body(Sex::Male);
97 self.controls.sex = PreviewSex::Male;
98 self.controls.select_clip("walk");
126 99 }
127 100
128 fn ok(&mut self, message: &str) {
129 self.notice = message.to_string();
130 self.notice_ok = true;
101 fn reset_render(&mut self) {
102 self.render_rx = None;
103 self.render_ui = RenderUiState::idle();
131 104 }
132 105
133 fn fail(&mut self, error: ProjectError) {
134 self.notice = error.to_string();
135 self.notice_ok = false;
106 fn sync_fields(&mut self) {
107 if let Some(opened) = &self.session.opened {
108 self.width_text = opened.document.render.target_width.to_string();
109 self.height_text = opened.document.render.target_height.to_string();
110 self.fps_text = opened.document.render.fps.to_string();
111 }
136 112 }
137 113
138 fn fail_text(&mut self, message: impl Into<String>) {
139 self.notice = message.into();
140 self.notice_ok = false;
114 fn reload_bodies(&mut self) {
115 self.preview_male = None;
116 self.preview_female = None;
117 self.inventories = [None, None];
118 let Some(root) = self.session.project_root() else {
119 return;
120 };
121 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
122 if let Some(relative) = self.session.body_path(sex) {
123 let imported =
124 import_project_model(&root, relative).map_err(|error| error.to_string());
125 // Validate the source and its dependencies before the viewport reads it.
126 if imported.is_ok()
127 && matches!(
128 std::path::Path::new(relative)
129 .extension()
130 .and_then(|e| e.to_str()),
131 Some("gltf" | "glb")
132 )
133 {
134 match load_gltf_file(&root.join(relative)) {
135 Ok(character) => match sex {
136 Sex::Male => self.preview_male = Some(character),
137 Sex::Female => self.preview_female = Some(character),
138 },
139 Err(error) => self.session.fail(format!("3D: {error}")),
140 }
141 }
142 self.inventories[index] = Some(imported);
143 }
144 }
141 145 }
142 146
143 fn draft(&self) -> Result<ProjectDraft, String> {
144 let opened = self
145 .opened
146 .as_ref()
147 .ok_or_else(|| "open of maak eerst een project".to_string())?;
148 Ok(ProjectDraft {
149 id: opened.document.id.clone(),
150 name: self.name.clone(),
151 male_model: self.male.clone(),
152 female_model: self.female.clone(),
153 equipment_presets: self.presets.clone(),
154 output_directory: self.output.clone(),
155 target_width: parse_u32("breedte", &self.width)?,
156 target_height: parse_u32("hoogte", &self.height)?,
157 fps: parse_u32("fps", &self.fps)?,
158 })
147 fn character(&self) -> Option<&PreviewCharacter> {
148 match self.controls.sex {
149 PreviewSex::Male => self.preview_male.as_ref(),
150 PreviewSex::Female => self.preview_female.as_ref(),
151 }
159 152 }
160 153
161 fn create(&mut self) {
162 let root = PathBuf::from(self.root_input.trim());
163 if self.root_input.trim().is_empty() {
164 self.fail_text("vul de projectmap in");
165 return;
166 }
167 match create_project(&root, "Nieuw project") {
168 Ok(opened) => self.load(
169 opened,
170 "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
171 ),
172 Err(error) => self.fail(error),
154 fn poll_dialogs(&mut self) {
155 let poll = self.dialogs.poll();
156 self.dialog_pending = matches!(poll, DialogPoll::Pending);
157 if let DialogPoll::Ready { purpose, path } = poll {
158 let Some(path) = path else {
159 return;
160 };
161 match purpose {
162 DialogPurpose::NewProject => {
163 if let Err(error) = self.session.install_new_project(&path) {
164 self.session.fail(error.to_string());
165 } else {
166 self.reset_render();
167 self.sync_fields();
168 self.reload_bodies();
169 }
170 }
171 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
172 Ok(opened) => {
173 self.session.install_loaded(opened);
174 self.reset_render();
175 self.sync_fields();
176 self.reload_bodies();
177 }
178 Err(error) => self.session.fail(error.to_string()),
179 },
180 DialogPurpose::Body(sex) => self.take_model(sex, &path),
181 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
182 }
173 183 }
174 184 }
175 185
176 fn open(&mut self) {
177 let path = PathBuf::from(self.open_input.trim());
178 if self.open_input.trim().is_empty() {
179 self.fail_text("vul het pad naar een .ppd-bestand in");
186 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
187 let Some(root) = self.session.project_root() else {
180 188 return;
181 }
182 match open_project(&path) {
183 Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
184 Err(error) => self.fail(error),
189 };
190 match adopt_file(&root, path, "assets/body") {
191 Ok(relative) => {
192 self.session.set_body_path(sex, relative.clone());
193 self.reload_bodies();
194 self.reset_render();
195 let index = if sex == Sex::Male { 0 } else { 1 };
196 if let Some(Err(error)) = &self.inventories[index] {
197 self.session.fail(format!("{relative}: {error}"));
198 } else {
199 self.session.notice = format!("{relative} staat in het project.");
200 self.session.notice_ok = true;
201 }
202 }
203 Err(error) => self.session.fail(error),
185 204 }
186 205 }
187 206
188 fn save(&mut self) {
189 let Some(current) = self.opened.clone() else {
190 self.fail_text("open of maak eerst een project");
207 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208 let Some(root) = self.session.project_root() else {
191 209 return;
192 210 };
193 let draft = match self.draft() {
194 Ok(draft) => draft,
195 Err(message) => {
196 self.fail_text(message);
197 return;
198 }
199 };
200 let document = match assemble(draft) {
201 Ok(document) => document,
202 Err(error) => {
203 self.fail(error);
204 return;
211 match adopt_file(&root, path, "assets/equipment") {
212 Ok(relative) => {
213 self.session.set_equipment_model(index, relative.clone());
214 self.session.notice = format!("{relative} hoort bij deze equipment.");
215 self.session.notice_ok = true;
205 216 }
206 };
207 let candidate = OpenedProject {
208 manifest_path: current.manifest_path,
209 document,
210 disk_issues: Vec::new(),
211 };
212 match save_project(&candidate) {
213 Ok(()) => {
214 let path = candidate.manifest_path.display().to_string();
215 self.opened = Some(candidate);
216 self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
217 }
218 Err(error) => self.fail(error),
217 Err(error) => self.session.fail(error),
219 218 }
220 219 }
221 220
222 fn add_preset(&mut self, relative: String) {
223 if relative.trim().is_empty() {
224 return;
221 fn begin(&mut self, intent: Intent) {
222 if self.session.gate(intent) {
223 self.run_intent(intent);
225 224 }
226 self.presets.push(relative);
227 if let Err(error) = self
228 .draft()
229 .and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
230 {
231 self.presets.pop();
232 self.fail_text(error);
233 } else {
234 self.preset_input.clear();
235 self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");
225 }
226
227 fn run_intent(&mut self, intent: Intent) {
228 match intent {
229 Intent::Quit => {}
230 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
231 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
236 232 }
237 233 }
238 234
239 fn assign_file(&mut self, relative: &str) {
240 if relative == MANIFEST_NAME {
241 self.fail_text("character.ppd is het projectbestand, geen model of preset");
242 return;
235 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
236 match self.session.resolve(choice) {
237 AfterChoice::Stay => {}
238 AfterChoice::SaveThen(intent) => match self.session.save() {
239 Ok(()) => self.finish_intent(intent, ctx),
240 Err(error) => self.session.fail(error.to_string()),
241 },
242 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
243 243 }
244 let lower = relative.to_ascii_lowercase();
245 if lower.contains("/preset") || lower.starts_with("presets/") {
246 self.add_preset(relative.to_string());
247 } else if self.male.trim().is_empty() {
248 self.male = relative.to_string();
249 self.inspect(true);
250 } else if self.female.trim().is_empty() {
251 self.female = relative.to_string();
252 self.inspect(false);
244 }
245
246 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
247 if intent == Intent::Quit {
248 self.allow_close = true;
249 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
253 250 } else {
254 self.add_preset(relative.to_string());
251 self.run_intent(intent);
252 }
253 }
254
255 fn save(&mut self) {
256 if let Err(error) = self.session.save() {
257 self.session.fail(error.to_string());
255 258 }
256 259 }
257 260
258 fn inspect(&mut self, male: bool) {
259 let Some(root) = self.opened.as_ref().and_then(|opened| {
260 opened
261 .manifest_path
262 .parent()
263 .map(std::path::Path::to_path_buf)
264 }) else {
265 self.fail_text("open of maak eerst een project");
261 fn apply_render_fields(&mut self) {
262 let width = self.width_text.parse::<u32>().unwrap_or(0);
263 let height = self.height_text.parse::<u32>().unwrap_or(0);
264 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
265 self.session.set_render(width, height, fps);
266 }
267
268 fn start_render(&mut self, preview: [u32; 2]) {
269 if let Some(opened) = &self.session.opened {
270 if let Err(error) = opened.document.validate_shape() {
271 self.session.fail(error.to_string());
272 return;
273 }
274 }
275 let Some(character) = self.character().cloned() else {
276 self.session
277 .fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
266 278 return;
267 279 };
268 let relative = if male {
269 self.male.trim()
270 } else {
271 self.female.trim()
280 let Some(root) = self.session.project_root() else {
281 return;
272 282 };
273 if relative.is_empty() {
274 if male {
275 self.male_import = None;
276 } else {
277 self.female_import = None;
278 }
279 self.fail_text("dit slot heeft nog geen modelpad");
283 let Some(opened) = &self.session.opened else {
284 return;
285 };
286 if let Err(error) =
287 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
288 {
289 self.session.fail(error.to_string());
280 290 return;
281 291 }
282 match import_project_model(&root, relative) {
283 Ok(model) => {
284 let message = format!(
285 "{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
286 model.meshes.len(),
287 model.joints.len(),
288 model.clips.len()
289 );
290 if male {
291 self.male_import = Some(Ok(model));
292 } else {
293 self.female_import = Some(Ok(model));
292 let sex = match self.controls.sex {
293 PreviewSex::Male => "male",
294 PreviewSex::Female => "female",
295 };
296 let output = root
297 .join(&opened.document.output_directory)
298 .join(format!("body-{sex}-still.png"));
299 let job = RenderJob {
300 character,
301 controls: self.controls.clone(),
302 target_width: opened.document.render.target_width.max(1),
303 target_height: opened.document.render.target_height.max(1),
304 preview_width: preview[0].max(1),
305 preview_height: preview[1].max(1),
306 output,
307 };
308 let thread = spawn(job);
309 self.render_rx = Some(thread.rx);
310 self.render_ui = RenderUiState::idle();
311 self.render_ui.running = true;
312 let _ = thread.handle;
313 }
314
315 fn drain_render(&mut self) {
316 let Some(rx) = &self.render_rx else {
317 return;
318 };
319 loop {
320 let event = match rx.try_recv() {
321 Ok(event) => event,
322 Err(std::sync::mpsc::TryRecvError::Empty) => break,
323 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
324 let message = "De renderworker stopte zonder eindresultaat.".to_string();
325 self.session.fail(message.clone());
326 self.render_ui.apply(RenderEvent::Failed(message));
327 self.render_rx = None;
328 break;
294 329 }
295 self.ok(&message);
330 };
331 let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
332 if let RenderEvent::Finished { saved: Some(path) } = &event {
333 self.session.notice = format!("Standbeeld opgeslagen: {path}");
334 self.session.notice_ok = true;
296 335 }
297 Err(error) => {
298 let text = error.to_string();
299 if male {
300 self.male_import = Some(Err(text.clone()));
301 } else {
302 self.female_import = Some(Err(text.clone()));
303 }
304 self.fail_text(text);
336 if let RenderEvent::Failed(message) = &event {
337 self.session.fail(message.clone());
338 }
339 self.render_ui.apply(event);
340 if finished {
341 self.render_rx = None;
342 break;
305 343 }
306 344 }
307 345 }
308 346 }
309 347
348 impl Default for EditorApp {
349 fn default() -> Self {
350 Self::new(Box::new(SystemDialogs::new()))
351 }
352 }
353
310 354 impl eframe::App for EditorApp {
311 355 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
312 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
313 ui.horizontal_wrapped(|ui| {
314 if ui.button("Nieuw").clicked() {
315 self.create();
316 }
317 if ui.button("Open").clicked() {
318 self.open();
319 }
320 if ui.button("Opslaan").clicked() {
321 self.save();
322 }
323 if ui.button("Sluiten").clicked() {
324 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
325 }
326 ui.label(format!("format_version {FORMAT_VERSION}"));
327 });
328 });
356 let shell = self.session.shell_size();
357 if self.applied_shell != Some(shell) {
358 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
359 shell[0], shell[1],
360 )));
361 let min = if shell == crate::session::START_WINDOW {
362 egui::vec2(420.0, 220.0)
363 } else {
364 egui::vec2(900.0, 560.0)
365 };
366 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
367 self.applied_shell = Some(shell);
368 }
369 if ctx.input(|input| input.viewport().close_requested())
370 && !self.allow_close
371 && self.session.confirm.is_none()
372 {
373 if !self.session.gate(Intent::Quit) {
374 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
375 }
376 } else if self.session.confirm.is_some()
377 && ctx.input(|input| input.viewport().close_requested())
378 {
379 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
380 }
381 self.poll_dialogs();
382 self.drain_render();
383 if self.render_ui.running {
384 ctx.request_repaint();
385 }
329 386 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
330 let color = if self.notice_ok {
331 egui::Color32::from_rgb(20, 90, 40)
387 let color = if self.session.notice_ok {
388 egui::Color32::LIGHT_GREEN
332 389 } else {
333 egui::Color32::from_rgb(150, 30, 30)
390 egui::Color32::LIGHT_RED
334 391 };
335 ui.colored_label(color, &self.notice);
392 ui.colored_label(color, &self.session.notice);
336 393 });
337 egui::SidePanel::left("form")
338 .resizable(true)
339 .default_width(380.0)
340 .show(ctx, |ui| {
341 egui::ScrollArea::vertical().show(ui, |ui| self.show_form(ui));
342 });
343 egui::SidePanel::right("browser")
344 .min_width(220.0)
394 match self.session.screen {
395 Screen::Start => self.show_start(ctx),
396 Screen::Bodies => self.show_bodies(ctx),
397 Screen::Body(_) => self.show_body(ctx),
398 Screen::EquipmentList => self.show_equipment_list(ctx),
399 Screen::Equipment(_) => self.show_equipment(ctx),
400 }
401 self.show_confirm(ctx);
402 if self.dialog_pending {
403 ctx.request_repaint_after(std::time::Duration::from_millis(50));
404 }
405 }
406 }
407
408 impl EditorApp {
409 fn show_confirm(&mut self, ctx: &egui::Context) {
410 let Some(_intent) = self.session.confirm else {
411 return;
412 };
413 let mut choice = None;
414 egui::Window::new("Wijzigingen")
415 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
416 .collapsible(false)
417 .resizable(false)
345 418 .show(ctx, |ui| {
346 self.show_browser(ui);
419 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
420 ui.horizontal(|ui| {
421 if ui.button("Opslaan").clicked() {
422 choice = Some(crate::session::Choice::Save);
423 }
424 if ui.button("Niet opslaan").clicked() {
425 choice = Some(crate::session::Choice::Discard);
426 }
427 if ui.button("Annuleren").clicked() {
428 choice = Some(crate::session::Choice::Cancel);
429 }
430 });
347 431 });
432 if let Some(choice) = choice {
433 self.apply_choice(choice, ctx);
434 }
435 }
436
437 fn show_start(&mut self, ctx: &egui::Context) {
348 438 egui::CentralPanel::default().show(ctx, |ui| {
349 self.show_preview(ui);
439 if self.session.confirm.is_some() || self.dialog_pending {
440 ui.disable();
441 }
442 ui.heading("Paperdoll Renderer");
443 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
444 ui.add_space(12.0);
445 if ui.button("Project laden").clicked() {
446 self.begin(Intent::PickLoad);
447 }
448 if ui.button("Nieuw project").clicked() {
449 self.begin(Intent::PickNew);
450 }
350 451 });
351 452 }
352 }
353 453
354 impl EditorApp {
355 fn show_preview(&mut self, ui: &mut egui::Ui) {
356 ui.horizontal_wrapped(|ui| {
357 if ui.button("Laad testlichamen").clicked() {
358 self.load_fixture_bodies();
454 fn show_bodies(&mut self, ctx: &egui::Context) {
455 self.toolbar(ctx, true);
456 egui::CentralPanel::default().show(ctx, |ui| {
457 if self.session.confirm.is_some() || self.dialog_pending {
458 ui.disable();
359 459 }
360 if ui.button("3D van project").clicked() {
361 self.refresh_preview_from_project();
362 self.ok("De 3D-preview gebruikt de .glb- of .gltf-paden van man en vrouw.");
460 ui.heading("Body");
461 if let Some(opened) = &mut self.session.opened {
462 ui.horizontal(|ui| {
463 ui.label("Naam");
464 ui.text_edit_singleline(&mut opened.document.name);
465 });
363 466 }
467 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
468 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
469 ui.add_space(8.0);
470 ui.horizontal(|ui| {
471 ui.label("Breedte");
472 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
473 ui.label("Hoogte");
474 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
475 ui.label("fps");
476 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
477 });
478 self.apply_render_fields();
364 479 });
365 let character = match self.preview_controls.sex {
366 PreviewSex::Male => self.preview_male.as_ref(),
367 PreviewSex::Female => self.preview_female.as_ref(),
368 };
369 self.viewport
370 .show(ui, &mut self.preview_controls, character);
371 480 }
372 }
373 481
374 impl EditorApp {
375 fn show_form(&mut self, ui: &mut egui::Ui) {
376 ui.heading("Paperdoll Renderer");
377 ui.label(
378 "Links het projectbestand. In het midden draait de live 3D-preview van een glTF/GLB. Lees man of Lees vrouw toont het importoverzicht.",
379 );
380 ui.add_space(8.0);
381 ui.horizontal(|ui| {
382 ui.label("Projectmap");
383 ui.add(
384 egui::TextEdit::singleline(&mut self.root_input)
385 .desired_width(360.0)
386 .hint_text("/pad/naar/nieuwe-map"),
387 );
388 });
482 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
389 483 ui.horizontal(|ui| {
390 ui.label(".ppd openen");
391 ui.add(
392 egui::TextEdit::singleline(&mut self.open_input)
393 .desired_width(360.0)
394 .hint_text("/pad/naar/character.ppd"),
395 );
396 });
397 ui.separator();
398 ui.horizontal(|ui| {
399 ui.label("Naam");
400 ui.text_edit_singleline(&mut self.name);
401 });
402 ui.horizontal(|ui| {
403 ui.label("Man");
404 let male_edit = ui.add(
405 egui::TextEdit::singleline(&mut self.male)
406 .desired_width(360.0)
407 .hint_text("assets/body/male.glb"),
408 );
409 if male_edit.changed() {
410 self.male_import = None;
411 }
412 if ui.button("Wis man").clicked() {
413 self.male.clear();
414 self.male_import = None;
415 }
416 if ui.button("Lees man").clicked() {
417 self.inspect(true);
484 ui.label(title);
485 let path = self
486 .session
487 .body_path(sex)
488 .unwrap_or("geen model")
489 .to_string();
490 ui.label(path);
491 if ui.button(pick).clicked() {
492 self.dialogs.start(DialogPurpose::Body(sex));
418 493 }
419 });
420 show_import(ui, "Import man", self.male_import.as_ref());
421 ui.horizontal(|ui| {
422 ui.label("Vrouw");
423 let female_edit = ui.add(
424 egui::TextEdit::singleline(&mut self.female)
425 .desired_width(360.0)
426 .hint_text("assets/body/female.glb"),
427 );
428 if female_edit.changed() {
429 self.female_import = None;
430 }
431 if ui.button("Wis vrouw").clicked() {
432 self.female.clear();
433 self.female_import = None;
434 }
435 if ui.button("Lees vrouw").clicked() {
436 self.inspect(false);
494 if ui.button(open).clicked() {
495 self.controls.sex = match sex {
496 Sex::Male => PreviewSex::Male,
497 Sex::Female => PreviewSex::Female,
498 };
499 self.session.show_body(sex);
437 500 }
438 501 });
439 show_import(ui, "Import vrouw", self.female_import.as_ref());
440 ui.label("Equipment-presets, relatief aan de projectmap");
441 let mut remove = None;
442 for (index, preset) in self.presets.iter().enumerate() {
443 ui.horizontal(|ui| {
444 ui.label(preset);
445 if ui.button("Verwijder").clicked() {
446 remove = Some(index);
502 let index = if sex == Sex::Male { 0 } else { 1 };
503 if let Some(inventory) = &self.inventories[index] {
504 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
505 Ok(model) => {
506 for mesh in &model.meshes {
507 ui.label(format!(
508 "{}: {} vertices, {} driehoeken",
509 mesh.name, mesh.vertex_count, mesh.triangle_count
510 ));
511 }
512 for material in &model.materials {
513 ui.label(format!("materiaal {}", material.name));
514 }
515 for texture in &model.textures {
516 ui.label(format!(
517 "texture {}: {}×{}",
518 texture.source, texture.width, texture.height
519 ));
520 }
521 ui.label(format!("{} joints", model.joints.len()));
522 for clip in &model.clips {
523 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
524 }
525 for notice in &model.notices {
526 ui.label(notice);
527 }
528 }
529 Err(message) => {
530 ui.colored_label(egui::Color32::LIGHT_RED, message);
447 531 }
448 532 });
449 533 }
450 if let Some(index) = remove {
451 self.presets.remove(index);
452 }
453 ui.horizontal(|ui| {
454 ui.add(
455 egui::TextEdit::singleline(&mut self.preset_input)
456 .desired_width(280.0)
457 .hint_text("presets/zwaard.bin"),
458 );
459 if ui.button("Voeg preset toe").clicked() {
460 let value = self.preset_input.trim().to_string();
461 self.add_preset(value);
462 }
463 });
464 ui.horizontal(|ui| {
465 ui.label("Uitvoer");
466 ui.text_edit_singleline(&mut self.output);
467 });
468 ui.horizontal(|ui| {
469 ui.label("Breedte");
470 ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
471 ui.label("Hoogte");
472 ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
473 ui.label("fps");
474 ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
475 });
476 ui.add_space(8.0);
477 ui.label(
478 "Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.",
479 );
480 534 }
481 535
482 fn show_browser(&mut self, ui: &mut egui::Ui) {
483 ui.heading("Bestanden");
484 let Some(opened) = self.opened.clone() else {
485 ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
486 return;
536 fn show_body(&mut self, ctx: &egui::Context) {
537 self.toolbar(ctx, true);
538 let sex = match self.session.screen {
539 Screen::Body(sex) => sex,
540 _ => Sex::Male,
487 541 };
488 let root = opened
489 .manifest_path
490 .parent()
491 .map(PathBuf::from)
492 .unwrap_or_default();
493 ui.label(if self.browser.is_empty() {
494 "projectmap".to_string()
495 } else {
496 self.browser.clone()
497 });
498 if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
499 self.browser = parent_relative(&self.browser);
500 }
501 if ui.button("Deze map als uitvoer").clicked() {
502 if self.browser.is_empty() {
503 self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
504 } else {
505 self.output = self.browser.clone();
506 self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");
542 self.controls.sex = match sex {
543 Sex::Male => PreviewSex::Male,
544 Sex::Female => PreviewSex::Female,
545 };
546 egui::CentralPanel::default().show(ctx, |ui| {
547 if self.session.confirm.is_some() || self.dialog_pending {
548 ui.disable();
507 549 }
508 }
509 let entries = match list_dir(&root, &self.browser) {
510 Ok(entries) => entries,
511 Err(error) => {
512 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
513 return;
550 ui.horizontal_wrapped(|ui| {
551 if ui.button("Terug").clicked() {
552 self.session.show_bodies();
553 }
554 if ui.button("Equipment").clicked() {
555 self.session.show_equipment_list();
556 }
557 if ui
558 .add_enabled(!self.render_ui.running, egui::Button::new("Render"))
559 .clicked()
560 {
561 self.start_render([480, 640]);
562 }
563 if ui.button("Live 3D").clicked() {
564 self.render_ui.preview = None;
565 }
566 });
567 if self.render_ui.progress.total > 0 {
568 let fraction = self.render_ui.progress.completed as f32
569 / self.render_ui.progress.total.max(1) as f32;
570 ui.add(egui::ProgressBar::new(fraction).text(format!(
571 "{} / {} {}",
572 self.render_ui.progress.completed,
573 self.render_ui.progress.total,
574 self.render_ui.progress.label
575 )));
514 576 }
515 };
516 let mut enter = None;
517 let mut choose = None;
518 for entry in &entries {
519 let label = if entry.is_dir {
520 format!("{}/", entry.name)
577 if let Some(image) = &self.render_ui.preview {
578 self.paint_pass(ui, image);
521 579 } else {
522 entry.name.clone()
523 };
524 if ui.button(label).clicked() {
525 if entry.is_dir {
526 enter = Some(entry.relative.clone());
580 let character = match self.controls.sex {
581 PreviewSex::Male => self.preview_male.as_ref(),
582 PreviewSex::Female => self.preview_female.as_ref(),
583 };
584 if character.is_some() {
585 self.viewport.show(ui, &mut self.controls, character);
527 586 } else {
528 choose = Some(entry.relative.clone());
587 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
529 588 }
530 589 }
531 }
532 if let Some(relative) = enter {
533 self.browser = relative;
534 }
535 if let Some(relative) = choose {
536 self.assign_file(&relative);
537 }
590 });
538 591 }
539 }
540 592
541 fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
542 let Some(report) = report else {
543 return;
544 };
545 ui.group(|ui| {
546 ui.strong(title);
547 match report {
548 Ok(model) => {
549 let format = match model.format {
550 ModelFormat::Gltf => "glTF/GLB",
551 ModelFormat::Obj => "OBJ",
552 ModelFormat::Fbx => "FBX",
553 };
554 ui.label(format);
555 for mesh in &model.meshes {
556 ui.label(format!(
557 "mesh {}: {} vertices, {} driehoeken{}",
558 mesh.name,
559 mesh.vertex_count,
560 mesh.triangle_count,
561 if mesh.has_skin { ", skin" } else { "" }
562 ));
593 fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
594 let color = egui::ColorImage::from_rgba_unmultiplied(
595 [image.width as usize, image.height as usize],
596 &image.rgba,
597 );
598 let texture = ui.ctx().load_texture(
599 "render-pass",
600 color,
601 egui::TextureOptions {
602 magnification: egui::TextureFilter::Nearest,
603 minification: egui::TextureFilter::Nearest,
604 wrap_mode: egui::TextureWrapMode::ClampToEdge,
605 ..Default::default()
606 },
607 );
608 let available = ui.available_size();
609 ui.image((texture.id(), available));
610 }
611
612 fn show_equipment_list(&mut self, ctx: &egui::Context) {
613 self.toolbar(ctx, true);
614 egui::CentralPanel::default().show(ctx, |ui| {
615 if self.session.confirm.is_some() || self.dialog_pending {
616 ui.disable();
617 }
618 ui.heading(format!(
619 "Bodies → {} → Equipment",
620 match self.session.active_body {
621 Some(Sex::Male) => "Man",
622 Some(Sex::Female) => "Vrouw",
623 None => "Body",
563 624 }
564 for material in &model.materials {
565 ui.label(format!(
566 "materiaal {}{}",
567 material.name,
568 material
569 .base_color_texture
570 .as_ref()
571 .map(|name| format!(", texture {name}"))
572 .unwrap_or_default()
573 ));
625 ));
626 if ui.button("Terug naar body").clicked() {
627 self.session.back();
628 }
629 let mut open = None;
630 for (index, item) in self.session.equipment.iter().enumerate() {
631 let label = match &item.model {
632 Some(model) => format!("{} {model}", item.slug),
633 None => item.slug.clone(),
634 };
635 if ui.button(label).clicked() {
636 open = Some(index);
574 637 }
575 for texture in &model.textures {
576 ui.label(format!(
577 "texture {} ({}x{})",
578 texture.source, texture.width, texture.height
579 ));
638 }
639 if ui.button("Nieuw").clicked() {
640 self.session.add_equipment();
641 }
642 if let Some(index) = open {
643 self.session.show_equipment(index);
644 }
645 });
646 }
647
648 fn show_equipment(&mut self, ctx: &egui::Context) {
649 self.toolbar(ctx, true);
650 let Screen::Equipment(index) = self.session.screen else {
651 return;
652 };
653 egui::CentralPanel::default().show(ctx, |ui| {
654 if self.session.confirm.is_some() || self.dialog_pending {
655 ui.disable();
656 }
657 ui.heading("Equipment");
658 if ui.button("Terug").clicked() {
659 self.session.show_equipment_list();
660 }
661 if let Some(item) = self.session.equipment.get_mut(index) {
662 ui.horizontal(|ui| {
663 ui.label("Naam");
664 ui.text_edit_singleline(&mut item.slug);
665 });
666 let model = item
667 .model
668 .clone()
669 .unwrap_or_else(|| "geen model".to_string());
670 ui.label(model);
671 }
672 if ui.button("Kies model").clicked() {
673 self.dialogs.start(DialogPurpose::EquipmentModel(index));
674 }
675 if ui.button("Verwijder").clicked() {
676 self.session.delete_equipment(index);
677 }
678 });
679 }
680
681 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
682 if !project {
683 return;
684 }
685 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
686 if self.session.confirm.is_some() || self.dialog_pending {
687 ui.disable();
688 }
689 ui.horizontal(|ui| {
690 if ui.button("Body").clicked() {
691 self.session.show_bodies();
580 692 }
581 if model.joints.is_empty() {
582 ui.label("geen skeleton");
583 } else {
584 ui.label(format!("{} joints", model.joints.len()));
585 for joint in model.joints.iter().take(8) {
586 match &joint.parent {
587 Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
588 None => ui.label(format!("{} (wortel)", joint.name)),
589 };
590 }
693 if ui.button("Opslaan").clicked() {
694 self.save();
591 695 }
592 if model.clips.is_empty() {
593 ui.label("geen animation clips");
594 } else {
595 for clip in &model.clips {
596 ui.label(format!(
597 "clip {} ({:.2} s, {} kanalen)",
598 clip.name, clip.duration_seconds, clip.channel_count
599 ));
600 }
696 if ui
697 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
698 .clicked()
699 {
700 self.begin(Intent::PickNew);
601 701 }
602 for notice in &model.notices {
603 ui.label(notice);
702 if ui
703 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
704 .clicked()
705 {
706 self.begin(Intent::PickLoad);
604 707 }
605 }
606 Err(message) => {
607 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
608 }
609 }
610 });
708 });
709 });
710 }
611 711 }
612 712
613 fn load_preview_path(root: &std::path::Path, relative: &str) -> Option<PreviewCharacter> {
614 let relative = relative.trim();
615 let lower = relative.to_ascii_lowercase();
616 if !(lower.ends_with(".glb") || lower.ends_with(".gltf")) {
617 return None;
713 #[cfg(test)]
714 mod tests {
715 use super::*;
716 use crate::dialogs::ScriptedDialogs;
717 use crate::session::Choice;
718
719 fn root() -> PathBuf {
720 std::env::temp_dir().join(format!(
721 "paperdoll-editor-{}",
722 std::time::SystemTime::now()
723 .duration_since(std::time::UNIX_EPOCH)
724 .unwrap()
725 .as_nanos()
726 ))
618 727 }
619 load_gltf_file(&root.join(relative)).ok()
620 }
621 728
622 fn parent_relative(relative: &str) -> String {
623 match relative.rfind('/') {
624 Some(index) => relative[..index].to_string(),
625 None => String::new(),
729 #[test]
730 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
731 let root = root();
732 let mut dialogs = ScriptedDialogs::default();
733 dialogs.push(Some(root.clone()));
734 dialogs.push(None);
735 let mut app = EditorApp::new(Box::new(dialogs));
736 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
737 app.begin(Intent::PickNew);
738 app.poll_dialogs();
739 assert_eq!(app.session.screen, Screen::Bodies);
740 assert!(app.preview_male.is_some());
741 assert!(app.preview_female.is_some());
742 app.session.set_name("Keep my edit".into());
743 app.begin(Intent::PickLoad);
744 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
745 app.apply_choice(Choice::Discard, &egui::Context::default());
746 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
747 assert_eq!(
748 app.session.opened.as_ref().unwrap().document.name,
749 "Keep my edit"
750 );
751 assert!(app.session.is_dirty());
752 let _ = std::fs::remove_dir_all(root);
626 753 }
627 }
628 754
629 fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
630 let text = text.trim();
631 if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
632 return Err(format!("{label} moet een geheel getal zijn"));
755 #[test]
756 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
757 let root = root();
758 let mut app = EditorApp::default();
759 app.session.install_new_project(&root).unwrap();
760 app.session.set_render(0, 128, 12);
761 app.begin(Intent::Quit);
762 app.apply_choice(Choice::Save, &egui::Context::default());
763 assert!(!app.allow_close);
764 assert_eq!(app.session.confirm, Some(Intent::Quit));
765 assert!(app.session.is_dirty());
766 app.open_path(root.join("missing.ppd"));
767 assert!(app.session.opened.is_some());
768 assert_eq!(app.session.screen, Screen::Bodies);
769 let _ = std::fs::remove_dir_all(root);
633 770 }
634 text.parse::<u32>()
635 .map_err(|_| format!("{label} is te groot"))
636 }
637 771
638 impl Default for EditorApp {
639 fn default() -> Self {
640 Self::new()
772 #[test]
773 fn open_with_project_path_loads_bodies_and_models() {
774 let root = root().join("project with spaces");
775 let opened = crate::session::create_seeded_project(&root).unwrap();
776 let mut app = EditorApp::default();
777 app.open_path(opened.manifest_path.clone());
778 assert_eq!(app.session.screen, Screen::Bodies);
779 assert!(app.preview_male.is_some());
780 assert_eq!(
781 app.session.opened.unwrap().manifest_path,
782 opened.manifest_path
783 );
784 let _ = std::fs::remove_dir_all(root.parent().unwrap());
785 }
786
787 #[test]
788 fn renderer_disconnect_is_reported() {
789 let mut app = EditorApp::default();
790 let (tx, rx) = std::sync::mpsc::channel();
791 app.render_rx = Some(rx);
792 app.render_ui.running = true;
793 drop(tx);
794 app.drain_render();
795 assert!(!app.render_ui.running);
796 assert!(app.render_ui.error.is_some());
641 797 }
642 798 }
@@ -1,642 +1,798 @@
1//! Project window. It edits a `.ppd` manifest and does not import or render.
1//! Project window: start screen, body, equipment, and the render queue.
2
2
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};
9use crate::import::{import_project_model, ImportedModel};
8use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
9use crate::preview_gl::Viewport;
11use crate::preview_gl::Viewport;
10use crate::project::{
12use crate::project::open_project;
11 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
13use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
12 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
14use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
13};
14
15
15pub struct EditorApp {
16pub struct EditorApp {
16 root_input: String,
17 session: Session,
17 open_input: String,
18 dialogs: Box<dyn Dialogs>,
18 opened: Option<OpenedProject>,
19 controls: PreviewControls,
19 name: String,
20 male: String,
21 female: String,
22 presets: Vec<String>,
23 preset_input: String,
24 output: String,
25 width: String,
26 height: String,
27 fps: String,
28 browser: String,
29 male_import: Option<Result<ImportedModel, String>>,
30 female_import: Option<Result<ImportedModel, String>>,
31 preview_controls: PreviewControls,
32 preview_male: Option<PreviewCharacter>,
20 preview_male: Option<PreviewCharacter>,
33 preview_female: Option<PreviewCharacter>,
21 preview_female: Option<PreviewCharacter>,
34 viewport: Viewport,
22 viewport: Viewport,
35 notice: String,
23 inventories: [Option<Result<ImportedModel, String>>; 2],
36 notice_ok: bool,
24 render_rx: Option<Receiver<RenderEvent>>,
25 render_ui: RenderUiState,
26 applied_shell: Option<[f32; 2]>,
27 width_text: String,
28 height_text: String,
29 fps_text: String,
30 allow_close: bool,
31 dialog_pending: bool,
37}
32}
38
33
39impl EditorApp {
34impl EditorApp {
40 pub fn new() -> Self {
35 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
41 Self {
36 Self {
42 root_input: String::new(),
37 session: Session::new(),
43 open_input: String::new(),
38 dialogs,
44 opened: None,
39 controls: PreviewControls::default(),
45 name: "Nieuw project".to_string(),
46 male: String::new(),
47 female: String::new(),
48 presets: Vec::new(),
49 preset_input: String::new(),
50 output: "renders".to_string(),
51 width: "128".to_string(),
52 height: "128".to_string(),
53 fps: "12".to_string(),
54 browser: String::new(),
55 male_import: None,
56 female_import: None,
57 preview_controls: PreviewControls::default(),
58 preview_male: None,
40 preview_male: None,
59 preview_female: None,
41 preview_female: None,
60 viewport: Viewport::new(),
42 viewport: Viewport::new(),
61 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
43 inventories: [None, None],
62 notice_ok: true,
44 render_rx: None,
45 render_ui: RenderUiState::idle(),
46 applied_shell: None,
47 width_text: "128".to_string(),
48 height_text: "128".to_string(),
49 fps_text: "12".to_string(),
50 allow_close: false,
51 dialog_pending: false,
63 }
52 }
64 }
53 }
65
54
66 fn load(&mut self, opened: OpenedProject, notice: &str) {
55 pub fn open_path(&mut self, path: PathBuf) {
67 self.name = opened.document.name.clone();
56 let candidate = std::env::current_dir()
68 self.male = opened.document.male_model.clone().unwrap_or_default();
57 .map_err(|e| e.to_string())
69 self.female = opened.document.female_model.clone().unwrap_or_default();
58 .and_then(|base| {
70 self.presets = opened.document.equipment_presets.clone();
59 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
71 self.output = opened.document.output_directory.clone();
60 });
72 self.width = opened.document.render.target_width.to_string();
61 let path = match candidate {
73 self.height = opened.document.render.target_height.to_string();
62 Ok(path) => path,
74 self.fps = opened.document.render.fps.to_string();
63 Err(error) => {
75 self.browser.clear();
64 self.session.fail(error);
76 self.male_import = None;
65 return;
77 self.female_import = None;
66 }
78 self.opened = Some(opened.clone());
67 };
79 let male = self.male.clone();
68 match open_project(&path) {
80 let female = self.female.clone();
69 Ok(opened) => {
81 if !male.trim().is_empty() {
70 self.session.install_loaded(opened);
82 self.inspect(true);
71 self.reset_render();
83 }
72 self.sync_fields();
84 if !female.trim().is_empty() {
73 self.reload_bodies();
85 self.inspect(false);
74 }
86 }
75 Err(error) => {
87 self.refresh_preview_from_project();
76 self.session.fail(error.to_string());
88 if male.trim().is_empty() && female.trim().is_empty() {
89 if opened.disk_issues.is_empty() {
90 self.ok(notice);
91 } else {
92 self.fail_text(opened.disk_issues.join("\n"));
93 }
77 }
94 }
78 }
95 }
79 }
96
80
97 pub fn load_fixture_bodies(&mut self) {
81 pub fn open_fixture_project(&mut self) {
98 let root =
82 let root = std::env::temp_dir().join(format!(
99 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies");
83 "paperdoll-renderer-fixture-{}-{}",
100 match load_gltf_file(&root.join("test-body-male.glb")) {
84 std::process::id(),
101 Ok(character) => self.preview_male = Some(character),
85 std::time::SystemTime::now()
102 Err(error) => self.fail_text(format!("testman: {error}")),
86 .duration_since(std::time::UNIX_EPOCH)
103 }
87 .unwrap_or_default()
104 match load_gltf_file(&root.join("test-body-female.glb")) {
88 .as_nanos()
105 Ok(character) => self.preview_female = Some(character),
89 ));
106 Err(error) => self.fail_text(format!("testvrouw: {error}")),
90 if let Err(error) = self.session.install_new_project(&root) {
107 }
91 self.session.fail(error.to_string());
108 if self.preview_male.is_some() {
109 self.preview_controls.sex = PreviewSex::Male;
110 self.preview_controls.select_clip("walk");
111 self.ok("Testlichamen staan in de 3D-preview.");
112 }
113 }
114
115 fn refresh_preview_from_project(&mut self) {
116 let Some(root) = self.opened.as_ref().and_then(|opened| {
117 opened
118 .manifest_path
119 .parent()
120 .map(std::path::Path::to_path_buf)
121 }) else {
122 return;
92 return;
123 };
93 }
124 self.preview_male = load_preview_path(&root, &self.male);
94 self.sync_fields();
125 self.preview_female = load_preview_path(&root, &self.female);
95 self.reload_bodies();
96 self.session.show_body(Sex::Male);
97 self.controls.sex = PreviewSex::Male;
98 self.controls.select_clip("walk");
126 }
99 }
127
100
128 fn ok(&mut self, message: &str) {
101 fn reset_render(&mut self) {
129 self.notice = message.to_string();
102 self.render_rx = None;
130 self.notice_ok = true;
103 self.render_ui = RenderUiState::idle();
131 }
104 }
132
105
133 fn fail(&mut self, error: ProjectError) {
106 fn sync_fields(&mut self) {
134 self.notice = error.to_string();
107 if let Some(opened) = &self.session.opened {
135 self.notice_ok = false;
108 self.width_text = opened.document.render.target_width.to_string();
109 self.height_text = opened.document.render.target_height.to_string();
110 self.fps_text = opened.document.render.fps.to_string();
111 }
136 }
112 }
137
113
138 fn fail_text(&mut self, message: impl Into<String>) {
114 fn reload_bodies(&mut self) {
139 self.notice = message.into();
115 self.preview_male = None;
140 self.notice_ok = false;
116 self.preview_female = None;
117 self.inventories = [None, None];
118 let Some(root) = self.session.project_root() else {
119 return;
120 };
121 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
122 if let Some(relative) = self.session.body_path(sex) {
123 let imported =
124 import_project_model(&root, relative).map_err(|error| error.to_string());
125 // Validate the source and its dependencies before the viewport reads it.
126 if imported.is_ok()
127 && matches!(
128 std::path::Path::new(relative)
129 .extension()
130 .and_then(|e| e.to_str()),
131 Some("gltf" | "glb")
132 )
133 {
134 match load_gltf_file(&root.join(relative)) {
135 Ok(character) => match sex {
136 Sex::Male => self.preview_male = Some(character),
137 Sex::Female => self.preview_female = Some(character),
138 },
139 Err(error) => self.session.fail(format!("3D: {error}")),
140 }
141 }
142 self.inventories[index] = Some(imported);
143 }
144 }
141 }
145 }
142
146
143 fn draft(&self) -> Result<ProjectDraft, String> {
147 fn character(&self) -> Option<&PreviewCharacter> {
144 let opened = self
148 match self.controls.sex {
145 .opened
149 PreviewSex::Male => self.preview_male.as_ref(),
146 .as_ref()
150 PreviewSex::Female => self.preview_female.as_ref(),
147 .ok_or_else(|| "open of maak eerst een project".to_string())?;
151 }
148 Ok(ProjectDraft {
149 id: opened.document.id.clone(),
150 name: self.name.clone(),
151 male_model: self.male.clone(),
152 female_model: self.female.clone(),
153 equipment_presets: self.presets.clone(),
154 output_directory: self.output.clone(),
155 target_width: parse_u32("breedte", &self.width)?,
156 target_height: parse_u32("hoogte", &self.height)?,
157 fps: parse_u32("fps", &self.fps)?,
158 })
159 }
152 }
160
153
161 fn create(&mut self) {
154 fn poll_dialogs(&mut self) {
162 let root = PathBuf::from(self.root_input.trim());
155 let poll = self.dialogs.poll();
163 if self.root_input.trim().is_empty() {
156 self.dialog_pending = matches!(poll, DialogPoll::Pending);
164 self.fail_text("vul de projectmap in");
157 if let DialogPoll::Ready { purpose, path } = poll {
165 return;
158 let Some(path) = path else {
166 }
159 return;
167 match create_project(&root, "Nieuw project") {
160 };
168 Ok(opened) => self.load(
161 match purpose {
169 opened,
162 DialogPurpose::NewProject => {
170 "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
163 if let Err(error) = self.session.install_new_project(&path) {
171 ),
164 self.session.fail(error.to_string());
172 Err(error) => self.fail(error),
165 } else {
166 self.reset_render();
167 self.sync_fields();
168 self.reload_bodies();
169 }
170 }
171 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
172 Ok(opened) => {
173 self.session.install_loaded(opened);
174 self.reset_render();
175 self.sync_fields();
176 self.reload_bodies();
177 }
178 Err(error) => self.session.fail(error.to_string()),
179 },
180 DialogPurpose::Body(sex) => self.take_model(sex, &path),
181 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
182 }
173 }
183 }
174 }
184 }
175
185
176 fn open(&mut self) {
186 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
177 let path = PathBuf::from(self.open_input.trim());
187 let Some(root) = self.session.project_root() else {
178 if self.open_input.trim().is_empty() {
179 self.fail_text("vul het pad naar een .ppd-bestand in");
180 return;
188 return;
181 }
189 };
182 match open_project(&path) {
190 match adopt_file(&root, path, "assets/body") {
183 Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
191 Ok(relative) => {
184 Err(error) => self.fail(error),
192 self.session.set_body_path(sex, relative.clone());
193 self.reload_bodies();
194 self.reset_render();
195 let index = if sex == Sex::Male { 0 } else { 1 };
196 if let Some(Err(error)) = &self.inventories[index] {
197 self.session.fail(format!("{relative}: {error}"));
198 } else {
199 self.session.notice = format!("{relative} staat in het project.");
200 self.session.notice_ok = true;
201 }
202 }
203 Err(error) => self.session.fail(error),
185 }
204 }
186 }
205 }
187
206
188 fn save(&mut self) {
207 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
189 let Some(current) = self.opened.clone() else {
208 let Some(root) = self.session.project_root() else {
190 self.fail_text("open of maak eerst een project");
191 return;
209 return;
192 };
210 };
193 let draft = match self.draft() {
211 match adopt_file(&root, path, "assets/equipment") {
194 Ok(draft) => draft,
212 Ok(relative) => {
195 Err(message) => {
213 self.session.set_equipment_model(index, relative.clone());
196 self.fail_text(message);
214 self.session.notice = format!("{relative} hoort bij deze equipment.");
197 return;
215 self.session.notice_ok = true;
198 }
199 };
200 let document = match assemble(draft) {
201 Ok(document) => document,
202 Err(error) => {
203 self.fail(error);
204 return;
205 }
216 }
206 };
217 Err(error) => self.session.fail(error),
207 let candidate = OpenedProject {
208 manifest_path: current.manifest_path,
209 document,
210 disk_issues: Vec::new(),
211 };
212 match save_project(&candidate) {
213 Ok(()) => {
214 let path = candidate.manifest_path.display().to_string();
215 self.opened = Some(candidate);
216 self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
217 }
218 Err(error) => self.fail(error),
219 }
218 }
220 }
219 }
221
220
222 fn add_preset(&mut self, relative: String) {
221 fn begin(&mut self, intent: Intent) {
223 if relative.trim().is_empty() {
222 if self.session.gate(intent) {
224 return;
223 self.run_intent(intent);
225 }
224 }
226 self.presets.push(relative);
225 }
227 if let Err(error) = self
226
228 .draft()
227 fn run_intent(&mut self, intent: Intent) {
229 .and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
228 match intent {
230 {
229 Intent::Quit => {}
231 self.presets.pop();
230 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
232 self.fail_text(error);
231 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
233 } else {
234 self.preset_input.clear();
235 self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");
236 }
232 }
237 }
233 }
238
234
239 fn assign_file(&mut self, relative: &str) {
235 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
240 if relative == MANIFEST_NAME {
236 match self.session.resolve(choice) {
241 self.fail_text("character.ppd is het projectbestand, geen model of preset");
237 AfterChoice::Stay => {}
242 return;
238 AfterChoice::SaveThen(intent) => match self.session.save() {
239 Ok(()) => self.finish_intent(intent, ctx),
240 Err(error) => self.session.fail(error.to_string()),
241 },
242 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
243 }
243 }
244 let lower = relative.to_ascii_lowercase();
244 }
245 if lower.contains("/preset") || lower.starts_with("presets/") {
245
246 self.add_preset(relative.to_string());
246 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
247 } else if self.male.trim().is_empty() {
247 if intent == Intent::Quit {
248 self.male = relative.to_string();
248 self.allow_close = true;
249 self.inspect(true);
249 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
250 } else if self.female.trim().is_empty() {
251 self.female = relative.to_string();
252 self.inspect(false);
253 } else {
250 } else {
254 self.add_preset(relative.to_string());
251 self.run_intent(intent);
252 }
253 }
254
255 fn save(&mut self) {
256 if let Err(error) = self.session.save() {
257 self.session.fail(error.to_string());
255 }
258 }
256 }
259 }
257
260
258 fn inspect(&mut self, male: bool) {
261 fn apply_render_fields(&mut self) {
259 let Some(root) = self.opened.as_ref().and_then(|opened| {
262 let width = self.width_text.parse::<u32>().unwrap_or(0);
260 opened
263 let height = self.height_text.parse::<u32>().unwrap_or(0);
261 .manifest_path
264 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
262 .parent()
265 self.session.set_render(width, height, fps);
263 .map(std::path::Path::to_path_buf)
266 }
264 }) else {
267
265 self.fail_text("open of maak eerst een project");
268 fn start_render(&mut self, preview: [u32; 2]) {
269 if let Some(opened) = &self.session.opened {
270 if let Err(error) = opened.document.validate_shape() {
271 self.session.fail(error.to_string());
272 return;
273 }
274 }
275 let Some(character) = self.character().cloned() else {
276 self.session
277 .fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
266 return;
278 return;
267 };
279 };
268 let relative = if male {
280 let Some(root) = self.session.project_root() else {
269 self.male.trim()
281 return;
270 } else {
271 self.female.trim()
272 };
282 };
273 if relative.is_empty() {
283 let Some(opened) = &self.session.opened else {
274 if male {
284 return;
275 self.male_import = None;
285 };
276 } else {
286 if let Err(error) =
277 self.female_import = None;
287 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
278 }
288 {
279 self.fail_text("dit slot heeft nog geen modelpad");
289 self.session.fail(error.to_string());
280 return;
290 return;
281 }
291 }
282 match import_project_model(&root, relative) {
292 let sex = match self.controls.sex {
283 Ok(model) => {
293 PreviewSex::Male => "male",
284 let message = format!(
294 PreviewSex::Female => "female",
285 "{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
295 };
286 model.meshes.len(),
296 let output = root
287 model.joints.len(),
297 .join(&opened.document.output_directory)
288 model.clips.len()
298 .join(format!("body-{sex}-still.png"));
289 );
299 let job = RenderJob {
290 if male {
300 character,
291 self.male_import = Some(Ok(model));
301 controls: self.controls.clone(),
292 } else {
302 target_width: opened.document.render.target_width.max(1),
293 self.female_import = Some(Ok(model));
303 target_height: opened.document.render.target_height.max(1),
304 preview_width: preview[0].max(1),
305 preview_height: preview[1].max(1),
306 output,
307 };
308 let thread = spawn(job);
309 self.render_rx = Some(thread.rx);
310 self.render_ui = RenderUiState::idle();
311 self.render_ui.running = true;
312 let _ = thread.handle;
313 }
314
315 fn drain_render(&mut self) {
316 let Some(rx) = &self.render_rx else {
317 return;
318 };
319 loop {
320 let event = match rx.try_recv() {
321 Ok(event) => event,
322 Err(std::sync::mpsc::TryRecvError::Empty) => break,
323 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
324 let message = "De renderworker stopte zonder eindresultaat.".to_string();
325 self.session.fail(message.clone());
326 self.render_ui.apply(RenderEvent::Failed(message));
327 self.render_rx = None;
328 break;
294 }
329 }
295 self.ok(&message);
330 };
331 let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
332 if let RenderEvent::Finished { saved: Some(path) } = &event {
333 self.session.notice = format!("Standbeeld opgeslagen: {path}");
334 self.session.notice_ok = true;
296 }
335 }
297 Err(error) => {
336 if let RenderEvent::Failed(message) = &event {
298 let text = error.to_string();
337 self.session.fail(message.clone());
299 if male {
338 }
300 self.male_import = Some(Err(text.clone()));
339 self.render_ui.apply(event);
301 } else {
340 if finished {
302 self.female_import = Some(Err(text.clone()));
341 self.render_rx = None;
303 }
342 break;
304 self.fail_text(text);
305 }
343 }
306 }
344 }
307 }
345 }
308}
346}
309
347
348impl Default for EditorApp {
349 fn default() -> Self {
350 Self::new(Box::new(SystemDialogs::new()))
351 }
352}
353
310impl eframe::App for EditorApp {
354impl eframe::App for EditorApp {
311 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
355 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
312 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
356 let shell = self.session.shell_size();
313 ui.horizontal_wrapped(|ui| {
357 if self.applied_shell != Some(shell) {
314 if ui.button("Nieuw").clicked() {
358 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
315 self.create();
359 shell[0], shell[1],
316 }
360 )));
317 if ui.button("Open").clicked() {
361 let min = if shell == crate::session::START_WINDOW {
318 self.open();
362 egui::vec2(420.0, 220.0)
319 }
363 } else {
320 if ui.button("Opslaan").clicked() {
364 egui::vec2(900.0, 560.0)
321 self.save();
365 };
322 }
366 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
323 if ui.button("Sluiten").clicked() {
367 self.applied_shell = Some(shell);
324 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
368 }
325 }
369 if ctx.input(|input| input.viewport().close_requested())
326 ui.label(format!("format_version {FORMAT_VERSION}"));
370 && !self.allow_close
327 });
371 && self.session.confirm.is_none()
328 });
372 {
373 if !self.session.gate(Intent::Quit) {
374 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
375 }
376 } else if self.session.confirm.is_some()
377 && ctx.input(|input| input.viewport().close_requested())
378 {
379 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
380 }
381 self.poll_dialogs();
382 self.drain_render();
383 if self.render_ui.running {
384 ctx.request_repaint();
385 }
329 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
386 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
330 let color = if self.notice_ok {
387 let color = if self.session.notice_ok {
331 egui::Color32::from_rgb(20, 90, 40)
388 egui::Color32::LIGHT_GREEN
332 } else {
389 } else {
333 egui::Color32::from_rgb(150, 30, 30)
390 egui::Color32::LIGHT_RED
334 };
391 };
335 ui.colored_label(color, &self.notice);
392 ui.colored_label(color, &self.session.notice);
336 });
393 });
337 egui::SidePanel::left("form")
394 match self.session.screen {
338 .resizable(true)
395 Screen::Start => self.show_start(ctx),
339 .default_width(380.0)
396 Screen::Bodies => self.show_bodies(ctx),
340 .show(ctx, |ui| {
397 Screen::Body(_) => self.show_body(ctx),
341 egui::ScrollArea::vertical().show(ui, |ui| self.show_form(ui));
398 Screen::EquipmentList => self.show_equipment_list(ctx),
342 });
399 Screen::Equipment(_) => self.show_equipment(ctx),
343 egui::SidePanel::right("browser")
400 }
344 .min_width(220.0)
401 self.show_confirm(ctx);
402 if self.dialog_pending {
403 ctx.request_repaint_after(std::time::Duration::from_millis(50));
404 }
405 }
406}
407
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)
345 .show(ctx, |ui| {
418 .show(ctx, |ui| {
346 self.show_browser(ui);
419 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
420 ui.horizontal(|ui| {
421 if ui.button("Opslaan").clicked() {
422 choice = Some(crate::session::Choice::Save);
423 }
424 if ui.button("Niet opslaan").clicked() {
425 choice = Some(crate::session::Choice::Discard);
426 }
427 if ui.button("Annuleren").clicked() {
428 choice = Some(crate::session::Choice::Cancel);
429 }
430 });
347 });
431 });
432 if let Some(choice) = choice {
433 self.apply_choice(choice, ctx);
434 }
435 }
436
437 fn show_start(&mut self, ctx: &egui::Context) {
348 egui::CentralPanel::default().show(ctx, |ui| {
438 egui::CentralPanel::default().show(ctx, |ui| {
349 self.show_preview(ui);
439 if self.session.confirm.is_some() || self.dialog_pending {
440 ui.disable();
441 }
442 ui.heading("Paperdoll Renderer");
443 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
444 ui.add_space(12.0);
445 if ui.button("Project laden").clicked() {
446 self.begin(Intent::PickLoad);
447 }
448 if ui.button("Nieuw project").clicked() {
449 self.begin(Intent::PickNew);
450 }
350 });
451 });
351 }
452 }
352}
353
453
354impl EditorApp {
454 fn show_bodies(&mut self, ctx: &egui::Context) {
355 fn show_preview(&mut self, ui: &mut egui::Ui) {
455 self.toolbar(ctx, true);
356 ui.horizontal_wrapped(|ui| {
456 egui::CentralPanel::default().show(ctx, |ui| {
357 if ui.button("Laad testlichamen").clicked() {
457 if self.session.confirm.is_some() || self.dialog_pending {
358 self.load_fixture_bodies();
458 ui.disable();
359 }
459 }
360 if ui.button("3D van project").clicked() {
460 ui.heading("Body");
361 self.refresh_preview_from_project();
461 if let Some(opened) = &mut self.session.opened {
362 self.ok("De 3D-preview gebruikt de .glb- of .gltf-paden van man en vrouw.");
462 ui.horizontal(|ui| {
463 ui.label("Naam");
464 ui.text_edit_singleline(&mut opened.document.name);
465 });
363 }
466 }
467 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
468 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
469 ui.add_space(8.0);
470 ui.horizontal(|ui| {
471 ui.label("Breedte");
472 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
473 ui.label("Hoogte");
474 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
475 ui.label("fps");
476 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
477 });
478 self.apply_render_fields();
364 });
479 });
365 let character = match self.preview_controls.sex {
366 PreviewSex::Male => self.preview_male.as_ref(),
367 PreviewSex::Female => self.preview_female.as_ref(),
368 };
369 self.viewport
370 .show(ui, &mut self.preview_controls, character);
371 }
480 }
372}
373
481
374impl EditorApp {
482 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
375 fn show_form(&mut self, ui: &mut egui::Ui) {
376 ui.heading("Paperdoll Renderer");
377 ui.label(
378 "Links het projectbestand. In het midden draait de live 3D-preview van een glTF/GLB. Lees man of Lees vrouw toont het importoverzicht.",
379 );
380 ui.add_space(8.0);
381 ui.horizontal(|ui| {
382 ui.label("Projectmap");
383 ui.add(
384 egui::TextEdit::singleline(&mut self.root_input)
385 .desired_width(360.0)
386 .hint_text("/pad/naar/nieuwe-map"),
387 );
388 });
389 ui.horizontal(|ui| {
483 ui.horizontal(|ui| {
390 ui.label(".ppd openen");
484 ui.label(title);
391 ui.add(
485 let path = self
392 egui::TextEdit::singleline(&mut self.open_input)
486 .session
393 .desired_width(360.0)
487 .body_path(sex)
394 .hint_text("/pad/naar/character.ppd"),
488 .unwrap_or("geen model")
395 );
489 .to_string();
396 });
490 ui.label(path);
397 ui.separator();
491 if ui.button(pick).clicked() {
398 ui.horizontal(|ui| {
492 self.dialogs.start(DialogPurpose::Body(sex));
399 ui.label("Naam");
400 ui.text_edit_singleline(&mut self.name);
401 });
402 ui.horizontal(|ui| {
403 ui.label("Man");
404 let male_edit = ui.add(
405 egui::TextEdit::singleline(&mut self.male)
406 .desired_width(360.0)
407 .hint_text("assets/body/male.glb"),
408 );
409 if male_edit.changed() {
410 self.male_import = None;
411 }
412 if ui.button("Wis man").clicked() {
413 self.male.clear();
414 self.male_import = None;
415 }
416 if ui.button("Lees man").clicked() {
417 self.inspect(true);
418 }
493 }
419 });
494 if ui.button(open).clicked() {
420 show_import(ui, "Import man", self.male_import.as_ref());
495 self.controls.sex = match sex {
421 ui.horizontal(|ui| {
496 Sex::Male => PreviewSex::Male,
422 ui.label("Vrouw");
497 Sex::Female => PreviewSex::Female,
423 let female_edit = ui.add(
498 };
424 egui::TextEdit::singleline(&mut self.female)
499 self.session.show_body(sex);
425 .desired_width(360.0)
426 .hint_text("assets/body/female.glb"),
427 );
428 if female_edit.changed() {
429 self.female_import = None;
430 }
431 if ui.button("Wis vrouw").clicked() {
432 self.female.clear();
433 self.female_import = None;
434 }
435 if ui.button("Lees vrouw").clicked() {
436 self.inspect(false);
437 }
500 }
438 });
501 });
439 show_import(ui, "Import vrouw", self.female_import.as_ref());
502 let index = if sex == Sex::Male { 0 } else { 1 };
440 ui.label("Equipment-presets, relatief aan de projectmap");
503 if let Some(inventory) = &self.inventories[index] {
441 let mut remove = None;
504 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
442 for (index, preset) in self.presets.iter().enumerate() {
505 Ok(model) => {
443 ui.horizontal(|ui| {
506 for mesh in &model.meshes {
444 ui.label(preset);
507 ui.label(format!(
445 if ui.button("Verwijder").clicked() {
508 "{}: {} vertices, {} driehoeken",
446 remove = Some(index);
509 mesh.name, mesh.vertex_count, mesh.triangle_count
510 ));
511 }
512 for material in &model.materials {
513 ui.label(format!("materiaal {}", material.name));
514 }
515 for texture in &model.textures {
516 ui.label(format!(
517 "texture {}: {}×{}",
518 texture.source, texture.width, texture.height
519 ));
520 }
521 ui.label(format!("{} joints", model.joints.len()));
522 for clip in &model.clips {
523 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
524 }
525 for notice in &model.notices {
526 ui.label(notice);
527 }
528 }
529 Err(message) => {
530 ui.colored_label(egui::Color32::LIGHT_RED, message);
447 }
531 }
448 });
532 });
449 }
533 }
450 if let Some(index) = remove {
451 self.presets.remove(index);
452 }
453 ui.horizontal(|ui| {
454 ui.add(
455 egui::TextEdit::singleline(&mut self.preset_input)
456 .desired_width(280.0)
457 .hint_text("presets/zwaard.bin"),
458 );
459 if ui.button("Voeg preset toe").clicked() {
460 let value = self.preset_input.trim().to_string();
461 self.add_preset(value);
462 }
463 });
464 ui.horizontal(|ui| {
465 ui.label("Uitvoer");
466 ui.text_edit_singleline(&mut self.output);
467 });
468 ui.horizontal(|ui| {
469 ui.label("Breedte");
470 ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
471 ui.label("Hoogte");
472 ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
473 ui.label("fps");
474 ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
475 });
476 ui.add_space(8.0);
477 ui.label(
478 "Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.",
479 );
480 }
534 }
481
535
482 fn show_browser(&mut self, ui: &mut egui::Ui) {
536 fn show_body(&mut self, ctx: &egui::Context) {
483 ui.heading("Bestanden");
537 self.toolbar(ctx, true);
484 let Some(opened) = self.opened.clone() else {
538 let sex = match self.session.screen {
485 ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
539 Screen::Body(sex) => sex,
486 return;
540 _ => Sex::Male,
487 };
541 };
488 let root = opened
542 self.controls.sex = match sex {
489 .manifest_path
543 Sex::Male => PreviewSex::Male,
490 .parent()
544 Sex::Female => PreviewSex::Female,
491 .map(PathBuf::from)
545 };
492 .unwrap_or_default();
546 egui::CentralPanel::default().show(ctx, |ui| {
493 ui.label(if self.browser.is_empty() {
547 if self.session.confirm.is_some() || self.dialog_pending {
494 "projectmap".to_string()
548 ui.disable();
495 } else {
496 self.browser.clone()
497 });
498 if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
499 self.browser = parent_relative(&self.browser);
500 }
501 if ui.button("Deze map als uitvoer").clicked() {
502 if self.browser.is_empty() {
503 self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
504 } else {
505 self.output = self.browser.clone();
506 self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");
507 }
549 }
508 }
550 ui.horizontal_wrapped(|ui| {
509 let entries = match list_dir(&root, &self.browser) {
551 if ui.button("Terug").clicked() {
510 Ok(entries) => entries,
552 self.session.show_bodies();
511 Err(error) => {
553 }
512 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
554 if ui.button("Equipment").clicked() {
513 return;
555 self.session.show_equipment_list();
556 }
557 if ui
558 .add_enabled(!self.render_ui.running, egui::Button::new("Render"))
559 .clicked()
560 {
561 self.start_render([480, 640]);
562 }
563 if ui.button("Live 3D").clicked() {
564 self.render_ui.preview = None;
565 }
566 });
567 if self.render_ui.progress.total > 0 {
568 let fraction = self.render_ui.progress.completed as f32
569 / self.render_ui.progress.total.max(1) as f32;
570 ui.add(egui::ProgressBar::new(fraction).text(format!(
571 "{} / {} {}",
572 self.render_ui.progress.completed,
573 self.render_ui.progress.total,
574 self.render_ui.progress.label
575 )));
514 }
576 }
515 };
577 if let Some(image) = &self.render_ui.preview {
516 let mut enter = None;
578 self.paint_pass(ui, image);
517 let mut choose = None;
518 for entry in &entries {
519 let label = if entry.is_dir {
520 format!("{}/", entry.name)
521 } else {
579 } else {
522 entry.name.clone()
580 let character = match self.controls.sex {
523 };
581 PreviewSex::Male => self.preview_male.as_ref(),
524 if ui.button(label).clicked() {
582 PreviewSex::Female => self.preview_female.as_ref(),
525 if entry.is_dir {
583 };
526 enter = Some(entry.relative.clone());
584 if character.is_some() {
585 self.viewport.show(ui, &mut self.controls, character);
527 } else {
586 } else {
528 choose = Some(entry.relative.clone());
587 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
529 }
588 }
530 }
589 }
531 }
590 });
532 if let Some(relative) = enter {
533 self.browser = relative;
534 }
535 if let Some(relative) = choose {
536 self.assign_file(&relative);
537 }
538 }
591 }
539}
540
592
541fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
593 fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
542 let Some(report) = report else {
594 let color = egui::ColorImage::from_rgba_unmultiplied(
543 return;
595 [image.width as usize, image.height as usize],
544 };
596 &image.rgba,
545 ui.group(|ui| {
597 );
546 ui.strong(title);
598 let texture = ui.ctx().load_texture(
547 match report {
599 "render-pass",
548 Ok(model) => {
600 color,
549 let format = match model.format {
601 egui::TextureOptions {
550 ModelFormat::Gltf => "glTF/GLB",
602 magnification: egui::TextureFilter::Nearest,
551 ModelFormat::Obj => "OBJ",
603 minification: egui::TextureFilter::Nearest,
552 ModelFormat::Fbx => "FBX",
604 wrap_mode: egui::TextureWrapMode::ClampToEdge,
553 };
605 ..Default::default()
554 ui.label(format);
606 },
555 for mesh in &model.meshes {
607 );
556 ui.label(format!(
608 let available = ui.available_size();
557 "mesh {}: {} vertices, {} driehoeken{}",
609 ui.image((texture.id(), available));
558 mesh.name,
610 }
559 mesh.vertex_count,
611
560 mesh.triangle_count,
612 fn show_equipment_list(&mut self, ctx: &egui::Context) {
561 if mesh.has_skin { ", skin" } else { "" }
613 self.toolbar(ctx, true);
562 ));
614 egui::CentralPanel::default().show(ctx, |ui| {
615 if self.session.confirm.is_some() || self.dialog_pending {
616 ui.disable();
617 }
618 ui.heading(format!(
619 "Bodies → {} → Equipment",
620 match self.session.active_body {
621 Some(Sex::Male) => "Man",
622 Some(Sex::Female) => "Vrouw",
623 None => "Body",
563 }
624 }
564 for material in &model.materials {
625 ));
565 ui.label(format!(
626 if ui.button("Terug naar body").clicked() {
566 "materiaal {}{}",
627 self.session.back();
567 material.name,
628 }
568 material
629 let mut open = None;
569 .base_color_texture
630 for (index, item) in self.session.equipment.iter().enumerate() {
570 .as_ref()
631 let label = match &item.model {
571 .map(|name| format!(", texture {name}"))
632 Some(model) => format!("{} {model}", item.slug),
572 .unwrap_or_default()
633 None => item.slug.clone(),
573 ));
634 };
635 if ui.button(label).clicked() {
636 open = Some(index);
574 }
637 }
575 for texture in &model.textures {
638 }
576 ui.label(format!(
639 if ui.button("Nieuw").clicked() {
577 "texture {} ({}x{})",
640 self.session.add_equipment();
578 texture.source, texture.width, texture.height
641 }
579 ));
642 if let Some(index) = open {
643 self.session.show_equipment(index);
644 }
645 });
646 }
647
648 fn show_equipment(&mut self, ctx: &egui::Context) {
649 self.toolbar(ctx, true);
650 let Screen::Equipment(index) = self.session.screen else {
651 return;
652 };
653 egui::CentralPanel::default().show(ctx, |ui| {
654 if self.session.confirm.is_some() || self.dialog_pending {
655 ui.disable();
656 }
657 ui.heading("Equipment");
658 if ui.button("Terug").clicked() {
659 self.session.show_equipment_list();
660 }
661 if let Some(item) = self.session.equipment.get_mut(index) {
662 ui.horizontal(|ui| {
663 ui.label("Naam");
664 ui.text_edit_singleline(&mut item.slug);
665 });
666 let model = item
667 .model
668 .clone()
669 .unwrap_or_else(|| "geen model".to_string());
670 ui.label(model);
671 }
672 if ui.button("Kies model").clicked() {
673 self.dialogs.start(DialogPurpose::EquipmentModel(index));
674 }
675 if ui.button("Verwijder").clicked() {
676 self.session.delete_equipment(index);
677 }
678 });
679 }
680
681 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
682 if !project {
683 return;
684 }
685 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
686 if self.session.confirm.is_some() || self.dialog_pending {
687 ui.disable();
688 }
689 ui.horizontal(|ui| {
690 if ui.button("Body").clicked() {
691 self.session.show_bodies();
580 }
692 }
581 if model.joints.is_empty() {
693 if ui.button("Opslaan").clicked() {
582 ui.label("geen skeleton");
694 self.save();
583 } else {
584 ui.label(format!("{} joints", model.joints.len()));
585 for joint in model.joints.iter().take(8) {
586 match &joint.parent {
587 Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
588 None => ui.label(format!("{} (wortel)", joint.name)),
589 };
590 }
591 }
695 }
592 if model.clips.is_empty() {
696 if ui
593 ui.label("geen animation clips");
697 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
594 } else {
698 .clicked()
595 for clip in &model.clips {
699 {
596 ui.label(format!(
700 self.begin(Intent::PickNew);
597 "clip {} ({:.2} s, {} kanalen)",
598 clip.name, clip.duration_seconds, clip.channel_count
599 ));
600 }
601 }
701 }
602 for notice in &model.notices {
702 if ui
603 ui.label(notice);
703 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
704 .clicked()
705 {
706 self.begin(Intent::PickLoad);
604 }
707 }
605 }
708 });
606 Err(message) => {
709 });
607 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
710 }
608 }
609 }
610 });
611}
711}
612
712
613fn load_preview_path(root: &std::path::Path, relative: &str) -> Option<PreviewCharacter> {
713#[cfg(test)]
614 let relative = relative.trim();
714mod tests {
615 let lower = relative.to_ascii_lowercase();
715 use super::*;
616 if !(lower.ends_with(".glb") || lower.ends_with(".gltf")) {
716 use crate::dialogs::ScriptedDialogs;
617 return None;
717 use crate::session::Choice;
718
719 fn root() -> PathBuf {
720 std::env::temp_dir().join(format!(
721 "paperdoll-editor-{}",
722 std::time::SystemTime::now()
723 .duration_since(std::time::UNIX_EPOCH)
724 .unwrap()
725 .as_nanos()
726 ))
618 }
727 }
619 load_gltf_file(&root.join(relative)).ok()
620}
621
728
622fn parent_relative(relative: &str) -> String {
729 #[test]
623 match relative.rfind('/') {
730 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
624 Some(index) => relative[..index].to_string(),
731 let root = root();
625 None => String::new(),
732 let mut dialogs = ScriptedDialogs::default();
733 dialogs.push(Some(root.clone()));
734 dialogs.push(None);
735 let mut app = EditorApp::new(Box::new(dialogs));
736 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
737 app.begin(Intent::PickNew);
738 app.poll_dialogs();
739 assert_eq!(app.session.screen, Screen::Bodies);
740 assert!(app.preview_male.is_some());
741 assert!(app.preview_female.is_some());
742 app.session.set_name("Keep my edit".into());
743 app.begin(Intent::PickLoad);
744 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
745 app.apply_choice(Choice::Discard, &egui::Context::default());
746 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
747 assert_eq!(
748 app.session.opened.as_ref().unwrap().document.name,
749 "Keep my edit"
750 );
751 assert!(app.session.is_dirty());
752 let _ = std::fs::remove_dir_all(root);
626 }
753 }
627}
628
754
629fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
755 #[test]
630 let text = text.trim();
756 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
631 if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
757 let root = root();
632 return Err(format!("{label} moet een geheel getal zijn"));
758 let mut app = EditorApp::default();
759 app.session.install_new_project(&root).unwrap();
760 app.session.set_render(0, 128, 12);
761 app.begin(Intent::Quit);
762 app.apply_choice(Choice::Save, &egui::Context::default());
763 assert!(!app.allow_close);
764 assert_eq!(app.session.confirm, Some(Intent::Quit));
765 assert!(app.session.is_dirty());
766 app.open_path(root.join("missing.ppd"));
767 assert!(app.session.opened.is_some());
768 assert_eq!(app.session.screen, Screen::Bodies);
769 let _ = std::fs::remove_dir_all(root);
633 }
770 }
634 text.parse::<u32>()
635 .map_err(|_| format!("{label} is te groot"))
636}
637
771
638impl Default for EditorApp {
772 #[test]
639 fn default() -> Self {
773 fn open_with_project_path_loads_bodies_and_models() {
640 Self::new()
774 let root = root().join("project with spaces");
775 let opened = crate::session::create_seeded_project(&root).unwrap();
776 let mut app = EditorApp::default();
777 app.open_path(opened.manifest_path.clone());
778 assert_eq!(app.session.screen, Screen::Bodies);
779 assert!(app.preview_male.is_some());
780 assert_eq!(
781 app.session.opened.unwrap().manifest_path,
782 opened.manifest_path
783 );
784 let _ = std::fs::remove_dir_all(root.parent().unwrap());
785 }
786
787 #[test]
788 fn renderer_disconnect_is_reported() {
789 let mut app = EditorApp::default();
790 let (tx, rx) = std::sync::mpsc::channel();
791 app.render_rx = Some(rx);
792 app.render_ui.running = true;
793 drop(tx);
794 app.drain_render();
795 assert!(!app.render_ui.running);
796 assert!(app.render_ui.error.is_some());
641 }
797 }
642}
798}
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 +67 −30
@@ -24,9 +24,11 @@ pub enum Availability {
24 24 }
25 25
26 26 /// What the process should do with the command line it received.
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 #[derive(Debug, Clone, PartialEq, Eq)]
28 28 pub enum CliRequest {
29 29 OpenWindow,
30 InstallDesktop,
31 OpenProject(String),
30 32 PrintStatus,
31 33 PrintHelp,
32 34 PreviewFixtures,
@@ -37,6 +39,7 @@ pub enum CliRequest {
37 39 #[derive(Debug, Clone, PartialEq, Eq)]
38 40 pub enum CliError {
39 41 UnknownArgument(String),
42 Usage(String),
40 43 }
41 44
42 45 const CAPABILITIES: [Capability; 8] = [
@@ -108,14 +111,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
108 111 }
109 112
110 113 pub fn dutch_summary() -> &'static str {
111 "Deze build leest glTF/GLB, FBX en OBJ+MTL, en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
112 115 }
113 116
114 117 /// Stable status text shared by `--status` and the window.
115 118 pub fn report() -> String {
116 119 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
117 120 lines.push("name: Paperdoll Renderer".to_string());
118 lines.push("phase: live-3d-preview".to_string());
121 lines.push("phase: project-editor".to_string());
119 122 for capability in CAPABILITIES {
120 123 lines.push(format!(
121 124 "{}: {}",
@@ -139,7 +142,7 @@ pub const LINUX_WINDOW_LIBRARIES: &[&str] = &[
139 142
140 143 pub fn missing_library_message(missing: &[&str]) -> String {
141 144 format!(
142 "het statusvenster kan niet openen; deze bibliotheken ontbreken: {}. Import en render zijn niet gebouwd.",
145 "het venster kan niet openen; deze bibliotheken ontbreken: {}.",
143 146 missing.join(", ")
144 147 )
145 148 }
@@ -159,42 +162,70 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
159 162
160 163 pub fn help_text() -> &'static str {
161 164 "\
162 Paperdoll Renderer — modelimport
165 Paperdoll Renderer
163 166
164 167 Gebruik:
165 paperdoll-renderer open het projectvenster
166 paperdoll-renderer --status print de status en sluit
167 paperdoll-renderer --help toon deze tekst
168
169 paperdoll-renderer --preview-fixture open de 3D-preview met de testlichamen
168 paperdoll-renderer startscherm
169 paperdoll-renderer pad/naar/character.ppd open dat project
170 paperdoll-renderer --install-desktop registreer Openen met op Linux
171 paperdoll-renderer --status print de status en sluit
172 paperdoll-renderer --help toon deze tekst
173 paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam
170 174
171 Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."
175 --status staat alleen. Een .ppd-pad of een projectmap opent character.ppd."
172 176 }
173 177
174 178 pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
175 179 where
176 180 I: IntoIterator<Item = &'a str>,
177 181 {
178 let mut request = CliRequest::OpenWindow;
182 let args: Vec<&str> = args.into_iter().collect();
183 if args == ["--install-desktop"] {
184 return Ok(CliRequest::InstallDesktop);
185 }
186 let mut help = false;
187 let mut status = false;
188 let mut preview = false;
189 let mut project = None;
179 190 for arg in args {
180 191 match arg {
181 "--help" | "-h" => request = CliRequest::PrintHelp,
182 "--preview-fixture" => {
183 if request != CliRequest::PrintHelp {
184 request = CliRequest::PreviewFixtures;
185 }
186 }
187 "--status" => {
188 if request != CliRequest::PrintHelp {
189 request = CliRequest::PrintStatus;
190 }
192 "--help" | "-h" => help = true,
193 "--status" => status = true,
194 "--preview-fixture" => preview = true,
195 other if other.starts_with('-') => {
196 return Err(CliError::UnknownArgument(other.to_string()));
191 197 }
192 198 other => {
193 return Err(CliError::UnknownArgument(other.to_string()));
199 if project.is_some() {
200 return Err(CliError::Usage("geef één projectpad".to_string()));
201 }
202 project = Some(other.to_string());
194 203 }
195 204 }
196 205 }
197 Ok(request)
206 if help {
207 return Ok(CliRequest::PrintHelp);
208 }
209 if status && (preview || project.is_some()) {
210 return Err(CliError::Usage(
211 "--status staat alleen en neemt geen projectpad aan".to_string(),
212 ));
213 }
214 if status {
215 return Ok(CliRequest::PrintStatus);
216 }
217 if preview && project.is_some() {
218 return Err(CliError::Usage(
219 "--preview-fixture opent het voorbeeld en neemt geen projectpad aan".to_string(),
220 ));
221 }
222 if preview {
223 return Ok(CliRequest::PreviewFixtures);
224 }
225 if let Some(path) = project {
226 return Ok(CliRequest::OpenProject(path));
227 }
228 Ok(CliRequest::OpenWindow)
198 229 }
199 230
200 231 #[cfg(test)]
@@ -233,7 +264,7 @@ mod tests {
233 264 #[test]
234 265 fn status_report_matches_the_import_build() {
235 266 let report = report();
236 assert!(report.starts_with("name: Paperdoll Renderer\nphase: live-3d-preview\n"));
267 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
237 268 for key in [
238 269 "project_shell",
239 270 "obj_mtl_textures",
@@ -269,9 +300,15 @@ mod tests {
269 300 assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));
270 301 assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));
271 302 assert_eq!(
272 parse_cli(["tests/fixtures/bodies/test-body-male.glb"]),
273 Err(CliError::UnknownArgument(
274 "tests/fixtures/bodies/test-body-male.glb".to_string()
303 parse_cli(["mijn project/character.ppd"]),
304 Ok(CliRequest::OpenProject(
305 "mijn project/character.ppd".to_string()
306 ))
307 );
308 assert_eq!(
309 parse_cli(["--status", "character.ppd"]),
310 Err(CliError::Usage(
311 "--status staat alleen en neemt geen projectpad aan".to_string()
275 312 ))
276 313 );
277 314 assert_eq!(
@@ -289,8 +326,8 @@ mod tests {
289 326 fn missing_library_message_names_the_library_and_does_not_claim_import() {
290 327 let message = missing_library_message(&["libxkbcommon-x11.so.0"]);
291 328 assert!(message.contains("libxkbcommon-x11.so.0"));
292 assert!(message.contains("niet gebouwd"));
293 assert!(!message.to_lowercase().contains("import werkt"));
329 assert!(!message.to_lowercase().contains("import"));
330 assert!(!message.to_lowercase().contains("niet gebouwd"));
294 331 assert!(!LINUX_WINDOW_LIBRARIES.is_empty());
295 332 }
296 333 }
@@ -24,9 +24,11 @@ pub enum Availability {
24}
24}
25
25
26/// What the process should do with the command line it received.
26/// What the process should do with the command line it received.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum CliRequest {
28pub enum CliRequest {
29 OpenWindow,
29 OpenWindow,
30 InstallDesktop,
31 OpenProject(String),
30 PrintStatus,
32 PrintStatus,
31 PrintHelp,
33 PrintHelp,
32 PreviewFixtures,
34 PreviewFixtures,
@@ -37,6 +39,7 @@ pub enum CliRequest {
37#[derive(Debug, Clone, PartialEq, Eq)]
39#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum CliError {
40pub enum CliError {
39 UnknownArgument(String),
41 UnknownArgument(String),
42 Usage(String),
40}
43}
41
44
42const CAPABILITIES: [Capability; 8] = [
45const CAPABILITIES: [Capability; 8] = [
@@ -108,14 +111,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
108}
111}
109
112
110pub fn dutch_summary() -> &'static str {
113pub fn dutch_summary() -> &'static str {
111 "Deze build leest glTF/GLB, FBX en OBJ+MTL, en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
112}
115}
113
116
114/// Stable status text shared by `--status` and the window.
117/// Stable status text shared by `--status` and the window.
115pub fn report() -> String {
118pub fn report() -> String {
116 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
119 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
117 lines.push("name: Paperdoll Renderer".to_string());
120 lines.push("name: Paperdoll Renderer".to_string());
118 lines.push("phase: live-3d-preview".to_string());
121 lines.push("phase: project-editor".to_string());
119 for capability in CAPABILITIES {
122 for capability in CAPABILITIES {
120 lines.push(format!(
123 lines.push(format!(
121 "{}: {}",
124 "{}: {}",
@@ -139,7 +142,7 @@ pub const LINUX_WINDOW_LIBRARIES: &[&str] = &[
139
142
140pub fn missing_library_message(missing: &[&str]) -> String {
143pub fn missing_library_message(missing: &[&str]) -> String {
141 format!(
144 format!(
142 "het statusvenster kan niet openen; deze bibliotheken ontbreken: {}. Import en render zijn niet gebouwd.",
145 "het venster kan niet openen; deze bibliotheken ontbreken: {}.",
143 missing.join(", ")
146 missing.join(", ")
144 )
147 )
145}
148}
@@ -159,42 +162,70 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
159
162
160pub fn help_text() -> &'static str {
163pub fn help_text() -> &'static str {
161 "\
164 "\
162Paperdoll Renderer — modelimport
165Paperdoll Renderer
163
166
164Gebruik:
167Gebruik:
165 paperdoll-renderer open het projectvenster
168 paperdoll-renderer startscherm
166 paperdoll-renderer --status print de status en sluit
169 paperdoll-renderer pad/naar/character.ppd open dat project
167 paperdoll-renderer --help toon deze tekst
170 paperdoll-renderer --install-desktop registreer Openen met op Linux
168
171 paperdoll-renderer --status print de status en sluit
169 paperdoll-renderer --preview-fixture open de 3D-preview met de testlichamen
172 paperdoll-renderer --help toon deze tekst
173 paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam
170
174
171Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."
175--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd."
172}
176}
173
177
174pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
178pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
175where
179where
176 I: IntoIterator<Item = &'a str>,
180 I: IntoIterator<Item = &'a str>,
177{
181{
178 let mut request = CliRequest::OpenWindow;
182 let args: Vec<&str> = args.into_iter().collect();
183 if args == ["--install-desktop"] {
184 return Ok(CliRequest::InstallDesktop);
185 }
186 let mut help = false;
187 let mut status = false;
188 let mut preview = false;
189 let mut project = None;
179 for arg in args {
190 for arg in args {
180 match arg {
191 match arg {
181 "--help" | "-h" => request = CliRequest::PrintHelp,
192 "--help" | "-h" => help = true,
182 "--preview-fixture" => {
193 "--status" => status = true,
183 if request != CliRequest::PrintHelp {
194 "--preview-fixture" => preview = true,
184 request = CliRequest::PreviewFixtures;
195 other if other.starts_with('-') => {
185 }
196 return Err(CliError::UnknownArgument(other.to_string()));
186 }
187 "--status" => {
188 if request != CliRequest::PrintHelp {
189 request = CliRequest::PrintStatus;
190 }
191 }
197 }
192 other => {
198 other => {
193 return Err(CliError::UnknownArgument(other.to_string()));
199 if project.is_some() {
200 return Err(CliError::Usage("geef één projectpad".to_string()));
201 }
202 project = Some(other.to_string());
194 }
203 }
195 }
204 }
196 }
205 }
197 Ok(request)
206 if help {
207 return Ok(CliRequest::PrintHelp);
208 }
209 if status && (preview || project.is_some()) {
210 return Err(CliError::Usage(
211 "--status staat alleen en neemt geen projectpad aan".to_string(),
212 ));
213 }
214 if status {
215 return Ok(CliRequest::PrintStatus);
216 }
217 if preview && project.is_some() {
218 return Err(CliError::Usage(
219 "--preview-fixture opent het voorbeeld en neemt geen projectpad aan".to_string(),
220 ));
221 }
222 if preview {
223 return Ok(CliRequest::PreviewFixtures);
224 }
225 if let Some(path) = project {
226 return Ok(CliRequest::OpenProject(path));
227 }
228 Ok(CliRequest::OpenWindow)
198}
229}
199
230
200#[cfg(test)]
231#[cfg(test)]
@@ -233,7 +264,7 @@ mod tests {
233 #[test]
264 #[test]
234 fn status_report_matches_the_import_build() {
265 fn status_report_matches_the_import_build() {
235 let report = report();
266 let report = report();
236 assert!(report.starts_with("name: Paperdoll Renderer\nphase: live-3d-preview\n"));
267 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
237 for key in [
268 for key in [
238 "project_shell",
269 "project_shell",
239 "obj_mtl_textures",
270 "obj_mtl_textures",
@@ -269,9 +300,15 @@ mod tests {
269 assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));
300 assert_eq!(parse_cli(["--help"]), Ok(CliRequest::PrintHelp));
270 assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));
301 assert_eq!(parse_cli(["-h", "--status"]), Ok(CliRequest::PrintHelp));
271 assert_eq!(
302 assert_eq!(
272 parse_cli(["tests/fixtures/bodies/test-body-male.glb"]),
303 parse_cli(["mijn project/character.ppd"]),
273 Err(CliError::UnknownArgument(
304 Ok(CliRequest::OpenProject(
274 "tests/fixtures/bodies/test-body-male.glb".to_string()
305 "mijn project/character.ppd".to_string()
306 ))
307 );
308 assert_eq!(
309 parse_cli(["--status", "character.ppd"]),
310 Err(CliError::Usage(
311 "--status staat alleen en neemt geen projectpad aan".to_string()
275 ))
312 ))
276 );
313 );
277 assert_eq!(
314 assert_eq!(
@@ -289,8 +326,8 @@ mod tests {
289 fn missing_library_message_names_the_library_and_does_not_claim_import() {
326 fn missing_library_message_names_the_library_and_does_not_claim_import() {
290 let message = missing_library_message(&["libxkbcommon-x11.so.0"]);
327 let message = missing_library_message(&["libxkbcommon-x11.so.0"]);
291 assert!(message.contains("libxkbcommon-x11.so.0"));
328 assert!(message.contains("libxkbcommon-x11.so.0"));
292 assert!(message.contains("niet gebouwd"));
329 assert!(!message.to_lowercase().contains("import"));
293 assert!(!message.to_lowercase().contains("import werkt"));
330 assert!(!message.to_lowercase().contains("niet gebouwd"));
294 assert!(!LINUX_WINDOW_LIBRARIES.is_empty());
331 assert!(!LINUX_WINDOW_LIBRARIES.is_empty());
295 }
332 }
296}
333}
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 +9 −3
@@ -1,11 +1,17 @@
1 //! Foundation library for the Paperdoll Renderer shell.
1 //! Paperdoll Renderer library.
2 2 //!
3 //! The shell stores a `.ppd` project and reads glTF/GLB plus OBJ+MTL.
4 //! It does not render paperdolls and it does not play skeletal animation.
3 //! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4 //! and draws a live skinned glTF preview.
5 5
6 pub mod dialogs;
6 7 pub mod editor;
8 pub mod file_assoc;
7 9 pub mod foundation;
10 pub mod icon;
8 11 pub mod import;
9 12 pub mod preview;
10 13 pub mod preview_gl;
11 14 pub mod project;
15 pub mod render_job;
16 pub mod session;
17 pub mod still;
@@ -1,11 +1,17 @@
1//! Foundation library for the Paperdoll Renderer shell.
1//! Paperdoll Renderer library.
2//!
2//!
3//! The shell stores a `.ppd` project and reads glTF/GLB plus OBJ+MTL.
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! It does not render paperdolls and it does not play skeletal animation.
4//! and draws a live skinned glTF preview.
5
5
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;
9pub mod preview;
12pub mod preview;
10pub mod preview_gl;
13pub mod preview_gl;
11pub mod project;
14pub mod project;
15pub mod render_job;
16pub mod session;
17pub mod still;
src/main.rs +56 −8
@@ -11,6 +11,32 @@ fn main() -> ExitCode {
11 11 let args: Vec<String> = std::env::args().skip(1).collect();
12 12 let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
13 13 match parse_cli(borrowed) {
14 Ok(CliRequest::InstallDesktop) => {
15 #[cfg(target_os = "linux")]
16 {
17 let exe = std::env::var_os("APPIMAGE")
18 .map(std::path::PathBuf::from)
19 .or_else(|| std::env::current_exe().ok());
20 match exe
21 .ok_or_else(|| "geen uitvoerbaar pad".to_string())
22 .and_then(|exe| paperdoll_renderer::file_assoc::install_linux_integration(&exe))
23 {
24 Ok(()) => {
25 println!("Openen met is geregistreerd.");
26 ExitCode::SUCCESS
27 }
28 Err(error) => {
29 eprintln!("{error}");
30 ExitCode::FAILURE
31 }
32 }
33 }
34 #[cfg(not(target_os = "linux"))]
35 {
36 eprintln!("--install-desktop is voor Linux");
37 ExitCode::from(2)
38 }
39 }
14 40 Ok(CliRequest::PrintHelp) => {
15 41 println!("{}", help_text());
16 42 ExitCode::SUCCESS
@@ -19,8 +45,15 @@ fn main() -> ExitCode {
19 45 println!("{}", report());
20 46 ExitCode::SUCCESS
21 47 }
22 Ok(CliRequest::PreviewFixtures) => open_window(true),
23 Ok(CliRequest::OpenWindow) => open_window(false),
48 Ok(CliRequest::PreviewFixtures) => open_window(true, None),
49 Ok(CliRequest::OpenWindow) => open_window(false, None),
50 Ok(CliRequest::OpenProject(path)) => {
51 open_window(false, Some(std::path::PathBuf::from(path)))
52 }
53 Err(CliError::Usage(message)) => {
54 eprintln!("paperdoll-renderer: {message}");
55 ExitCode::from(2)
56 }
24 57 Err(CliError::UnknownArgument(arg)) => {
25 58 eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");
26 59 eprintln!("Gebruik --help.");
@@ -29,12 +62,18 @@ fn main() -> ExitCode {
29 62 }
30 63 }
31 64
32 fn open_window(fixtures: bool) -> ExitCode {
65 fn open_window(fixtures: bool, path: Option<std::path::PathBuf>) -> ExitCode {
66 #[cfg(windows)]
67 if let Ok(exe) = std::env::current_exe() {
68 if let Err(error) = paperdoll_renderer::file_assoc::ensure_user_file_association(&exe) {
69 eprintln!("Openen met registreren: {error}");
70 }
71 }
33 72 if let Some(message) = window_preflight() {
34 73 eprintln!("paperdoll-renderer: {message}");
35 74 return ExitCode::from(1);
36 75 }
37 match run_window(fixtures) {
76 match run_window(fixtures, path) {
38 77 Ok(()) => ExitCode::SUCCESS,
39 78 Err(err) => {
40 79 eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");
@@ -61,12 +100,18 @@ fn window_preflight() -> Option<String> {
61 100 }
62 101 }
63 102
64 fn run_window(fixtures: bool) -> eframe::Result<()> {
103 fn run_window(fixtures: bool, path: Option<std::path::PathBuf>) -> eframe::Result<()> {
104 let (width, height, rgba) = paperdoll_renderer::icon::window_icon_rgba();
65 105 let options = eframe::NativeOptions {
66 106 viewport: eframe::egui::ViewportBuilder::default()
67 107 .with_title("Paperdoll Renderer")
68 .with_inner_size([1180.0, 760.0])
69 .with_min_inner_size([900.0, 560.0]),
108 .with_inner_size(paperdoll_renderer::session::START_WINDOW)
109 .with_min_inner_size([420.0, 220.0])
110 .with_icon(eframe::egui::IconData {
111 rgba,
112 width,
113 height,
114 }),
70 115 ..Default::default()
71 116 };
72 117 eframe::run_native(
@@ -75,7 +120,10 @@ fn run_window(fixtures: bool) -> eframe::Result<()> {
75 120 Box::new(move |_creation| {
76 121 let mut app = EditorApp::default();
77 122 if fixtures {
78 app.load_fixture_bodies();
123 app.open_fixture_project();
124 }
125 if let Some(path) = path {
126 app.open_path(path);
79 127 }
80 128 Ok(Box::new(app))
81 129 }),
@@ -11,6 +11,32 @@ fn main() -> ExitCode {
11 let args: Vec<String> = std::env::args().skip(1).collect();
11 let args: Vec<String> = std::env::args().skip(1).collect();
12 let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
12 let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
13 match parse_cli(borrowed) {
13 match parse_cli(borrowed) {
14 Ok(CliRequest::InstallDesktop) => {
15 #[cfg(target_os = "linux")]
16 {
17 let exe = std::env::var_os("APPIMAGE")
18 .map(std::path::PathBuf::from)
19 .or_else(|| std::env::current_exe().ok());
20 match exe
21 .ok_or_else(|| "geen uitvoerbaar pad".to_string())
22 .and_then(|exe| paperdoll_renderer::file_assoc::install_linux_integration(&exe))
23 {
24 Ok(()) => {
25 println!("Openen met is geregistreerd.");
26 ExitCode::SUCCESS
27 }
28 Err(error) => {
29 eprintln!("{error}");
30 ExitCode::FAILURE
31 }
32 }
33 }
34 #[cfg(not(target_os = "linux"))]
35 {
36 eprintln!("--install-desktop is voor Linux");
37 ExitCode::from(2)
38 }
39 }
14 Ok(CliRequest::PrintHelp) => {
40 Ok(CliRequest::PrintHelp) => {
15 println!("{}", help_text());
41 println!("{}", help_text());
16 ExitCode::SUCCESS
42 ExitCode::SUCCESS
@@ -19,8 +45,15 @@ fn main() -> ExitCode {
19 println!("{}", report());
45 println!("{}", report());
20 ExitCode::SUCCESS
46 ExitCode::SUCCESS
21 }
47 }
22 Ok(CliRequest::PreviewFixtures) => open_window(true),
48 Ok(CliRequest::PreviewFixtures) => open_window(true, None),
23 Ok(CliRequest::OpenWindow) => open_window(false),
49 Ok(CliRequest::OpenWindow) => open_window(false, None),
50 Ok(CliRequest::OpenProject(path)) => {
51 open_window(false, Some(std::path::PathBuf::from(path)))
52 }
53 Err(CliError::Usage(message)) => {
54 eprintln!("paperdoll-renderer: {message}");
55 ExitCode::from(2)
56 }
24 Err(CliError::UnknownArgument(arg)) => {
57 Err(CliError::UnknownArgument(arg)) => {
25 eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");
58 eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");
26 eprintln!("Gebruik --help.");
59 eprintln!("Gebruik --help.");
@@ -29,12 +62,18 @@ fn main() -> ExitCode {
29 }
62 }
30}
63}
31
64
32fn open_window(fixtures: bool) -> ExitCode {
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 }
33 if let Some(message) = window_preflight() {
72 if let Some(message) = window_preflight() {
34 eprintln!("paperdoll-renderer: {message}");
73 eprintln!("paperdoll-renderer: {message}");
35 return ExitCode::from(1);
74 return ExitCode::from(1);
36 }
75 }
37 match run_window(fixtures) {
76 match run_window(fixtures, path) {
38 Ok(()) => ExitCode::SUCCESS,
77 Ok(()) => ExitCode::SUCCESS,
39 Err(err) => {
78 Err(err) => {
40 eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");
79 eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");
@@ -61,12 +100,18 @@ fn window_preflight() -> Option<String> {
61 }
100 }
62}
101}
63
102
64fn run_window(fixtures: bool) -> 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();
65 let options = eframe::NativeOptions {
105 let options = eframe::NativeOptions {
66 viewport: eframe::egui::ViewportBuilder::default()
106 viewport: eframe::egui::ViewportBuilder::default()
67 .with_title("Paperdoll Renderer")
107 .with_title("Paperdoll Renderer")
68 .with_inner_size([1180.0, 760.0])
108 .with_inner_size(paperdoll_renderer::session::START_WINDOW)
69 .with_min_inner_size([900.0, 560.0]),
109 .with_min_inner_size([420.0, 220.0])
110 .with_icon(eframe::egui::IconData {
111 rgba,
112 width,
113 height,
114 }),
70 ..Default::default()
115 ..Default::default()
71 };
116 };
72 eframe::run_native(
117 eframe::run_native(
@@ -75,7 +120,10 @@ fn run_window(fixtures: bool) -> eframe::Result<()> {
75 Box::new(move |_creation| {
120 Box::new(move |_creation| {
76 let mut app = EditorApp::default();
121 let mut app = EditorApp::default();
77 if fixtures {
122 if fixtures {
78 app.load_fixture_bodies();
123 app.open_fixture_project();
124 }
125 if let Some(path) = path {
126 app.open_path(path);
79 }
127 }
80 Ok(Box::new(app))
128 Ok(Box::new(app))
81 }),
129 }),
src/preview_gl.rs +32 −20
@@ -40,23 +40,24 @@ impl Viewport {
40 40 character: Option<&PreviewCharacter>,
41 41 ) {
42 42 ui.horizontal_wrapped(|ui| {
43 if ui
44 .selectable_label(controls.sex == crate::preview::PreviewSex::Male, "Man")
45 .clicked()
46 {
47 controls.sex = crate::preview::PreviewSex::Male;
48 }
49 if ui
50 .selectable_label(controls.sex == crate::preview::PreviewSex::Female, "Vrouw")
51 .clicked()
52 {
53 controls.sex = crate::preview::PreviewSex::Female;
54 }
55 if ui.button("walk").clicked() {
56 controls.select_clip("walk");
57 }
58 if ui.button("attack").clicked() {
59 controls.select_clip("attack");
43 if let Some(character) = character {
44 if !character
45 .clips
46 .iter()
47 .any(|clip| clip.name == controls.clip_name)
48 {
49 if let Some(clip) = character.clips.first() {
50 controls.select_clip(&clip.name);
51 }
52 }
53 for clip in &character.clips {
54 if ui
55 .selectable_label(controls.clip_name == clip.name, &clip.name)
56 .clicked()
57 {
58 controls.select_clip(&clip.name);
59 }
60 }
60 61 }
61 62 if ui
62 63 .selectable_label(
@@ -156,9 +157,20 @@ impl Viewport {
156 157 }
157 158
158 159 fn character_generation(character: &PreviewCharacter) -> u64 {
159 character.positions.len() as u64
160 ^ ((character.indices.len() as u64) << 16)
161 ^ ((character.base_color[0].to_bits() as u64) << 32)
160 use std::hash::{Hash, Hasher};
161 let mut hash = std::collections::hash_map::DefaultHasher::new();
162 for point in &character.positions {
163 point.map(f32::to_bits).hash(&mut hash);
164 }
165 for normal in &character.normals {
166 normal.map(f32::to_bits).hash(&mut hash);
167 }
168 for weights in &character.weights {
169 weights.map(f32::to_bits).hash(&mut hash);
170 }
171 character.joints.hash(&mut hash);
172 character.indices.hash(&mut hash);
173 hash.finish()
162 174 }
163 175
164 176 #[derive(Clone)]
@@ -40,23 +40,24 @@ impl Viewport {
40 character: Option<&PreviewCharacter>,
40 character: Option<&PreviewCharacter>,
41 ) {
41 ) {
42 ui.horizontal_wrapped(|ui| {
42 ui.horizontal_wrapped(|ui| {
43 if ui
43 if let Some(character) = character {
44 .selectable_label(controls.sex == crate::preview::PreviewSex::Male, "Man")
44 if !character
45 .clicked()
45 .clips
46 {
46 .iter()
47 controls.sex = crate::preview::PreviewSex::Male;
47 .any(|clip| clip.name == controls.clip_name)
48 }
48 {
49 if ui
49 if let Some(clip) = character.clips.first() {
50 .selectable_label(controls.sex == crate::preview::PreviewSex::Female, "Vrouw")
50 controls.select_clip(&clip.name);
51 .clicked()
51 }
52 {
52 }
53 controls.sex = crate::preview::PreviewSex::Female;
53 for clip in &character.clips {
54 }
54 if ui
55 if ui.button("walk").clicked() {
55 .selectable_label(controls.clip_name == clip.name, &clip.name)
56 controls.select_clip("walk");
56 .clicked()
57 }
57 {
58 if ui.button("attack").clicked() {
58 controls.select_clip(&clip.name);
59 controls.select_clip("attack");
59 }
60 }
60 }
61 }
61 if ui
62 if ui
62 .selectable_label(
63 .selectable_label(
@@ -156,9 +157,20 @@ impl Viewport {
156}
157}
157
158
158fn character_generation(character: &PreviewCharacter) -> u64 {
159fn character_generation(character: &PreviewCharacter) -> u64 {
159 character.positions.len() as u64
160 use std::hash::{Hash, Hasher};
160 ^ ((character.indices.len() as u64) << 16)
161 let mut hash = std::collections::hash_map::DefaultHasher::new();
161 ^ ((character.base_color[0].to_bits() as u64) << 32)
162 for point in &character.positions {
163 point.map(f32::to_bits).hash(&mut hash);
164 }
165 for normal in &character.normals {
166 normal.map(f32::to_bits).hash(&mut hash);
167 }
168 for weights in &character.weights {
169 weights.map(f32::to_bits).hash(&mut hash);
170 }
171 character.joints.hash(&mut hash);
172 character.indices.hash(&mut hash);
173 hash.finish()
162}
174}
163
175
164#[derive(Clone)]
176#[derive(Clone)]
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}