Compare commits
683 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7376ecf270 | |||
| 5481a598d9 | |||
| 0d379a8013 | |||
| 868ff0446a | |||
| 081c1e7046 | |||
| 042548412d | |||
| 5c4dc51093 | |||
| 94a50302cc | |||
| e17382c505 | |||
| 106c20418c | |||
| 413097db1c | |||
| f9c28ed42a | |||
| 69dbea3fae | |||
| a24ab44b17 | |||
| 9e02f8b8ee | |||
| 7c3dd2d15b | |||
| 135c7b0c94 | |||
| 06d457f4e5 | |||
| 67c5936399 | |||
| f1751f2589 | |||
| 48c0b01f93 | |||
| 64ef3d31eb | |||
| b14f005b22 | |||
| 72a288145d | |||
| af0815b01f | |||
| df3e813470 | |||
| 1b28b8b7fd | |||
| c449cb3390 | |||
| 05a420f345 | |||
| df395657e3 | |||
| 8970457353 | |||
| 0aab42f0e9 | |||
| 76d965ff43 | |||
| 0b9921e592 | |||
| aee29c47e4 | |||
| f2ee454b89 | |||
| a77eb0b1e0 | |||
| 91a7b170fe | |||
| 4d51b9f556 | |||
| 92a98064c3 | |||
| 1fbddd483c | |||
| d47ce02cc6 | |||
| 01c4a80c8f | |||
| 174d77a9fd | |||
| a078f28a82 | |||
| f3f3864b83 | |||
| 46bb5f74b1 | |||
| 89a0316fb4 | |||
| 51ee910fb6 | |||
| a198de05c2 | |||
| d3db2b3650 | |||
| 5a6f195f56 | |||
| 0cae981779 | |||
| c541b3f474 | |||
| ceccb2c901 | |||
| 8c21f3e2e4 | |||
| 55eecdc8d7 | |||
| b98c0e866b | |||
| 3d294f38c2 | |||
| a237dc3995 | |||
| 80c74dc745 | |||
| fb9313913d | |||
| 33e7fac74c | |||
| 2face72aee | |||
| 1f8e7efdb2 | |||
| b184449e7b | |||
| bc31b1b2dd | |||
| 12e1f80830 | |||
| 1cadcf8bdb | |||
| 06d27e0644 | |||
| 98b519dee8 | |||
| 4da2d7fa20 | |||
| 0f992b26e9 | |||
| 2a57d1ecc3 | |||
| fd3180d08b | |||
| 43b5216d9f | |||
| e1e11346b4 | |||
| 3ce8c88a84 | |||
| 0285b522f1 | |||
| 4ea287b162 | |||
| c2d52449b4 | |||
| f9a0880cd9 | |||
| 1bb098e952 | |||
| 9c74c8e87b | |||
| 3f4d956903 | |||
| a5371c36b9 | |||
| 41b863a0eb | |||
| 7fd35f3f48 | |||
| 26c36b8302 | |||
| bfc259e660 | |||
| 6368bf9213 | |||
| cd802f1c22 | |||
| 59bb1d9579 | |||
| ae98ba76f7 | |||
| bd8c8fd988 | |||
| 7de5a1aa7c | |||
| a3e4fdd8a5 | |||
| f82fa051f2 | |||
| b66ddc3e81 | |||
| c85ac41ebc | |||
| 78d01533f4 | |||
| 18d4d66758 | |||
| 1b9dc9bb9e | |||
| 767d1157df | |||
| 1799889712 | |||
| 0b5d7b1534 | |||
| 10b78e6919 | |||
| 3fd72d081e | |||
| 1f3a465d3e | |||
| cddb6023e7 | |||
| 2f46484e0a | |||
| 4fcaf5d77d | |||
| ea8ddb43ef | |||
| 774c546487 | |||
| 6f46c1f598 | |||
| bc810ee2dd | |||
| 54a0419770 | |||
| d7a6bffe0a | |||
| df087decdb | |||
| 527764b0b5 | |||
| 8c97c68d11 | |||
| e5bf10cdb0 | |||
| de3912ff10 | |||
| 251a791876 | |||
| e36365ead6 | |||
| cb5c095888 | |||
| 281fa32c89 | |||
| d899701195 | |||
| 7061166fcd | |||
| 36b39d41ba | |||
| 7d0a7861ea | |||
| bf8c9bce95 | |||
| 9c984d3215 | |||
| 43e76770a8 | |||
| 6e10eabd9f | |||
| 922a4e3665 | |||
| 5e162ffb71 | |||
| f21ce92d43 | |||
| 98f3fc674b | |||
| c5901c65b3 | |||
| 2eab893dd7 | |||
| a3be035f98 | |||
| 743aa073b7 | |||
| a3de13c619 | |||
| 90be771211 | |||
| 7d79ec39ab | |||
| 211ff7205b | |||
| d0aa3b7b42 | |||
| d529eaa201 | |||
| c8ee64d7f4 | |||
| ec4c3fd98e | |||
| 0cc372cd36 | |||
| 9be405a89d | |||
| e3cfe89e19 | |||
| 144b99c180 | |||
| c788e118e3 | |||
| aae16335b3 | |||
| a43c250ed1 | |||
| 01d46f283b | |||
| 7c8c5b40a1 | |||
| 632f603fda | |||
| 4101ad033c | |||
| 3ee096bfab | |||
| 753e692fe2 | |||
| 40025a55c3 | |||
| 98262b2efc | |||
| 01e174f337 | |||
| a9fea7fd56 | |||
| e09ec81f1d | |||
| ee17f6862c | |||
| 8be7546eba | |||
| e156b732eb | |||
| 20c5a8ee92 | |||
| dac21a499f | |||
| 9a6c240140 | |||
| 8733ecd9b7 | |||
| fba531d3e8 | |||
| fc04d1b8d6 | |||
| 4c33a75164 | |||
| 6015e67d05 | |||
| f59f32ce26 | |||
| 3671610860 | |||
| c346f7f553 | |||
| f864616303 | |||
| ad2d28c9ba | |||
| 967923321f | |||
| 6b7e3281cf | |||
| fdf70f7eb8 | |||
| e4cb035ad9 | |||
| c02938b567 | |||
| c24740e8ec | |||
| e0d3912d83 | |||
| b113a30942 | |||
| 9534529e50 | |||
| 23a41d26b1 | |||
| 569c19ad1d | |||
| 17e30ebfe9 | |||
| 170e180c11 | |||
| 5a24038d68 | |||
| 99660b9a23 | |||
| 72f3bcfd78 | |||
| 297d61c188 | |||
| 382a58fc53 | |||
| 5daa299895 | |||
| af6609c97b | |||
| aca258268f | |||
| 9ff6dd7d4f | |||
| 40509cdd1f | |||
| 37f9238c55 | |||
| d14446d09e | |||
| 74cb4aaee4 | |||
| d90dbbcf21 | |||
| 31dd7f7ba4 | |||
| 113ac1bc2c | |||
| 0b01634a20 | |||
| 788f4ffea3 | |||
| d9285bbdc0 | |||
| a4eca1460e | |||
| 0c0061c9e0 | |||
| 88fec1e5d2 | |||
| d4b485160b | |||
| 97030e53a1 | |||
| 3da6e3c043 | |||
| 625b228103 | |||
| 6ae44241d5 | |||
| 1174fa4e12 | |||
| 01cbd7c117 | |||
| 1caa0ff96e | |||
| d83dd0badb | |||
| da0c1de47d | |||
| 15ec137edb | |||
| abdf763215 | |||
| a7c5a5f1cf | |||
| b37e8706a6 | |||
| e109f3584a | |||
| 8792c06e8a | |||
| 852ff91b7d | |||
| ba9d8e1bec | |||
| b7882ee6cf | |||
| d8ca913188 | |||
| ad86bc5fd4 | |||
| f04cdaa031 | |||
| 4b909adfb7 | |||
| f75b224a76 | |||
| 1a6e3626f6 | |||
| b5a8fe88ca | |||
| d5b003ab94 | |||
| 24d39e540e | |||
| b6fe8844f4 | |||
| 62f3ee1bcb | |||
| 07dc8b1d49 | |||
| 9c600f0773 | |||
| 66b1b8fa1b | |||
| eeff317abe | |||
| b346e104d4 | |||
| c9548e2da1 | |||
| 841ae91c33 | |||
| f2d86115fb | |||
| 0d596d9d3c | |||
| 0cce6075b9 | |||
| 0b1c27819e | |||
| 35c9f54f52 | |||
| f4bf5562f4 | |||
| 57c126a7bc | |||
| 35e1cc678a | |||
| f3d2fec0e9 | |||
| e919b2aa41 | |||
| 09f5460db7 | |||
| c92a931846 | |||
| 8f64edc3e4 | |||
| 7f03790cf7 | |||
| 622e15278f | |||
| 118caff7ba | |||
| dcfce13f4b | |||
| 8283e24407 | |||
| 2f8bcaa291 | |||
| 426b4a1332 | |||
| 316b1a52cb | |||
| 1b7ae688a6 | |||
| 9ca42dbc69 | |||
| d52a15eff7 | |||
| 9dc6742e82 | |||
| c1e12bffe8 | |||
| fc0914ded4 | |||
| f57d798971 | |||
| 2de1238405 | |||
| 0fd729be4a | |||
| ea42cc0aac | |||
| 7046957669 | |||
| 634b3292b4 | |||
| 4de6be7815 | |||
| da27ba37be | |||
| 87fe566d0d | |||
| ea5b16de0c | |||
| 386f1c627d | |||
| e77de7dbdb | |||
| a7eec4f7f2 | |||
| 4fff03ce54 | |||
| 9be13d1df5 | |||
| 698b7fbba9 | |||
| a3fc3eca2e | |||
| 6ce4747b4b | |||
| 89611b5e3e | |||
| fd652bd967 | |||
| 3a3de53752 | |||
| 10b0aa3c45 | |||
| d587b42987 | |||
| 9f3c4ffdf2 | |||
| 3054d5eb12 | |||
| 84633e5707 | |||
| 1c6b9f79c5 | |||
| 9923b66208 | |||
| 48d8b4f958 | |||
| 490207c9ff | |||
| 91156c42ae | |||
| 7dd5797481 | |||
| 7849f77712 | |||
| 1990501a66 | |||
| aad698f486 | |||
| 6ab093786a | |||
| b02d633f41 | |||
| d2094aa527 | |||
| 4d304cb567 | |||
| fb06437c43 | |||
| 5e0b31aaf2 | |||
| 756f1c0148 | |||
| 8ac2fad1b1 | |||
| e69e48533f | |||
| 9dbe31d207 | |||
| 2bffac6558 | |||
| db203e1503 | |||
| 97e0512dc3 | |||
| e8605336df | |||
| 818ed06a9a | |||
| 539600b0d3 | |||
| a4d8f0f613 | |||
| d903bd5a1c | |||
| fb8ee64ee4 | |||
| ac17be696b | |||
| 787d202a91 | |||
| ea7f7dc544 | |||
| bbd7d75b92 | |||
| 2d6466050b | |||
| 73c2038497 | |||
| 16e653afa8 | |||
| ce1558a6ab | |||
| 3ac81b376d | |||
| cd3ad93196 | |||
| a63b6b3633 | |||
| d77010ad41 | |||
| 92184e9456 | |||
| 092b19ae40 | |||
| 44fa0bd3e2 | |||
| 62fa5f79b6 | |||
| 42405ec93f | |||
| d07b0b5d7f | |||
| b9f24d9f4c | |||
| 2c5ac4dc5c | |||
| a19d6a2f4f | |||
| 951bfb4cd2 | |||
| 586dbc7a9a | |||
| cf55bf509e | |||
| 1ecc30fb21 | |||
| 47dd527c70 | |||
| d4c40a2a53 | |||
| a0c58841d7 | |||
| cfa729ff70 | |||
| 7ba5b5118a | |||
| 3da1717341 | |||
| 9978918bf9 | |||
| 904a1c5852 | |||
| 420428ec3e | |||
| ae7b1d8c99 | |||
| bdc535472d | |||
| ef4e476b89 | |||
| d33ffc7d95 | |||
| 1dd5cb631a | |||
| 4e530b4bff | |||
| c32e327320 | |||
| c9c6125462 | |||
| 2827b7c1b5 | |||
| 314a4939e2 | |||
| e805f017b9 | |||
| ed2a2b5c83 | |||
| 75fc3d9809 | |||
| a86b2fa50e | |||
| 296adfee51 | |||
| 00c56a2d68 | |||
| 3fd7f28d7e | |||
| d8659f8ed5 | |||
| 5e2f34f792 | |||
| 63459acc7f | |||
| db01879419 | |||
| ce8b48b101 | |||
| 42607df3d6 | |||
| af6015d6a0 | |||
| 1a3dabbc1e | |||
| 8afe3424d3 | |||
| 0e89643555 | |||
| 41c22399dc | |||
| 89ddd58935 | |||
| d6b3793275 | |||
| 6988c61a50 | |||
| 3f6348c49c | |||
| 0ccd54ba53 | |||
| 278061bd7c | |||
| 4c05ba695b | |||
| 8d2ca13b51 | |||
| 5b6f27d692 | |||
| e532ae7929 | |||
| 4e75952f57 | |||
| 5ecb2eb9b5 | |||
| 588952496b | |||
| 4d5ea77909 | |||
| 70fe15054a | |||
| 069f14ddf7 | |||
| 1f30b919f5 | |||
| f229d0b3e3 | |||
| fe920ca939 | |||
| 5bced012ca | |||
| b3b7eb10c2 | |||
| 10c0e28ecd | |||
| 0854061e36 | |||
| e7f78c52da | |||
| d182e9e925 | |||
| 63816ba4cf | |||
| 1bcebf8e00 | |||
| abefdd6c21 | |||
| 0e59ee93f8 | |||
| 215bfdf582 | |||
| 46a7dbd8f2 | |||
| 5d9343989b | |||
| 8a7bbb90d7 | |||
| c93d3a030d | |||
| c2e80f99c3 | |||
| bd7fef7c46 | |||
| f8e52bf4c0 | |||
| 9e58939dfd | |||
| b204e49e36 | |||
| 634fb62913 | |||
| 431487a328 | |||
| ae75d4011b | |||
| 168585c89d | |||
| 5017e20871 | |||
| b61223ea81 | |||
| 162c69fc7c | |||
| f76d57f331 | |||
| 080c18cf19 | |||
| 1ce4da5d11 | |||
| d6df900a49 | |||
| 200ea62d95 | |||
| c783793b5c | |||
| cfa3e58d09 | |||
| c432792300 | |||
| 6c257784ca | |||
| ba8295dc57 | |||
| 2872354a67 | |||
| ef2ffa0f78 | |||
| 12fc57af1f | |||
| 0e29dba043 | |||
| b75c3bc251 | |||
| 1aba163b60 | |||
| 9ea6eb2b5d | |||
| e8e4e4c953 | |||
| cee796c705 | |||
| 2d57fc5fee | |||
| 6ee9100fdc | |||
| 6a6fb74229 | |||
| 181788ee46 | |||
| 42eafde631 | |||
| 33e1a28223 | |||
| c47a7bc02f | |||
| 4e5f582978 | |||
| 7d586eec90 | |||
| 49e4291f2d | |||
| a19d789174 | |||
| 38288a47e5 | |||
| 2478e9a77b | |||
| 3c8775de3d | |||
| 23a2ea566d | |||
| bbc7498653 | |||
| 74c2285800 | |||
| 227610fcb9 | |||
| 4952be1b47 | |||
| ac022094ed | |||
| aa23377774 | |||
| 9b56b257b7 | |||
| 227cbd536f | |||
| ba41f520b0 | |||
| 03e21beddd | |||
| 0ba9ad75b0 | |||
| 6a0ff5cd80 | |||
| 3ff5384b42 | |||
| bec053ad18 | |||
| a4b41e845b | |||
| 97201c0f73 | |||
| 767e828332 | |||
| 68789de008 | |||
| d0fa715dee | |||
| 10d4b75b05 | |||
| 5645736256 | |||
| c400660a6f | |||
| 81f69631f9 | |||
| 2689093485 | |||
| d977aa0a70 | |||
| 73019c008b | |||
| 9d127230ca | |||
| 909f947407 | |||
| 43eba6b642 | |||
| 3fed74ea00 | |||
| 33422faf30 | |||
| 99de6a01ce | |||
| 13e38e8d5e | |||
| e8f48f7427 | |||
| adf71187c6 | |||
| 0bc844d6c5 | |||
| d489759087 | |||
| e0096e53a9 | |||
| 68d7568dd7 | |||
| 8574c4e14d | |||
| c9626dfa44 | |||
| 283c420f98 | |||
| e4ea3992b3 | |||
| b4a106e7bc | |||
| b7f4e9c043 | |||
| 6a638d2eeb | |||
| cac70f5707 | |||
| efb45218af | |||
| 0880f569b7 | |||
| 4e69ff1572 | |||
| 8045edac87 | |||
| 944ef033a0 | |||
| 813dde318c | |||
| d951bc13c8 | |||
| 59eac0569d | |||
| 515ce57c18 | |||
| 88f9c0e5fb | |||
| 69733beb5e | |||
| 55347f1cac | |||
| f62a8a38cb | |||
| 81792f3dd5 | |||
| 2e370ea217 | |||
| 45ffc31dfd | |||
| 5d28dafb68 | |||
| 7005ae7e15 | |||
| 421c7a908b | |||
| f29700dcd6 | |||
| 6e02d7bddb | |||
| 5e946343f2 | |||
| 42597da736 | |||
| 4b76e90db3 | |||
| e81beeb36b | |||
| f5d9cea236 | |||
| c9be4873f4 | |||
| e2b54cdf5e | |||
| c96703538c | |||
| f95fdf02a5 | |||
| 5f5edcff2c | |||
| a7efb274b8 | |||
| b560aacd7f | |||
| c61d761773 | |||
| cf1036b572 | |||
| 4bf630edd3 | |||
| bfca33e55d | |||
| 2a42eb5f3c | |||
| 1b2d6a9850 | |||
| 379091433c | |||
| b338ec0316 | |||
| ccf6f01e4d | |||
| 7482752698 | |||
| 05cf4fc138 | |||
| eb609e9873 | |||
| addf57b2a6 | |||
| d2d1c85f50 | |||
| 68d319e022 | |||
| 6626c2e274 | |||
| 90e72cbacc | |||
| 1b75de0376 | |||
| 0ae82776e1 | |||
| c2d5195243 | |||
| f6ca10846f | |||
| d1567fa2dd | |||
| 83a9007b51 | |||
| 869614fedb | |||
| c9ccb914bf | |||
| e50c9052ec | |||
| 84c9b99097 | |||
| 482a8ed50c | |||
| 0122491367 | |||
| 17b805a964 | |||
| 9821713309 | |||
| b2b5a2fcb4 | |||
| b980a76ca2 | |||
| d17c30c314 | |||
| dec60bc7d1 | |||
| d5c198c186 | |||
| dc2a639aff | |||
| 122093885d | |||
| 78d280eb37 | |||
| 38a9f0011a | |||
| 19e5cbd2da | |||
| 3be4b97c17 | |||
| adf4a10375 | |||
| 2c90293fc5 | |||
| 67cb048d71 | |||
| 9991b69471 | |||
| ba12a52224 | |||
| c0fa16dd71 | |||
| b7ae17f3bf | |||
| 4c66c428e6 | |||
| a150caad9d | |||
| 2b4d196e7c | |||
| 00381579db | |||
| ecfc0ac413 | |||
| b7a4568f07 | |||
| 9dee0e1bb1 | |||
| ae1cb70710 | |||
| 8f87a1304b | |||
| fac760b709 | |||
| 95914ec334 | |||
| b0590e52e8 | |||
| f7a51d1077 | |||
| a82f7bcb46 | |||
| 16481b4054 | |||
| c8e972f72d | |||
| d13a9d2b80 | |||
| f11b4b0c35 | |||
| a706a377b2 | |||
| 0a144a7473 | |||
| 028beabb5d | |||
| 35acb958cf | |||
| 5dc80e74d5 | |||
| fb42c39b2d | |||
| 027d5eebc7 | |||
| 0b4fe8a0ef | |||
| dc6e5daf65 | |||
| 28e33dd396 | |||
| 48e96d5775 | |||
| 0abe0b530c | |||
| 9ca4381442 | |||
| 5609fe35d7 | |||
| fb9d5637be | |||
| 81335c59f8 | |||
| 3649d53b04 | |||
| 5148891fc4 | |||
| 7e521235f4 | |||
| d800c8612f | |||
| 32352962ef | |||
| 0085add674 | |||
| 9d6815cb05 | |||
| b804b9df1f | |||
| 0724705939 | |||
| b8c28fc545 | |||
| 91d94ca9ac | |||
| c5deaa56e9 | |||
| c71f0a702d | |||
| 1550bb192a | |||
| 4018c07faf | |||
| e71fc68652 | |||
| e0fa434aeb | |||
| 14c38a1700 | |||
| dc7689793d | |||
| 0fb2387e30 | |||
| 259bc0b5eb | |||
| f67071fd74 | |||
| f3e5a66589 | |||
| cde36ffda5 | |||
| 290f678253 | |||
| 3eb8190cda | |||
| 6b47100f3f | |||
| 0acbb4c83a | |||
| 23806e366b | |||
| 09f9649d6f | |||
| 8528f3400f | |||
| 39675081a0 | |||
| 1d4f43d7ae | |||
| 7edf8792f8 | |||
| 085091d8c2 | |||
| 19450ec104 | |||
| 63acf93d20 | |||
| a297c65937 | |||
| b0aff0f61b | |||
| 5b8ae1d020 |
@@ -0,0 +1,61 @@
|
|||||||
|
name: Deploy GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
BASE_PATH: /SpotMicroESP32-Leika
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: "./app/pnpm-lock.yaml"
|
||||||
|
|
||||||
|
- run: pnpm install
|
||||||
|
- run: pnpm run build
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
|
with:
|
||||||
|
static_site_generator: "sveltekit"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: app/build/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: PlatformIO CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- "esp32/**"
|
||||||
|
- "platformio.ini"
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- "esp32/**"
|
||||||
|
- "platformio.ini"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pip
|
||||||
|
~/.platformio/.cache
|
||||||
|
key: ${{ runner.os }}-pio
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- run: pip install -r esp32/scripts/requirements.txt
|
||||||
|
- name: Install PlatformIO Core
|
||||||
|
run: pip install --upgrade platformio
|
||||||
|
|
||||||
|
- name: Build PlatformIO Project
|
||||||
|
run: pio run
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-artifacts
|
||||||
|
path: esp32/build/firmware
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
name: CI
|
name: Frontend Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
|
paths:
|
||||||
|
- 'app/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
|
paths:
|
||||||
|
- 'app/**'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -19,7 +23,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -30,6 +34,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
+8
-2
@@ -1,2 +1,8 @@
|
|||||||
*.pyc
|
.vscode/.browse.c_cpp.db*
|
||||||
spot_env
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pio
|
||||||
|
|||||||
Vendored
+4
-1
@@ -2,7 +2,10 @@
|
|||||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
// for the documentation about the extensions.json format
|
// for the documentation about the extensions.json format
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"platformio.platformio-ide"
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"platformio.platformio-ide",
|
||||||
|
"svelte.svelte-vscode"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": [
|
"unwantedRecommendations": [
|
||||||
"ms-vscode.cpptools-extension-pack"
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
|||||||
Vendored
+10
-1
@@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"cmath": "cpp"
|
"cmath": "cpp",
|
||||||
|
"array": "cpp",
|
||||||
|
"deque": "cpp",
|
||||||
|
"string": "cpp",
|
||||||
|
"unordered_map": "cpp",
|
||||||
|
"unordered_set": "cpp",
|
||||||
|
"vector": "cpp",
|
||||||
|
"string_view": "cpp",
|
||||||
|
"initializer_list": "cpp",
|
||||||
|
"regex": "cpp"
|
||||||
},
|
},
|
||||||
"editor.tabSize": 4,
|
"editor.tabSize": 4,
|
||||||
"editor.detectIndentation": false,
|
"editor.detectIndentation": false,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
PUBLIC_VITE_USE_HOST_NAME=true
|
||||||
|
PUBLIC_USE_JSON=true
|
||||||
|
PUBLIC_USE_MSGPACK=true
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API_URL="leika.local"
|
|
||||||
VITE_SOCKET_URL="leika.local"
|
|
||||||
VITE_EMBEDDED_BUILD=true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API_URL="hostname"
|
|
||||||
VITE_SOCKET_URL="hostname:2096"
|
|
||||||
VITE_EMBEDDED_BUILD=true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API_URL="hostname"
|
|
||||||
VITE_SOCKET_URL="hostname:2096"
|
|
||||||
VITE_EMBEDDED_BUILD=false
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API_URL="leika.local"
|
|
||||||
VITE_SOCKET_URL="leika.local"
|
|
||||||
VITE_EMBEDDED_BUILD=false
|
|
||||||
+1
-1
@@ -10,4 +10,4 @@ node_modules
|
|||||||
# Ignore files for PNPM, NPM and YARN
|
# Ignore files for PNPM, NPM and YARN
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|||||||
+30
-19
@@ -1,20 +1,31 @@
|
|||||||
|
/** @type { import("eslint").Linter.Config } */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
parser: '@typescript-eslint/parser',
|
extends: [
|
||||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
'eslint:recommended',
|
||||||
plugins: ['svelte3', '@typescript-eslint'],
|
'plugin:@typescript-eslint/recommended',
|
||||||
ignorePatterns: ['*.cjs'],
|
'plugin:svelte/recommended',
|
||||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
'prettier'
|
||||||
settings: {
|
],
|
||||||
'svelte3/typescript': () => require('typescript')
|
parser: '@typescript-eslint/parser',
|
||||||
},
|
plugins: ['@typescript-eslint'],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2020
|
ecmaVersion: 2020,
|
||||||
},
|
extraFileExtensions: ['.svelte']
|
||||||
env: {
|
},
|
||||||
browser: true,
|
env: {
|
||||||
es2017: true,
|
browser: true,
|
||||||
node: true
|
es2017: true,
|
||||||
}
|
node: true
|
||||||
};
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.svelte'],
|
||||||
|
parser: 'svelte-eslint-parser',
|
||||||
|
parserOptions: {
|
||||||
|
parser: '@typescript-eslint/parser'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"es2021": true
|
|
||||||
},
|
|
||||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
|
||||||
"overrides": [],
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": "latest",
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"plugins": ["@typescript-eslint"],
|
|
||||||
"rules": {}
|
|
||||||
}
|
|
||||||
+8
-23
@@ -1,24 +1,9 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
node_modules
|
||||||
*.ntvs*
|
/build
|
||||||
*.njsproj
|
/.svelte-kit
|
||||||
*.sln
|
/package
|
||||||
*.sw?
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
+1
-10
@@ -1,13 +1,4 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/build
|
|
||||||
/.svelte-kit
|
|
||||||
/package
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Ignore files for PNPM, NPM and YARN
|
# Ignore files for PNPM, NPM and YARN
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|||||||
+10
-7
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"useTabs": true,
|
"useTabs": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"tabWidth": 4,
|
||||||
"printWidth": 100,
|
"trailingComma": "none",
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"arrowParens": "avoid",
|
||||||
"pluginSearchDirs": ["."],
|
"experimentalTernaries": true,
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
"printWidth": 100,
|
||||||
|
"semi": false,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -1,3 +1,29 @@
|
|||||||
# Controller App
|
# create-svelte
|
||||||
|
|
||||||
This is the controller for my spot micro
|
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created your project, follow these steps:
|
||||||
|
|
||||||
|
1: Delete package-lock.json
|
||||||
|
2: Check `git status`. If you see any changes other than package-lock.json or favicon.ico, run the command `git restore ./` (See below)
|
||||||
|
3: Run `npm install` or `pnpm install` or `yarn` to install the dependencies
|
||||||
|
4: Run `npm run build` to build the project
|
||||||
|
|
||||||
|
Running `git status` should show:
|
||||||
|
|
||||||
|
[](https://postimg.cc/7CFsp2bq)
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare module 'app-env' {
|
||||||
|
interface ENV {
|
||||||
|
VITE_USE_HOST_NAME: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const appEnv: ENV
|
||||||
|
export default appEnv
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
+64
-54
@@ -1,55 +1,65 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "spot_micro_controller",
|
||||||
"private": true,
|
"version": "0.0.1",
|
||||||
"version": "0.0.0",
|
"private": true,
|
||||||
"type": "module",
|
"scripts": {
|
||||||
"scripts": {
|
"dev": "vite dev --host",
|
||||||
"dev": "vite --mode embedded",
|
"build": "vite build",
|
||||||
"dev:mock_embedded": "vite --mode mock_embedded",
|
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
|
||||||
"dev:mock_web": "vite --mode mock_web",
|
"preview": "vite preview",
|
||||||
"build": "vite build --mode embedded",
|
"test": "pnpm run test:integration && pnpm run test:unit",
|
||||||
"build:mock_web": "vite build --mode mock_web",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"build:web": "vite build --mode web",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"preview": "vite preview",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test": "vitest --environment jsdom",
|
"format": "prettier --write .",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"test:integration": "playwright test",
|
||||||
"format": "prettier --plugin-search-dir . --write ."
|
"test:unit": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@tsconfig/svelte": "^5.0.2",
|
"@iconify-json/tabler": "^1.2.23",
|
||||||
"@types/three": "^0.160.0",
|
"@playwright/test": "^1.56.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@typescript-eslint/parser": "^6.20.0",
|
"@sveltejs/kit": "^2.46.4",
|
||||||
"autoprefixer": "^10.4.17",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"@types/eslint": "^9.6.1",
|
||||||
"husky": "^9.0.7",
|
"@types/three": "^0.180.0",
|
||||||
"jsdom": "^24.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.46.0",
|
||||||
"lint-staged": "^15.2.0",
|
"@typescript-eslint/parser": "^8.46.0",
|
||||||
"postcss": "^8.4.33",
|
"autoprefixer": "^10.4.21",
|
||||||
"prettier": "3.2.4",
|
"eslint": "^9.37.0",
|
||||||
"svelte": "^4.2.9",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"svelte-check": "^3.6.3",
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
"svelte-hero-icons": "^5.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"prettier": "^3.6.2",
|
||||||
"tslib": "^2.6.2",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"typescript": "^5.3.3",
|
"svelte": "^5.39.11",
|
||||||
"vite": "^5.0.12",
|
"svelte-check": "^4.3.3",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"svelte-focus-trap": "^1.2.0",
|
||||||
"vite-plugin-singlefile": "^1.0.0",
|
"tailwindcss": "^4.1.14",
|
||||||
"vitest": "^1.3.1"
|
"tslib": "^2.8.1",
|
||||||
},
|
"typescript": "^5.9.3",
|
||||||
"dependencies": {
|
"unplugin-icons": "^22.4.2",
|
||||||
"nipplejs": "^0.10.1",
|
"vite": "^7.1.9",
|
||||||
"prettier-plugin-svelte": "^3.2.1",
|
"vitest": "^3.2.4"
|
||||||
"svelte-routing": "^2.11.0",
|
},
|
||||||
"three": "^0.160.1",
|
"type": "module",
|
||||||
"urdf-loader": "^0.12.1",
|
"dependencies": {
|
||||||
"uzip": "^0.20201231.0",
|
"@msgpack/msgpack": "^3.1.2",
|
||||||
"xacro-parser": "^0.3.9"
|
"@niku/vite-env-caster": "^1.1.2",
|
||||||
},
|
"@sveltejs/adapter-auto": "^6.1.1",
|
||||||
"lint-staged": {
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"*.js": "eslint --cache --fix",
|
"chart.js": "^4.5.0",
|
||||||
"*.{js,css,md,ts,svelte}": "prettier --write"
|
"compare-versions": "^6.1.1",
|
||||||
}
|
"cross-env": "^10.1.0",
|
||||||
}
|
"daisyui": "^5.2.0",
|
||||||
|
"nipplejs": "^0.10.2",
|
||||||
|
"svelte-dnd-list": "^0.1.8",
|
||||||
|
"svelte-modals": "^2.0.1",
|
||||||
|
"three": "^0.180.0",
|
||||||
|
"urdf-loader": "^0.12.6",
|
||||||
|
"uzip": "^0.20201231.0",
|
||||||
|
"xacro-parser": "^0.3.10"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.3.0"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PlaywrightTestConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
const config: PlaywrightTestConfig = {
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm run build && pnpm run preview',
|
||||||
|
port: 4173
|
||||||
|
},
|
||||||
|
testDir: 'tests/integration',
|
||||||
|
testMatch: /(.+\.)?(test|spec)\.[jt]s/
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
Generated
+3264
-2562
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Binary file not shown.
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Router, Route } from 'svelte-routing';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import TopBar from './components/TopBar.svelte';
|
|
||||||
import socketService from '$lib/services/socket-service';
|
|
||||||
import Controller from './routes/Controller.svelte';
|
|
||||||
import { fileService } from '$lib/services';
|
|
||||||
import Settings from './routes/Settings.svelte';
|
|
||||||
import { jointNames, model, outControllerData, mode } from '$lib/stores';
|
|
||||||
import { loadModelAsync, socketLocation } from '$lib/utilities';
|
|
||||||
import type { Result } from '$lib/utilities/result';
|
|
||||||
|
|
||||||
export let url = window.location.pathname;
|
|
||||||
onMount(async () => {
|
|
||||||
socketService.connect(socketLocation);
|
|
||||||
socketService.addPublisher(outControllerData);
|
|
||||||
socketService.addPublisher(mode, 'mode');
|
|
||||||
|
|
||||||
registerFetchIntercept();
|
|
||||||
const modelRes = await loadModelAsync('/spot_micro.urdf.xacro');
|
|
||||||
|
|
||||||
if (modelRes.isOk()) {
|
|
||||||
const [urdf, JOINT_NAME] = modelRes.inner;
|
|
||||||
jointNames.set(JOINT_NAME);
|
|
||||||
model.set(urdf);
|
|
||||||
} else {
|
|
||||||
console.error(modelRes.inner, { exception: modelRes.exception });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerFetchIntercept = () => {
|
|
||||||
const { fetch: originalFetch } = window;
|
|
||||||
window.fetch = async (...args) => {
|
|
||||||
const [resource, config] = args;
|
|
||||||
let file: Result<Uint8Array | undefined, string>;
|
|
||||||
file = await fileService.getFile(resource.toString());
|
|
||||||
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Router {url}>
|
|
||||||
<TopBar />
|
|
||||||
<div class="absolute w-full h-full bg-background text-on-background">
|
|
||||||
<Route path="/" component={Controller} />
|
|
||||||
<Route path="/settings/*page" component={Settings} />
|
|
||||||
</div>
|
|
||||||
</Router>
|
|
||||||
+42
-14
@@ -1,20 +1,48 @@
|
|||||||
:root {
|
@import 'tailwindcss';
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
@plugin "daisyui";
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
@plugin "daisyui" {
|
||||||
text-rendering: optimizeLegibility;
|
themes:
|
||||||
-webkit-font-smoothing: antialiased;
|
light --default,
|
||||||
-moz-osx-font-smoothing: grayscale;
|
dark --prefersdark;
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@plugin "daisyui/theme" {
|
||||||
margin: 0;
|
name: 'light';
|
||||||
|
default: true;
|
||||||
|
--color-primary: #00bfff;
|
||||||
|
--color-secondary: #3c00ff;
|
||||||
|
--base-content: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: 'dark';
|
||||||
|
prefersdark: true;
|
||||||
|
--color-primary: #00bfff;
|
||||||
|
--color-secondary: #3c00ff;
|
||||||
|
--base-content: oklch(0.3 0.012 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nipple_0_0,
|
||||||
|
#nipple_1_1 {
|
||||||
|
z-index: 10 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#three-gui-panel {
|
#three-gui-panel {
|
||||||
top: 50px;
|
top: 64px;
|
||||||
right:0px
|
right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
#three-gui-panel {
|
||||||
|
top: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/logo512.png" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import nipplejs from 'nipplejs';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { capitalize, throttler, toInt8 } from '$lib/utilities';
|
|
||||||
import { input, outControllerData, mode, modes, type Modes } from '$lib/stores';
|
|
||||||
import type { vector } from '$lib/models';
|
|
||||||
import Range from './input/Range.svelte';
|
|
||||||
import Button from './input/Button.svelte';
|
|
||||||
|
|
||||||
let throttle = new throttler();
|
|
||||||
let left: nipplejs.JoystickManager;
|
|
||||||
let right: nipplejs.JoystickManager;
|
|
||||||
|
|
||||||
let throttle_timing = 40;
|
|
||||||
let data = new Int8Array($outControllerData.length);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
left = nipplejs.create({
|
|
||||||
zone: document.getElementById('left') as HTMLElement,
|
|
||||||
color: 'grey',
|
|
||||||
dynamicPage: true,
|
|
||||||
mode: 'static',
|
|
||||||
restOpacity: 0.3
|
|
||||||
});
|
|
||||||
|
|
||||||
right = nipplejs.create({
|
|
||||||
zone: document.getElementById('right') as HTMLElement,
|
|
||||||
color: 'grey',
|
|
||||||
dynamicPage: true,
|
|
||||||
mode: 'static',
|
|
||||||
restOpacity: 0.3
|
|
||||||
});
|
|
||||||
|
|
||||||
left.on('move', (_, data) => handleJoyMove('left', data.vector));
|
|
||||||
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }));
|
|
||||||
right.on('move', (_, data) => handleJoyMove('right', data.vector));
|
|
||||||
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleJoyMove = (key: 'left' | 'right', data: vector) => {
|
|
||||||
input.update((inputData) => {
|
|
||||||
inputData[key] = data;
|
|
||||||
return inputData;
|
|
||||||
});
|
|
||||||
throttle.throttle(updateData, throttle_timing);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateData = () => {
|
|
||||||
data[0] = 1;
|
|
||||||
data[1] = 0;
|
|
||||||
data[2] = toInt8($input.left.x, -1, 1);
|
|
||||||
data[3] = toInt8($input.left.y, -1, 1);
|
|
||||||
data[4] = toInt8($input.right.x, -1, 1);
|
|
||||||
data[5] = toInt8($input.right.y, -1, 1);
|
|
||||||
data[6] = toInt8($input.height, 0, 100);
|
|
||||||
data[7] = toInt8($input.speed, 0, 100);
|
|
||||||
|
|
||||||
outControllerData.set(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyup = (event: KeyboardEvent) => {
|
|
||||||
const down = event.type === 'keydown';
|
|
||||||
input.update((data) => {
|
|
||||||
if (event.key === 'w') data.left.y = down ? -1 : 0;
|
|
||||||
if (event.key === 'a') data.left.x = down ? -1 : 0;
|
|
||||||
if (event.key === 's') data.left.y = down ? 1 : 0;
|
|
||||||
if (event.key === 'd') data.left.x = down ? 1 : 0;
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
throttle.throttle(updateData, throttle_timing);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRange = (event:CustomEvent, key: 'speed' | 'height') => {
|
|
||||||
const value:number = event.detail
|
|
||||||
input.update((inputData) => {
|
|
||||||
inputData[key] = value;
|
|
||||||
return inputData;
|
|
||||||
});
|
|
||||||
throttle.throttle(updateData, throttle_timing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeMode = (modeValue: Modes) => {
|
|
||||||
mode.set(modeValue);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="absolute top-0 left-0 w-screen h-screen">
|
|
||||||
<div class="absolute top-0 left-0 h-full w-full flex portrait:hidden">
|
|
||||||
<div id="left" class="flex w-60 items-center justify-end" />
|
|
||||||
<div class="flex-1" />
|
|
||||||
<div id="right" class="flex w-60 items-center" />
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-0 z-10 p-4 gap-4 flex items-end">
|
|
||||||
{#each modes as modeValue}
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
on:click={() => changeMode(modeValue)}
|
|
||||||
active={$mode === modeValue}
|
|
||||||
>
|
|
||||||
{capitalize(modeValue)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div>
|
|
||||||
{#if $mode === 'walk'}
|
|
||||||
<Range label="Speed" on:value={(e) => handleRange(e, 'speed')}></Range>
|
|
||||||
{/if}
|
|
||||||
<Range label="Height" on:value={(e) => handleRange(e, 'height')}></Range>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svelte:window on:keyup={handleKeyup} on:keydown={handleKeyup} />
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import socketService from '$lib/services/socket-service';
|
|
||||||
import { Icon, Bars3, XMark, Power, Battery100, Signal, SignalSlash } from 'svelte-hero-icons';
|
|
||||||
import { emulateModel } from '$lib/stores';
|
|
||||||
import { Link, useLocation } from 'svelte-routing';
|
|
||||||
import { isConnected } from '$lib/stores';
|
|
||||||
|
|
||||||
const views = ['Virtual environment', 'Robot camera'];
|
|
||||||
const modes = ['Drive', 'Choreography'];
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
let selected_view = views[0];
|
|
||||||
let selected_modes = modes[0];
|
|
||||||
let settingOpen = window.location.pathname.includes('/settings');
|
|
||||||
|
|
||||||
$: emulateModel.set(selected_view === views[0]);
|
|
||||||
$: settingOpen = $location.pathname.includes('/settings');
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if ($isConnected) {
|
|
||||||
socketService.send(JSON.stringify({ type: 'system/stop' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="topbar absolute left-0 top-0 w-full z-10 flex justify-between bg-zinc-800">
|
|
||||||
<div class="flex gap-2 p-2">
|
|
||||||
{#if settingOpen}
|
|
||||||
<Link to="/">
|
|
||||||
<Icon src={XMark} size="32" />
|
|
||||||
</Link>
|
|
||||||
{:else}
|
|
||||||
<Link to="/settings">
|
|
||||||
<Icon src={Bars3} size="32" />
|
|
||||||
</Link>
|
|
||||||
{/if}
|
|
||||||
<select
|
|
||||||
bind:value={selected_modes}
|
|
||||||
class="rounded-md outline outline-2 text-zinc-200 outline-zinc-600 bg-zinc-800"
|
|
||||||
>
|
|
||||||
{#each modes as mode}
|
|
||||||
<option>{mode}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
bind:value={selected_view}
|
|
||||||
class="rounded-md outline outline-2 text-zinc-200 outline-zinc-600 bg-zinc-800"
|
|
||||||
>
|
|
||||||
{#each views as view}
|
|
||||||
<option>{view}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 p-2">
|
|
||||||
<button class="action_button bg-zinc-600">
|
|
||||||
<Icon src={Power} size="24" />
|
|
||||||
</button>
|
|
||||||
<button class="action_button"><Icon src={Battery100} size="24" /></button>
|
|
||||||
<button class="action_button"
|
|
||||||
><Icon src={$isConnected ? Signal : SignalSlash} size="24" /></button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button class="h-full w-20 bg-red-600 text-white" on:click={stop}>STOP</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.topbar {
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
.action_button {
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
outline: 1px solid #52525b;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { BufferGeometry, CanvasTexture, CircleGeometry, CubicBezierCurve3, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, Vector3, type NormalBufferAttributes } from 'three';
|
|
||||||
import socketService from '$lib/services/socket-service';
|
|
||||||
import uzip from 'uzip';
|
|
||||||
import { model } from '$lib/stores';
|
|
||||||
import { footColor, isEmbeddedApp, location, toeWorldPositions } from '$lib/utilities';
|
|
||||||
import { fileService } from '$lib/services';
|
|
||||||
import { servoAngles, mpu, jointNames } from '$lib/stores';
|
|
||||||
import SceneBuilder from '$lib/sceneBuilder';
|
|
||||||
import { lerp, degToRad } from 'three/src/math/MathUtils';
|
|
||||||
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
|
|
||||||
|
|
||||||
let sceneManager = new SceneBuilder();
|
|
||||||
let canvas: HTMLCanvasElement, streamCanvas: HTMLCanvasElement, stream: HTMLImageElement;
|
|
||||||
let context: CanvasRenderingContext2D, texture: CanvasTexture;
|
|
||||||
|
|
||||||
let modelAngles: number[] | Int16Array = new Array(12).fill(0);
|
|
||||||
let modelTargetAngles: number[] | Int16Array = new Array(12).fill(0);
|
|
||||||
|
|
||||||
let feet_trace = new Array(4).fill([]);
|
|
||||||
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
|
|
||||||
|
|
||||||
const videoStream = `//${location}/api/stream`;
|
|
||||||
|
|
||||||
let showStream = false;
|
|
||||||
|
|
||||||
let settings = {
|
|
||||||
'Trace feet':true,
|
|
||||||
'Trace points': 30,
|
|
||||||
'Fix camera on robot': true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await cacheModelFiles()
|
|
||||||
await createScene();
|
|
||||||
if (!isEmbeddedApp) createPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
canvas.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const createPanel = () => {
|
|
||||||
const panel = new GUI({width: 310});
|
|
||||||
panel.close();
|
|
||||||
panel.domElement.id = 'three-gui-panel';
|
|
||||||
|
|
||||||
const visibility = panel.addFolder('Visualization');
|
|
||||||
visibility.add(settings, 'Trace feet')
|
|
||||||
visibility.add(settings, 'Trace points', 1, 1000, 1)
|
|
||||||
visibility.add(settings, 'Fix camera on robot')
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheModelFiles = async () => {
|
|
||||||
let data = await fetch('/stl.zip').then((data) => data.arrayBuffer());
|
|
||||||
|
|
||||||
var files = uzip.parse(data);
|
|
||||||
|
|
||||||
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
|
|
||||||
const url = new URL(path, window.location.href);
|
|
||||||
fileService.saveFile(url.toString(), data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateAngles = (name: string, angle: number) => {
|
|
||||||
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
|
|
||||||
socketService.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'kinematic/angle',
|
|
||||||
angle: angle * (180 / Math.PI),
|
|
||||||
id: $jointNames.indexOf(name)
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createScene = async () => {
|
|
||||||
sceneManager
|
|
||||||
.addRenderer({ antialias: true, canvas: canvas, alpha: true })
|
|
||||||
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
|
|
||||||
.addOrbitControls(8, 30)
|
|
||||||
.addSky()
|
|
||||||
.addGroundPlane()
|
|
||||||
.addGridHelper({ size: 250, divisions: 125 })
|
|
||||||
.addAmbientLight({ color: 0xffffff, intensity: 0.7 })
|
|
||||||
.addDirectionalLight({ x: 10, y: 100, z: 10, color: 0xffffff, intensity: 1 })
|
|
||||||
.addArrowHelper({ origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: -2, z: 0 } })
|
|
||||||
.addFogExp2(0xcccccc, 0.015)
|
|
||||||
.addModel($model)
|
|
||||||
.addDragControl(updateAngles)
|
|
||||||
.handleResize()
|
|
||||||
.addRenderCb(render)
|
|
||||||
.startRenderLoop();
|
|
||||||
|
|
||||||
addVideoStream();
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const geometry = new BufferGeometry();
|
|
||||||
const material = new LineBasicMaterial({ color: footColor() });
|
|
||||||
const line = new Line(geometry, material);
|
|
||||||
trace_lines.push(geometry);
|
|
||||||
sceneManager.scene.add(line);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addVideoStream = () => {
|
|
||||||
context = streamCanvas.getContext('2d')!;
|
|
||||||
texture = new CanvasTexture(stream);
|
|
||||||
const liveStream = new Mesh(
|
|
||||||
new CircleGeometry(35, 32),
|
|
||||||
new MeshBasicMaterial({ map: texture })
|
|
||||||
);
|
|
||||||
liveStream.position.z = -50;
|
|
||||||
liveStream.visible = showStream;
|
|
||||||
sceneManager.scene.add(liveStream);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVideoStream = () => {
|
|
||||||
if (!showStream) return;
|
|
||||||
context.drawImage(stream, 0, 0);
|
|
||||||
texture.needsUpdate = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTraceLines = (foot_positions: Vector3[]) => {
|
|
||||||
if (!settings['Trace feet']) {
|
|
||||||
if (!feet_trace.length) return
|
|
||||||
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
|
|
||||||
feet_trace = new Array(4).fill([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trace_lines.forEach((line, i) => {
|
|
||||||
feet_trace[i].push(foot_positions[i])
|
|
||||||
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
|
|
||||||
line.setFromPoints(feet_trace[i]);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
const robot = sceneManager.model;
|
|
||||||
if (!robot) return;
|
|
||||||
|
|
||||||
const toes = toeWorldPositions(robot)
|
|
||||||
|
|
||||||
renderTraceLines(toes)
|
|
||||||
|
|
||||||
if (settings['Fix camera on robot']) {
|
|
||||||
sceneManager.controls.target = robot.position.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
|
|
||||||
robot.rotation.z = lerp(robot.rotation.z, degToRad($mpu.heading + 90), 0.1);
|
|
||||||
modelTargetAngles = $servoAngles;
|
|
||||||
|
|
||||||
handleVideoStream();
|
|
||||||
|
|
||||||
for (let i = 0; i < $jointNames.length; i++) {
|
|
||||||
modelAngles[i] = lerp(
|
|
||||||
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
|
|
||||||
modelTargetAngles[i],
|
|
||||||
0.1
|
|
||||||
);
|
|
||||||
robot.joints[$jointNames[i]].setJointValue(degToRad(modelAngles[i]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window on:resize={sceneManager.handleResize} />
|
|
||||||
|
|
||||||
{#if showStream}
|
|
||||||
<img
|
|
||||||
bind:this={stream}
|
|
||||||
src={videoStream}
|
|
||||||
class="hidden"
|
|
||||||
alt="Live stream is down"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<canvas bind:this={streamCanvas} class="hidden"></canvas>
|
|
||||||
<canvas bind:this={canvas} class="absolute"></canvas>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { location } from '$lib/utilities';
|
|
||||||
|
|
||||||
let videoStream = `//${location}/api/stream`;
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
videoStream = '#';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full h-full">
|
|
||||||
<img
|
|
||||||
src={videoStream}
|
|
||||||
class="absolute object-cover blur-3xl w-full h-full -z-10"
|
|
||||||
alt="Live stream is down"
|
|
||||||
/>
|
|
||||||
<img src={videoStream} class="object-contain w-full h-full" alt="Live stream is down" />
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export let active = false
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
on:click
|
|
||||||
class={$$restProps.class + ' rounded-md outline outline-2 text-zinc-200 outline-zinc-600 p-2' +
|
|
||||||
(active ? ' bg-zinc-600' : '')}
|
|
||||||
>
|
|
||||||
<slot/>
|
|
||||||
</button>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
|
|
||||||
export let value = 50;
|
|
||||||
export let min = 0;
|
|
||||||
export let max = 100;
|
|
||||||
export let label = '';
|
|
||||||
|
|
||||||
const dispatchValueInput = () => {
|
|
||||||
dispatch('value', value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<input
|
|
||||||
id="range"
|
|
||||||
type="range"
|
|
||||||
{min}
|
|
||||||
{max}
|
|
||||||
bind:value
|
|
||||||
on:change
|
|
||||||
on:input={dispatchValueInput}
|
|
||||||
class="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label for="range" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{label}</label
|
|
||||||
>
|
|
||||||
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { jointNames } from '../../lib/stores';
|
|
||||||
|
|
||||||
type Servo = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
minPWM: number;
|
|
||||||
maxPWM: number;
|
|
||||||
pwmFor180: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let servos: Servo[] = [];
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
jointNames.subscribe((data) => {
|
|
||||||
servos = data.map((name: string, i: number) => {
|
|
||||||
return {
|
|
||||||
id: i,
|
|
||||||
name,
|
|
||||||
minPWM: 0,
|
|
||||||
maxPWM: 0,
|
|
||||||
pwmFor180: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let selectedServo: number | null = null;
|
|
||||||
|
|
||||||
function updateServoValue(index: number, field: keyof Servo, value: number): void {
|
|
||||||
servos[index] = { ...servos[index], [field]: value };
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatServo = (servo: Servo) => {
|
|
||||||
const string = servo.name;
|
|
||||||
const name = string.charAt(0).toUpperCase() + string.split('_').join(' ').slice(1);
|
|
||||||
return `${servo.id} ${name}`;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="servo-selector">
|
|
||||||
<label for="servo-select">Select Servo:</label>
|
|
||||||
<select id="servo-select" class="bg-zinc-800" bind:value={selectedServo}>
|
|
||||||
{#each servos as servo}
|
|
||||||
<option value={servo.id}>{formatServo(servo)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedServo !== null}
|
|
||||||
<div class="mt-5">
|
|
||||||
<h2>Servo {formatServo(servos[selectedServo])} Calibration</h2>
|
|
||||||
<label for="minPWM">Min PWM:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="minPWM"
|
|
||||||
class="bg-zinc-800"
|
|
||||||
value={servos[selectedServo].minPWM}
|
|
||||||
on:blur={(event) =>
|
|
||||||
updateServoValue(selectedServo ?? 0, 'minPWM', Number(event.target?.value))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label for="maxPWM">Max PWM:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="maxPWM"
|
|
||||||
class="bg-zinc-800"
|
|
||||||
value={servos[selectedServo].maxPWM}
|
|
||||||
on:blur={(event) =>
|
|
||||||
updateServoValue(selectedServo ?? 0, 'maxPWM', Number(event.target?.value))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label for="pwmFor180">PWM for 180°:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="pwmFor180"
|
|
||||||
class="bg-zinc-800"
|
|
||||||
value={servos[selectedServo].pwmFor180}
|
|
||||||
on:blur={(event) =>
|
|
||||||
updateServoValue(selectedServo ?? 0, 'pwmFor180', Number(event.target?.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { socketService } from '$lib/services';
|
|
||||||
import { isConnected, settings } from '$lib/stores';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if ($isConnected) {
|
|
||||||
const message = JSON.stringify({ type: 'system/settings' });
|
|
||||||
socketService.send(message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full h-full">
|
|
||||||
<div>
|
|
||||||
{#each Object.entries($settings) as entry}
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div class="w-32">{entry[0]}:</div>
|
|
||||||
<div>{entry[1]}</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { humanFileSize } from '$lib/utilities';
|
|
||||||
import socketService from '$lib/services/socket-service';
|
|
||||||
import { isConnected, systemInfo } from '$lib/stores';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if ($isConnected) {
|
|
||||||
const message = JSON.stringify({ type: 'system/info' });
|
|
||||||
socketService.send(message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full h-full">
|
|
||||||
<div class="w-1/3">
|
|
||||||
{#each Object.entries($systemInfo ?? {}) as entry}
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div class="w-32">{entry[0]}:</div>
|
|
||||||
{#if entry[0].includes('Size') || entry[0].includes('Free') || entry[0].includes('Min')}
|
|
||||||
<div>{humanFileSize(entry[1])}</div>
|
|
||||||
{:else}
|
|
||||||
<div>{entry[1]}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import socketService from '$lib/services/socket-service';
|
|
||||||
import { isConnected, logs } from '$lib/stores';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if ($isConnected) {
|
|
||||||
const message = JSON.stringify({ type: 'system/logs' });
|
|
||||||
socketService.send(message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="w-full h-full">
|
|
||||||
{#each $logs as entry}
|
|
||||||
<div>{entry}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* @layer base {
|
|
||||||
:root {
|
|
||||||
--primary: 98 0 238;
|
|
||||||
--primary-variant: 55 0 179;
|
|
||||||
--secondary: 55 0 179;
|
|
||||||
--secondary-variant: 55 0 179;
|
|
||||||
--background: 255 255 255;
|
|
||||||
--surface: 251 251 250;
|
|
||||||
--error: 176 0 32;
|
|
||||||
--on-primary: 255 255 255;
|
|
||||||
--on-secondary: 0 0 0;
|
|
||||||
--on-background: 0 0 0;
|
|
||||||
--on-surface: 0 0 0;
|
|
||||||
--on-error: 255 255 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[class~="dark"] {
|
|
||||||
--primary: 98 0 238;
|
|
||||||
--primary-variant: 55 0 179;
|
|
||||||
--secondary: 55 0 179;
|
|
||||||
--secondary-variant: 55 0 179;
|
|
||||||
--background: 30 30 30;
|
|
||||||
--surface: 36 36 36;
|
|
||||||
--error: 176 0 32;
|
|
||||||
--on-primary: 255 255 255;
|
|
||||||
--on-secondary: 255 255 255;
|
|
||||||
--on-background: 255 255 255;
|
|
||||||
--on-surface: 255 255 255;
|
|
||||||
--on-error: 255 255 255;
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { get } from 'svelte/store'
|
||||||
|
import { Err, Ok, type Result } from './utilities'
|
||||||
|
import { apiLocation } from './stores'
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get<TResponse>(endpoint: string, params?: RequestInit) {
|
||||||
|
return sendRequest<TResponse>(endpoint, 'GET', null, params)
|
||||||
|
},
|
||||||
|
|
||||||
|
post<TResponse>(endpoint: string, data?: unknown) {
|
||||||
|
return sendRequest<TResponse>(endpoint, 'POST', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
put<TResponse>(endpoint: string, data?: unknown) {
|
||||||
|
return sendRequest<TResponse>(endpoint, 'PUT', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
remove<TResponse>(endpoint: string) {
|
||||||
|
return sendRequest<TResponse>(endpoint, 'DELETE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRequest<TResponse>(
|
||||||
|
endpoint: string,
|
||||||
|
method: string,
|
||||||
|
data?: unknown,
|
||||||
|
params?: RequestInit
|
||||||
|
): Promise<Result<TResponse, Error>> {
|
||||||
|
endpoint = resolveUrl(endpoint)
|
||||||
|
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
...params,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
...params?.headers,
|
||||||
|
Authorization: 'Basic',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint, request)
|
||||||
|
} catch {
|
||||||
|
return Err.new(new Error(), 'An error has occurred')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isResponseOk = response.status >= 200 && response.status < 400
|
||||||
|
if (!isResponseOk) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
return Err.new(new ApiError(response), 'User was not authorized')
|
||||||
|
}
|
||||||
|
return Err.new(new ApiError(response), 'An error has occurred')
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type')
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
const data = await response.json()
|
||||||
|
return Ok.new(data as TResponse)
|
||||||
|
} else {
|
||||||
|
// Handle empty object as response
|
||||||
|
return Ok.new(null as TResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(url: string): string {
|
||||||
|
if (url.startsWith('http') || !get(apiLocation)) return url
|
||||||
|
const protocol = window.location.protocol
|
||||||
|
return `${protocol}//${get(apiLocation)}${url.startsWith('/') ? '' : '/'}${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public readonly response: Response) {
|
||||||
|
super(`${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition'
|
||||||
|
import { cubicOut } from 'svelte/easing'
|
||||||
|
import { Down } from './icons'
|
||||||
|
|
||||||
|
function openCollapsible() {
|
||||||
|
open = !open
|
||||||
|
if (open) {
|
||||||
|
opened()
|
||||||
|
} else {
|
||||||
|
closed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon, title, children, open, opened, closed, class: klass } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-baseline">
|
||||||
|
{@render icon?.()}
|
||||||
|
{@render title?.()}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm" onclick={() => openCollapsible()}>
|
||||||
|
<Down
|
||||||
|
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
|
||||||
|
open
|
||||||
|
) ?
|
||||||
|
'rotate-180'
|
||||||
|
: ''}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 pt-0"
|
||||||
|
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { focusTrap } from 'svelte-focus-trap'
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
import { Cancel, Check } from '$lib/components/icons'
|
||||||
|
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
|
||||||
|
|
||||||
|
let {
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
labels = {
|
||||||
|
cancel: { label: 'Cancel', icon: Cancel },
|
||||||
|
confirm: { label: 'OK', icon: Check }
|
||||||
|
}
|
||||||
|
}: ModalProps = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
{@const SvelteComponent = labels?.confirm.icon}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
transition:fly={{ y: 50 }}
|
||||||
|
use:exitBeforeEnter
|
||||||
|
use:focusTrap
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<p class="text-base-content mb-1 text-start">{message}</p>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-error inline-flex items-center"
|
||||||
|
onclick={() => modals.close()}
|
||||||
|
>
|
||||||
|
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
|
||||||
|
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { focusTrap } from 'svelte-focus-trap'
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
import { telemetry } from '$lib/stores/telemetry'
|
||||||
|
import { Cancel } from './icons'
|
||||||
|
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals'
|
||||||
|
|
||||||
|
// provided by <Modals />
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen }: Props = $props()
|
||||||
|
|
||||||
|
let updating = $state(true)
|
||||||
|
|
||||||
|
let progress = $state(0)
|
||||||
|
$effect(() => {
|
||||||
|
if ($telemetry.download_ota.status == 'progress') {
|
||||||
|
progress = $telemetry.download_ota.progress
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($telemetry.download_ota.status == 'error') {
|
||||||
|
updating = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let message = $state('Preparing ...')
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($telemetry.download_ota.status == 'progress') {
|
||||||
|
message = 'Downloading ...'
|
||||||
|
} else if ($telemetry.download_ota.status == 'error') {
|
||||||
|
message = $telemetry.download_ota.error
|
||||||
|
} else if ($telemetry.download_ota.status == 'finished') {
|
||||||
|
message = 'Restarting ...'
|
||||||
|
progress = 0
|
||||||
|
// Reload page after 5 sec
|
||||||
|
setTimeout(() => {
|
||||||
|
modals.closeAll()
|
||||||
|
location.reload()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeClose(() => {
|
||||||
|
if (updating) {
|
||||||
|
// prevents modal from closing
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
$telemetry.download_ota.status = 'idle'
|
||||||
|
$telemetry.download_ota.error = ''
|
||||||
|
$telemetry.download_ota.progress = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
transition:fly={{ y: 50 }}
|
||||||
|
use:exitBeforeEnter
|
||||||
|
use:focusTrap
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<h2 class="text-base-content text-start text-2xl font-bold">Updating Firmware</h2>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<div class="bg-base-100 flex flex-col items-center justify-center p-6">
|
||||||
|
{#if $telemetry.download_ota.status == 'progress'}
|
||||||
|
<progress class="progress progress-primary w-56" value={progress} max="100"
|
||||||
|
></progress>
|
||||||
|
{:else}
|
||||||
|
<progress class="progress progress-primary w-56"></progress>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-8 text-2xl">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<div class="grow"></div>
|
||||||
|
<button
|
||||||
|
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
|
||||||
|
disabled={updating}
|
||||||
|
onclick={() => {
|
||||||
|
modals.closeAll()
|
||||||
|
location.reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { focusTrap } from 'svelte-focus-trap'
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
import { Check } from './icons'
|
||||||
|
import { exitBeforeEnter, type ModalProps } from 'svelte-modals'
|
||||||
|
|
||||||
|
let {
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onDismiss,
|
||||||
|
labels = {
|
||||||
|
dismiss: { label: 'Dismiss', icon: Check }
|
||||||
|
}
|
||||||
|
}: ModalProps = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
transition:fly={{ y: 50 }}
|
||||||
|
use:exitBeforeEnter
|
||||||
|
use:focusTrap
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<p class="text-base-content mb-1 text-start">{message}</p>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-warning text-warning-content inline-flex items-center"
|
||||||
|
onclick={onDismiss}
|
||||||
|
>
|
||||||
|
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { imu } from '$lib/stores/imu'
|
||||||
|
import SceneBuilder from '$lib/sceneBuilder'
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement
|
||||||
|
let sceneBuilder: SceneBuilder
|
||||||
|
let cube: THREE.Mesh
|
||||||
|
let targetRotation = new THREE.Euler()
|
||||||
|
let lastUpdateTime = 0
|
||||||
|
const LERP_SPEED = 5 // rotations per second
|
||||||
|
|
||||||
|
const initThreeJS = () => {
|
||||||
|
sceneBuilder = new SceneBuilder()
|
||||||
|
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
|
||||||
|
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
|
||||||
|
.addOrbitControls(1, 10, false)
|
||||||
|
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
|
||||||
|
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
|
||||||
|
.fillParent()
|
||||||
|
|
||||||
|
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0x00ff00,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8
|
||||||
|
})
|
||||||
|
cube = new THREE.Mesh(geometry, material)
|
||||||
|
sceneBuilder.scene.add(cube)
|
||||||
|
|
||||||
|
sceneBuilder.addRenderCb(() => {
|
||||||
|
if (!cube) return
|
||||||
|
const currentTime = performance.now()
|
||||||
|
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds
|
||||||
|
lastUpdateTime = currentTime
|
||||||
|
|
||||||
|
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime)
|
||||||
|
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor)
|
||||||
|
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor)
|
||||||
|
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor)
|
||||||
|
})
|
||||||
|
|
||||||
|
sceneBuilder.startRenderLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOrientation = () => {
|
||||||
|
if (!cube) return
|
||||||
|
|
||||||
|
const y = -$imu.x[$imu.x.length - 1] || 0
|
||||||
|
const x = $imu.y[$imu.y.length - 1] || 0
|
||||||
|
const z = -$imu.z[$imu.z.length - 1] || 0
|
||||||
|
|
||||||
|
targetRotation.set(
|
||||||
|
THREE.MathUtils.degToRad(x),
|
||||||
|
THREE.MathUtils.degToRad(y),
|
||||||
|
THREE.MathUtils.degToRad(z)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initThreeJS()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
sceneBuilder?.renderer?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($imu) {
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
|
||||||
|
<canvas class="w-full h-full" bind:this={canvas}></canvas>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition'
|
||||||
|
import { cubicOut } from 'svelte/easing'
|
||||||
|
import { Down } from './icons'
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
collapsible?: boolean
|
||||||
|
icon?: import('svelte').Snippet
|
||||||
|
title?: import('svelte').Snippet
|
||||||
|
children?: import('svelte').Snippet
|
||||||
|
right?: import('svelte').Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(true),
|
||||||
|
collapsible = true,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
right
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if collapsible}
|
||||||
|
<div
|
||||||
|
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-baseline">
|
||||||
|
{@render icon?.()}
|
||||||
|
{@render title?.()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-circle btn-ghost btn-sm"
|
||||||
|
onclick={() => {
|
||||||
|
open = !open
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Down
|
||||||
|
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
|
||||||
|
open
|
||||||
|
) ?
|
||||||
|
'rotate-180'
|
||||||
|
: ''}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 pt-0"
|
||||||
|
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-baseline">
|
||||||
|
{@render icon?.()}
|
||||||
|
{@render title?.()}
|
||||||
|
</span>
|
||||||
|
{@render right?.()}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Loader } from './icons'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full w-full flex-col items-center justify-center p-6">
|
||||||
|
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
|
||||||
|
<p class="text-xl">Loading...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentType } from 'svelte'
|
||||||
|
|
||||||
|
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
|
||||||
|
|
||||||
|
const {
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description = '',
|
||||||
|
variant = 'primary',
|
||||||
|
class: klass = '',
|
||||||
|
children = null
|
||||||
|
} = $props<{
|
||||||
|
icon?: ComponentType
|
||||||
|
title: string
|
||||||
|
description?: string | number
|
||||||
|
variant?: Variant
|
||||||
|
class?: string
|
||||||
|
children?: () => ComponentType
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const Icon = $derived(icon)
|
||||||
|
|
||||||
|
const variants: Record<Variant, [string, string]> = {
|
||||||
|
success: ['bg-success', 'text-success-content'],
|
||||||
|
error: ['bg-error', 'text-error-content'],
|
||||||
|
primary: ['bg-primary', 'text-primary-content'],
|
||||||
|
info: ['bg-info', 'text-info-content'],
|
||||||
|
warning: ['bg-warning', 'text-warning-content']
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
|
||||||
|
const [bgColor, textColor] = variants[variantKey]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}">
|
||||||
|
{#if icon}
|
||||||
|
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
|
||||||
|
<Icon class="{textColor} h-auto w-full scale-75" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="grow">
|
||||||
|
<div class="font-bold">{title}</div>
|
||||||
|
<div class="text-sm opacity-75 grow">{description}</div>
|
||||||
|
</div>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
import { apiLocation } from '$lib/stores'
|
||||||
|
|
||||||
|
let source = $state(`${$apiLocation}/api/camera/stream`)
|
||||||
|
|
||||||
|
onDestroy(() => (source = '#'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<img
|
||||||
|
src={source}
|
||||||
|
class="absolute object-cover blur-3xl w-full h-full -z-10"
|
||||||
|
alt="Live stream is down"
|
||||||
|
/>
|
||||||
|
<img src={source} class="object-contain w-full h-full" alt="Live stream is down" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script>
|
||||||
|
import { flip } from 'svelte/animate'
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
|
import { error, info, success, warning } from './icons'
|
||||||
|
|
||||||
|
/** @type {{theme?: any, icon?: any}} */
|
||||||
|
let {
|
||||||
|
theme = {
|
||||||
|
error: 'alert-error',
|
||||||
|
success: 'alert-success',
|
||||||
|
warning: 'alert-warning',
|
||||||
|
info: 'alert-info'
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
error: error,
|
||||||
|
success: success,
|
||||||
|
warning: warning,
|
||||||
|
info: info
|
||||||
|
}
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toast toast-end mr-4">
|
||||||
|
{#each $notifications as notification (notification.id)}
|
||||||
|
{@const SvelteComponent = icon[notification.type]}
|
||||||
|
<div
|
||||||
|
animate:flip={{ duration: 400 }}
|
||||||
|
class="alert animate-none {theme[notification.type]}"
|
||||||
|
in:fly={{ y: 100, duration: 400 }}
|
||||||
|
out:fly={{ x: 100, duration: 400 }}
|
||||||
|
>
|
||||||
|
<SvelteComponent class="h-6 w-6 shrink-0" />
|
||||||
|
<span>{notification.message}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
import {
|
||||||
|
Mesh,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
type Object3D,
|
||||||
|
SphereGeometry,
|
||||||
|
Vector3,
|
||||||
|
type Object3DEventMap,
|
||||||
|
Color
|
||||||
|
} from 'three'
|
||||||
|
import {
|
||||||
|
ModesEnum,
|
||||||
|
kinematicData,
|
||||||
|
mode,
|
||||||
|
model,
|
||||||
|
outControllerData,
|
||||||
|
servoAnglesOut,
|
||||||
|
servoAngles,
|
||||||
|
mpu,
|
||||||
|
jointNames,
|
||||||
|
currentKinematic,
|
||||||
|
walkGait,
|
||||||
|
walkGaitToMode
|
||||||
|
} from '$lib/stores'
|
||||||
|
import { populateModelCache, throttler, getToeWorldPositions } from '$lib/utilities'
|
||||||
|
import SceneBuilder from '$lib/sceneBuilder'
|
||||||
|
import { lerp, degToRad } from 'three/src/math/MathUtils'
|
||||||
|
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
|
||||||
|
import { type body_state_t } from '$lib/kinematic'
|
||||||
|
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
|
||||||
|
import { radToDeg } from 'three/src/math/MathUtils.js'
|
||||||
|
import type { URDFRobot } from 'urdf-loader'
|
||||||
|
import { get } from 'svelte/store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
defaultColor?: string | null
|
||||||
|
orbit?: boolean
|
||||||
|
panel?: boolean
|
||||||
|
debug?: boolean
|
||||||
|
ground?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
defaultColor = '#0091ff',
|
||||||
|
orbit = false,
|
||||||
|
panel = true,
|
||||||
|
debug = false,
|
||||||
|
ground = true
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let sceneManager = $state(new SceneBuilder())
|
||||||
|
let canvas: HTMLCanvasElement
|
||||||
|
|
||||||
|
let currentModelAngles: number[] = new Array(12).fill(0)
|
||||||
|
let modelTargetAngles: number[] = new Array(12).fill(0)
|
||||||
|
let gui_panel: GUI
|
||||||
|
let Throttler = new throttler()
|
||||||
|
|
||||||
|
let target: Object3D<Object3DEventMap>
|
||||||
|
|
||||||
|
let target_position = { x: 0, z: 0, yaw: 0 }
|
||||||
|
|
||||||
|
let kinematic = get(currentKinematic)
|
||||||
|
|
||||||
|
let planners = {
|
||||||
|
[ModesEnum.Deactivated]: new IdleState(),
|
||||||
|
[ModesEnum.Idle]: new IdleState(),
|
||||||
|
[ModesEnum.Calibration]: new CalibrationState(),
|
||||||
|
[ModesEnum.Rest]: new RestState(),
|
||||||
|
[ModesEnum.Stand]: new StandState(),
|
||||||
|
[ModesEnum.Walk]: new BezierState()
|
||||||
|
}
|
||||||
|
let lastTick = performance.now()
|
||||||
|
|
||||||
|
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
|
||||||
|
|
||||||
|
let body_state = {
|
||||||
|
omega: 0,
|
||||||
|
phi: 0,
|
||||||
|
psi: 0,
|
||||||
|
xm: 0,
|
||||||
|
ym: 0.5,
|
||||||
|
zm: 0,
|
||||||
|
feet: kinematic.getDefaultFeetPos(),
|
||||||
|
cumulative_x: 0,
|
||||||
|
cumulative_y: 0,
|
||||||
|
cumulative_z: 0,
|
||||||
|
cumulative_roll: 0,
|
||||||
|
cumulative_pitch: 0,
|
||||||
|
cumulative_yaw: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = {
|
||||||
|
'Internal kinematic': true,
|
||||||
|
'Robot transform controls': false,
|
||||||
|
'Auto orient robot': true,
|
||||||
|
'Trace feet': debug,
|
||||||
|
'Target position': false,
|
||||||
|
'Trace points': 30,
|
||||||
|
'Fix camera on robot': true,
|
||||||
|
'Smooth motion': true,
|
||||||
|
omega: 0,
|
||||||
|
phi: 0,
|
||||||
|
psi: 0,
|
||||||
|
xm: 0,
|
||||||
|
ym: 0.7,
|
||||||
|
zm: 0,
|
||||||
|
Background: defaultColor
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await populateModelCache()
|
||||||
|
await createScene()
|
||||||
|
servoAngles.subscribe(updateAnglesFromStore)
|
||||||
|
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
|
||||||
|
if (panel) createPanel()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
canvas.remove()
|
||||||
|
gui_panel?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateAnglesFromStore = (angles: number[]) => {
|
||||||
|
if (sceneManager.isDragging) return
|
||||||
|
if (settings['Internal kinematic']) return
|
||||||
|
modelTargetAngles = angles
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPanel = () => {
|
||||||
|
gui_panel = new GUI({ width: 310 })
|
||||||
|
gui_panel.close()
|
||||||
|
gui_panel.domElement.id = 'three-gui-panel'
|
||||||
|
|
||||||
|
const general = gui_panel.addFolder('General')
|
||||||
|
general.add(settings, 'Internal kinematic')
|
||||||
|
general.add(settings, 'Robot transform controls')
|
||||||
|
general.add(settings, 'Auto orient robot')
|
||||||
|
|
||||||
|
const kinematic = gui_panel.addFolder('Kinematics')
|
||||||
|
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
|
||||||
|
|
||||||
|
const visibility = gui_panel.addFolder('Visualization')
|
||||||
|
visibility.add(settings, 'Trace feet')
|
||||||
|
visibility.add(settings, 'Trace points', 1, 1000, 1)
|
||||||
|
visibility.add(settings, 'Target position')
|
||||||
|
visibility.add(settings, 'Smooth motion')
|
||||||
|
visibility.addColor(settings, 'Background').onChange(setSceneBackground).listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateKinematicPosition = () => {
|
||||||
|
kinematicData.set([
|
||||||
|
settings.omega,
|
||||||
|
settings.phi,
|
||||||
|
settings.psi,
|
||||||
|
settings.xm,
|
||||||
|
settings.ym,
|
||||||
|
settings.zm
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
|
||||||
|
|
||||||
|
const updateAngles = (name: string, angle: number) => {
|
||||||
|
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
|
||||||
|
Throttler.throttle(
|
||||||
|
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
|
||||||
|
100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createScene = async () => {
|
||||||
|
sceneManager
|
||||||
|
.addRenderer({ antialias: true, canvas, alpha: true })
|
||||||
|
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
|
||||||
|
.addOrbitControls(2, 20, orbit)
|
||||||
|
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
|
||||||
|
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
|
||||||
|
.addFogExp2(0xcccccc, 0.015)
|
||||||
|
.addModel($model as URDFRobot)
|
||||||
|
.addTransformControls(sceneManager.model)
|
||||||
|
.fillParent()
|
||||||
|
.addRenderCb(render)
|
||||||
|
.startRenderLoop()
|
||||||
|
|
||||||
|
if (ground) sceneManager.addGroundPlane()
|
||||||
|
|
||||||
|
const geometry = new SphereGeometry(0.1, 32, 16)
|
||||||
|
const material = new MeshBasicMaterial({ color: 0xffff00 })
|
||||||
|
target = new Mesh(geometry, material)
|
||||||
|
sceneManager.scene.add(target)
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
sceneManager.addDragControl(angles => {
|
||||||
|
Object.entries(angles).forEach(([name, angle]) => {
|
||||||
|
updateAngles(name, angle)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (defaultColor) setSceneBackground(settings['Background'] || defaultColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculate_kinematics = () => {
|
||||||
|
if (sceneManager.isDragging || !settings['Internal kinematic']) return
|
||||||
|
const position: body_state_t = {
|
||||||
|
omega: settings.omega,
|
||||||
|
phi: settings.phi,
|
||||||
|
psi: settings.psi,
|
||||||
|
xm: settings.xm,
|
||||||
|
ym: settings.ym,
|
||||||
|
zm: settings.zm,
|
||||||
|
feet: body_state.feet,
|
||||||
|
cumulative_x: body_state.cumulative_x,
|
||||||
|
cumulative_y: body_state.cumulative_y,
|
||||||
|
cumulative_z: body_state.cumulative_z,
|
||||||
|
cumulative_roll: body_state.cumulative_roll,
|
||||||
|
cumulative_pitch: body_state.cumulative_pitch,
|
||||||
|
cumulative_yaw: body_state.cumulative_yaw
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
|
||||||
|
modelTargetAngles = new_angles
|
||||||
|
}
|
||||||
|
|
||||||
|
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
|
||||||
|
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
|
||||||
|
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
|
||||||
|
|
||||||
|
const cumulativeYaw = body_state.cumulative_yaw
|
||||||
|
|
||||||
|
const cosYaw = Math.cos(cumulativeYaw)
|
||||||
|
const sinYaw = Math.sin(cumulativeYaw)
|
||||||
|
const rotatedXm = settings.xm * cosYaw - settings.zm * sinYaw
|
||||||
|
const rotatedZm = settings.xm * sinYaw + settings.zm * cosYaw
|
||||||
|
|
||||||
|
robot.position.x = smooth(robot.position.x, -rotatedZm - body_state.cumulative_z * 1.2, 0.1)
|
||||||
|
robot.position.z = smooth(robot.position.z, -rotatedXm - body_state.cumulative_x * 1.2, 0.1)
|
||||||
|
|
||||||
|
const pitch = degToRad(settings.psi - 90) + body_state.cumulative_pitch
|
||||||
|
const roll = degToRad(settings.omega) + body_state.cumulative_roll
|
||||||
|
|
||||||
|
robot.rotation.z = smooth(
|
||||||
|
robot.rotation.z,
|
||||||
|
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
|
||||||
|
0.1
|
||||||
|
)
|
||||||
|
robot.rotation.y = smooth(robot.rotation.y, roll, 0.1)
|
||||||
|
robot.rotation.x = smooth(robot.rotation.x, pitch, 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_camera = (robot: URDFRobot) => {
|
||||||
|
if (!settings['Fix camera on robot']) return
|
||||||
|
sceneManager.orbit.target = robot.position.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
const smooth = (start: number, end: number, amount: number) => {
|
||||||
|
return settings['Smooth motion'] ? lerp(start, end, amount) : end
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_gait = () => {
|
||||||
|
if (sceneManager.isDragging || !settings['Internal kinematic']) return
|
||||||
|
const controlData = get(outControllerData)
|
||||||
|
const data = {
|
||||||
|
lx: controlData[0],
|
||||||
|
ly: controlData[1],
|
||||||
|
rx: controlData[2],
|
||||||
|
ry: controlData[3],
|
||||||
|
h: controlData[4],
|
||||||
|
s: controlData[5],
|
||||||
|
s1: controlData[6]
|
||||||
|
}
|
||||||
|
body_state.ym = data.h
|
||||||
|
|
||||||
|
let planner = planners[get(mode)]
|
||||||
|
const delta = performance.now() - lastTick
|
||||||
|
lastTick = performance.now()
|
||||||
|
|
||||||
|
body_state = planner.step(body_state, data, delta)
|
||||||
|
|
||||||
|
settings.omega = body_state.omega
|
||||||
|
settings.phi = body_state.phi
|
||||||
|
settings.psi = body_state.psi
|
||||||
|
settings.xm = body_state.xm
|
||||||
|
settings.ym = body_state.ym
|
||||||
|
settings.zm = body_state.zm
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_robot_position = (robot: URDFRobot) => {
|
||||||
|
if (!settings['Robot transform controls']) return
|
||||||
|
settings.omega = radToDeg(robot.rotation.y)
|
||||||
|
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
|
||||||
|
settings.psi = radToDeg(robot.rotation.x) + 90
|
||||||
|
settings.xm = robot.position.z * 100
|
||||||
|
settings.zm = -robot.position.x * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTargetPosition = () => {
|
||||||
|
target.visible = settings['Target position']
|
||||||
|
target.position.x = smooth(target.position.x, target_position.x, 0.5)
|
||||||
|
target.position.z = smooth(target.position.z, target_position.z, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const robot = sceneManager.model
|
||||||
|
if (!robot) return
|
||||||
|
|
||||||
|
const toes = getToeWorldPositions(robot)
|
||||||
|
|
||||||
|
update_camera(robot)
|
||||||
|
update_gait()
|
||||||
|
calculate_kinematics()
|
||||||
|
update_robot_position(robot)
|
||||||
|
|
||||||
|
sceneManager.transformControl.showX = settings['Robot transform controls']
|
||||||
|
sceneManager.transformControl.showY = settings['Robot transform controls']
|
||||||
|
sceneManager.transformControl.showZ = settings['Robot transform controls']
|
||||||
|
|
||||||
|
for (let i = 0; i < $jointNames.length; i++) {
|
||||||
|
currentModelAngles[i] = smooth(
|
||||||
|
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
|
||||||
|
modelTargetAngles[i],
|
||||||
|
0.1
|
||||||
|
)
|
||||||
|
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
orient_robot(robot, toes)
|
||||||
|
updateTargetPosition()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onresize={sceneManager.fillParent} />
|
||||||
|
|
||||||
|
<canvas bind:this={canvas}></canvas>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export { default as Connection } from '~icons/mdi/connection'
|
||||||
|
export { default as Users } from '~icons/mdi/users'
|
||||||
|
export { default as Settings } from '~icons/mdi/settings'
|
||||||
|
export { default as MdiController } from '~icons/mdi/controller'
|
||||||
|
export { default as Devices } from '~icons/mdi/devices'
|
||||||
|
export { default as Camera } from '~icons/mdi/camera-outline'
|
||||||
|
export { default as Rotate3d } from '~icons/mdi/rotate-3d'
|
||||||
|
export { default as MotorOutline } from '~icons/mdi/motor-outline'
|
||||||
|
export { default as Health } from '~icons/mdi/stethoscope'
|
||||||
|
export { default as Folder } from '~icons/mdi/folder-outline'
|
||||||
|
export { default as Update } from '~icons/mdi/reload'
|
||||||
|
export { default as Router } from '~icons/mdi/router'
|
||||||
|
export { default as AP } from '~icons/mdi/access-point'
|
||||||
|
export { default as Remote } from '~icons/mdi/network'
|
||||||
|
export { default as Copyright } from '~icons/mdi/copyright'
|
||||||
|
export { default as NTP } from '~icons/mdi/clock-check'
|
||||||
|
export { default as Metrics } from '~icons/mdi/report-bar'
|
||||||
|
export { default as MdiEyeOutline } from '~icons/mdi/eye-outline'
|
||||||
|
export { default as MdiEyeOffOutline } from '~icons/mdi/eye-off-outline'
|
||||||
|
export { default as Github } from '~icons/mdi/github'
|
||||||
|
export { default as Avatar } from '~icons/mdi/user-circle'
|
||||||
|
export { default as Logout } from '~icons/mdi/logout'
|
||||||
|
export { default as Record } from '~icons/mdi/radio-button-unchecked'
|
||||||
|
export { default as MdiFullscreen } from '~icons/mdi/fullscreen'
|
||||||
|
export { default as MdiFullscreenExit } from '~icons/mdi/fullscreen-exit'
|
||||||
|
export { default as WiFi } from '~icons/tabler/wifi'
|
||||||
|
export { default as WiFi0 } from '~icons/tabler/wifi-0'
|
||||||
|
export { default as WiFi1 } from '~icons/tabler/wifi-1'
|
||||||
|
export { default as WiFi2 } from '~icons/tabler/wifi-2'
|
||||||
|
export { default as WifiOff } from '~icons/tabler/wifi-off'
|
||||||
|
export { default as MdiWeatherSunny } from '~icons/mdi/weather-sunny'
|
||||||
|
export { default as MdiMoonAndStars } from '~icons/mdi/moon-and-stars'
|
||||||
|
export { default as Hamburger } from '~icons/mdi/hamburger-menu'
|
||||||
|
|
||||||
|
export { default as FileIcon } from '~icons/mdi/file'
|
||||||
|
export { default as FolderIcon } from '~icons/mdi/folder-outline'
|
||||||
|
export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
|
||||||
|
export { default as TrashIcon } from '~icons/mdi/trash'
|
||||||
|
export { default as RotateCcw } from '~icons/mdi/rotate-left'
|
||||||
|
export { default as RotateCw } from '~icons/mdi/rotate-right'
|
||||||
|
|
||||||
|
export { default as Down } from '~icons/tabler/chevron-down'
|
||||||
|
export { default as Cancel } from '~icons/tabler/x'
|
||||||
|
export { default as Check } from '~icons/tabler/check'
|
||||||
|
export { default as Login } from '~icons/tabler/login'
|
||||||
|
export { default as Loader } from '~icons/tabler/loader-2'
|
||||||
|
export { default as error } from '~icons/tabler/circle-x'
|
||||||
|
export { default as success } from '~icons/tabler/circle-check'
|
||||||
|
export { default as warning } from '~icons/tabler/alert-triangle'
|
||||||
|
export { default as info } from '~icons/tabler/info-circle'
|
||||||
|
export { default as Power } from '~icons/tabler/power'
|
||||||
|
|
||||||
|
export { default as MAC } from '~icons/tabler/dna-2'
|
||||||
|
export { default as Home } from '~icons/tabler/home'
|
||||||
|
export { default as SSID } from '~icons/tabler/router'
|
||||||
|
export { default as DNS } from '~icons/mdi/dns'
|
||||||
|
export { default as Gateway } from '~icons/tabler/torii'
|
||||||
|
export { default as Subnet } from '~icons/tabler/grid-dots'
|
||||||
|
export { default as Channel } from '~icons/tabler/antenna'
|
||||||
|
export { default as Scan } from '~icons/tabler/radar-2'
|
||||||
|
export { default as Add } from '~icons/tabler/circle-plus'
|
||||||
|
export { default as Edit } from '~icons/mdi/edit'
|
||||||
|
export { default as EditOff } from '~icons/mdi/edit-off'
|
||||||
|
export { default as Delete } from '~icons/tabler/trash'
|
||||||
|
|
||||||
|
export { default as Network } from '~icons/tabler/router'
|
||||||
|
export { default as Reload } from '~icons/tabler/reload'
|
||||||
|
|
||||||
|
export { default as Firmware } from '~icons/tabler/refresh-alert'
|
||||||
|
export { default as CloudDown } from '~icons/tabler/cloud-download'
|
||||||
|
export { default as Server } from '~icons/tabler/server'
|
||||||
|
export { default as Clock } from '~icons/tabler/clock'
|
||||||
|
export { default as UTC } from '~icons/tabler/clock-pin'
|
||||||
|
export { default as Stopwatch } from '~icons/tabler/24-hours'
|
||||||
|
|
||||||
|
export { default as CPU } from '~icons/tabler/cpu'
|
||||||
|
export { default as CPP } from '~icons/tabler/binary'
|
||||||
|
export { default as Sleep } from '~icons/tabler/zzz'
|
||||||
|
export { default as FactoryReset } from '~icons/tabler/refresh-dot'
|
||||||
|
export { default as Speed } from '~icons/tabler/activity'
|
||||||
|
export { default as Flash } from '~icons/tabler/device-sd-card'
|
||||||
|
export { default as Pyramid } from '~icons/tabler/pyramid'
|
||||||
|
export { default as Sketch } from '~icons/tabler/chart-pie'
|
||||||
|
export { default as Heap } from '~icons/tabler/box-model'
|
||||||
|
export { default as Temperature } from '~icons/tabler/temperature'
|
||||||
|
export { default as SDK } from '~icons/tabler/sdk'
|
||||||
|
|
||||||
|
export { default as Prerelease } from '~icons/tabler/test-pipe'
|
||||||
|
export { default as Error } from '~icons/tabler/circle-x'
|
||||||
|
|
||||||
|
export { default as OTA } from '~icons/tabler/file-upload'
|
||||||
|
export { default as Warning } from '~icons/tabler/alert-triangle'
|
||||||
|
|
||||||
|
export { default as AddUser } from '~icons/tabler/user-plus'
|
||||||
|
export { default as Admin } from '~icons/tabler/key'
|
||||||
|
export { default as Save } from '~icons/tabler/device-floppy'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { MdiEyeOffOutline, MdiEyeOutline } from '../icons'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show?: boolean
|
||||||
|
value?: string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props()
|
||||||
|
|
||||||
|
let type = $derived(show ? 'text' : 'password')
|
||||||
|
|
||||||
|
const handleInput = (e: Event) => (value = (e.target as HTMLInputElement).value)
|
||||||
|
|
||||||
|
const togglePassword = () => (show = !show)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
|
<input {type} class="grow" {value} oninput={handleInput} {id} />
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div onclick={togglePassword} role="button" tabindex="0">
|
||||||
|
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
|
||||||
|
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
value?: number
|
||||||
|
oninput?: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
value = $bindable((max - min) / 2),
|
||||||
|
...rest
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
style="writing-mode: vertical-lr; direction: rtl"
|
||||||
|
class="cursor-pointer"
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
bind:value
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
background: oklch(var(--p) / 1);
|
||||||
|
border-radius: var(--rounded-box, 1rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as PasswordInput } from './InputPassword.svelte'
|
||||||
|
export { default as VerticalSlider } from './VerticalSlider.svelte'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
children?: import('svelte').Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="box-border overflow-hidden flex-1">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import WidgetContainer from './WidgetContainer.svelte'
|
||||||
|
import {
|
||||||
|
WidgetComponents,
|
||||||
|
type WidgetContainerConfig,
|
||||||
|
isWidgetConfig
|
||||||
|
} from '$lib/stores/application'
|
||||||
|
import Widget from './Widget.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
container: WidgetContainerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
let { container }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full h-full flex flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="flex w-full h-full"
|
||||||
|
class:flex-row={container.layout === 'column'}
|
||||||
|
class:flex-col={container.layout === 'row'}
|
||||||
|
class:flex-wrap={container.layout === 'wrap'}
|
||||||
|
>
|
||||||
|
{#each container.widgets as widget, index (widget.id + '-' + index)}
|
||||||
|
<Widget>
|
||||||
|
{#if isWidgetConfig(widget)}
|
||||||
|
{@const SvelteComponent = WidgetComponents[widget.component]}
|
||||||
|
<SvelteComponent {...widget.props} />
|
||||||
|
{:else if widget.widgets}
|
||||||
|
<WidgetContainer container={widget} />
|
||||||
|
{/if}
|
||||||
|
</Widget>
|
||||||
|
{#if index !== container.widgets.length - 1}
|
||||||
|
<div
|
||||||
|
class="divider bg-base-300 m-0"
|
||||||
|
class:divider-horizontal={container.layout === 'column'}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Github } from '../icons'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
github: { url: string; version: string; active?: boolean; href?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
let { github }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if github.active}
|
||||||
|
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
|
||||||
|
<Github class="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script>
|
||||||
|
import logo from '$lib/assets/logo512.png'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
|
|
||||||
|
/** @type {{appName: any}} */
|
||||||
|
let { appName } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={resolve('/')}
|
||||||
|
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<img src={logo} alt="Logo" class="h-12 w-12" />
|
||||||
|
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { base } from '$app/paths'
|
||||||
|
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||||
|
import GithubButton from '../menu/GithubButton.svelte'
|
||||||
|
import LogoButton from '../menu/LogoButton.svelte'
|
||||||
|
import MenuList from '../menu/MenuList.svelte'
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Settings,
|
||||||
|
MdiController,
|
||||||
|
Devices,
|
||||||
|
Camera,
|
||||||
|
Rotate3d,
|
||||||
|
MotorOutline,
|
||||||
|
Health,
|
||||||
|
Folder,
|
||||||
|
Update,
|
||||||
|
WiFi,
|
||||||
|
Router,
|
||||||
|
AP,
|
||||||
|
Copyright,
|
||||||
|
Metrics,
|
||||||
|
DNS
|
||||||
|
} from '$lib/components/icons'
|
||||||
|
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
|
||||||
|
|
||||||
|
const features = useFeatureFlags()
|
||||||
|
|
||||||
|
const appName = page.data.app_name
|
||||||
|
|
||||||
|
const copyright = page.data.copyright
|
||||||
|
|
||||||
|
const github = { href: 'https://github.com/' + page.data.github, active: true }
|
||||||
|
|
||||||
|
import type { ComponentType } from 'svelte'
|
||||||
|
|
||||||
|
type menuItem = {
|
||||||
|
title: string
|
||||||
|
icon: ComponentType
|
||||||
|
href?: string
|
||||||
|
feature: boolean
|
||||||
|
active?: boolean
|
||||||
|
submenu?: menuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function withBase(path: string) {
|
||||||
|
return `${base}${path.startsWith('/') ? path : '/' + path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let menuItems = $state<menuItem[]>([])
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
menuItems = [
|
||||||
|
{
|
||||||
|
title: 'Connection',
|
||||||
|
icon: WiFi,
|
||||||
|
href: withBase('/connection'),
|
||||||
|
feature: !PUBLIC_VITE_USE_HOST_NAME
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Controller',
|
||||||
|
icon: MdiController,
|
||||||
|
href: withBase('/controller'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Peripherals',
|
||||||
|
icon: Devices,
|
||||||
|
feature: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
title: 'I2C',
|
||||||
|
icon: Connection,
|
||||||
|
href: withBase('/peripherals/i2c'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Camera',
|
||||||
|
icon: Camera,
|
||||||
|
href: withBase('/peripherals/camera'),
|
||||||
|
feature: $features.camera
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Servo',
|
||||||
|
icon: MotorOutline,
|
||||||
|
href: withBase('/peripherals/servo'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMU',
|
||||||
|
icon: Rotate3d,
|
||||||
|
href: withBase('/peripherals/imu'),
|
||||||
|
feature: $features.imu || $features.mag || $features.bmp
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'WiFi',
|
||||||
|
icon: WiFi,
|
||||||
|
feature: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
title: 'WiFi Station',
|
||||||
|
icon: Router,
|
||||||
|
href: withBase('/wifi/sta'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Access Point',
|
||||||
|
icon: AP,
|
||||||
|
href: withBase('/wifi/ap'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'mDNS',
|
||||||
|
icon: DNS,
|
||||||
|
href: withBase('/wifi/mdns'),
|
||||||
|
feature: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System',
|
||||||
|
icon: Settings,
|
||||||
|
feature: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
title: 'System Status',
|
||||||
|
icon: Health,
|
||||||
|
href: withBase('/system/status'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'File System',
|
||||||
|
icon: Folder,
|
||||||
|
href: withBase('/system/filesystem'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System Metrics',
|
||||||
|
icon: Metrics,
|
||||||
|
href: withBase('/system/metrics'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Firmware Update',
|
||||||
|
icon: Update,
|
||||||
|
href: withBase('/system/update'),
|
||||||
|
feature:
|
||||||
|
$features.ota ||
|
||||||
|
$features.upload_firmware ||
|
||||||
|
$features.download_firmware
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as menuItem[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const { menuClicked } = $props()
|
||||||
|
|
||||||
|
function setActiveMenuItem(targetTitle: string) {
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
item.active = item.title === targetTitle
|
||||||
|
item.submenu?.forEach(subItem => {
|
||||||
|
subItem.active = subItem.title === targetTitle
|
||||||
|
})
|
||||||
|
})
|
||||||
|
menuItems = menuItems
|
||||||
|
menuClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setActiveMenuItem(page.data.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMenu = (event: CustomEvent) => {
|
||||||
|
setActiveMenuItem(event.details)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
|
||||||
|
<LogoButton {appName} />
|
||||||
|
|
||||||
|
<MenuList
|
||||||
|
{menuItems}
|
||||||
|
select={updateMenu}
|
||||||
|
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
|
||||||
|
level="0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<GithubButton {github} />
|
||||||
|
<div class="flex items-center justify-end text-sm gap-2">
|
||||||
|
<Copyright class="h-4 w-4" />{copyright}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MenuList from './MenuList.svelte'
|
||||||
|
import type { ComponentType } from 'svelte'
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
title: string
|
||||||
|
icon: ComponentType
|
||||||
|
href?: string
|
||||||
|
feature: boolean
|
||||||
|
active?: boolean
|
||||||
|
submenu?: MenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let { level, menuItems, select, class: klass } = $props()
|
||||||
|
|
||||||
|
const selectMenuItem = (title: string) => {
|
||||||
|
select(title)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class={klass + ' menu w-full'}>
|
||||||
|
{#each menuItems as MenuItem[] as menuItem (menuItem.title)}
|
||||||
|
{#if menuItem.feature}
|
||||||
|
<li>
|
||||||
|
{#if menuItem.submenu}
|
||||||
|
<details open={menuItem.submenu.some(subItem => subItem.active)}>
|
||||||
|
<summary class="font-bold">
|
||||||
|
<menuItem.icon class="h-6 w-6" />
|
||||||
|
{menuItem.title}
|
||||||
|
</summary>
|
||||||
|
<div class="pl-4">
|
||||||
|
<MenuList
|
||||||
|
menuItems={menuItem.submenu}
|
||||||
|
level={level + 1}
|
||||||
|
{select}
|
||||||
|
class={klass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={menuItem.href}
|
||||||
|
class="font-bold"
|
||||||
|
class:bg-base-100={menuItem.active}
|
||||||
|
class:text-lg={level === 0}
|
||||||
|
class:text-md={level === 1}
|
||||||
|
onclick={() => selectMenuItem(menuItem.title)}
|
||||||
|
>
|
||||||
|
<menuItem.icon class="h-6 w-6" />
|
||||||
|
{menuItem.title}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { isFullscreen, toggleFullscreen } from '$lib/stores'
|
||||||
|
import { MdiFullscreenExit, MdiFullscreen } from '../icons'
|
||||||
|
|
||||||
|
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={toggleFullscreen}>
|
||||||
|
<SvelteComponent class="h-7 w-7" />
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showDBm?: boolean
|
||||||
|
rssi?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let { showDBm = false, rssi = 0 }: Props = $props()
|
||||||
|
|
||||||
|
const getWiFiIcon = () => {
|
||||||
|
if (rssi === 0) return WifiOff
|
||||||
|
if (rssi >= -55) return WiFi
|
||||||
|
if (rssi >= -75) return WiFi2
|
||||||
|
if (rssi >= -85) return WiFi1
|
||||||
|
return WiFi0
|
||||||
|
}
|
||||||
|
|
||||||
|
const SvelteComponent = $derived(getWiFiIcon())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="indicator">
|
||||||
|
<div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}>
|
||||||
|
{#if showDBm}
|
||||||
|
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
|
||||||
|
{rssi} dBm
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="h-7 w-7">
|
||||||
|
<SvelteComponent class="absolute inset-0 h-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useFeatureFlags } from '$lib/stores'
|
||||||
|
import { modals } from 'svelte-modals'
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||||
|
import { api } from '$lib/api'
|
||||||
|
import { Cancel, Power } from '../icons'
|
||||||
|
|
||||||
|
const features = useFeatureFlags()
|
||||||
|
|
||||||
|
const postSleep = async () => await api.post('/api/system/sleep')
|
||||||
|
|
||||||
|
const confirmSleep = () => {
|
||||||
|
modals.open(ConfirmDialog, {
|
||||||
|
title: 'Confirm Power Down',
|
||||||
|
message: 'Are you sure you want to switch off the device?',
|
||||||
|
labels: {
|
||||||
|
cancel: { label: 'Abort', icon: Cancel },
|
||||||
|
confirm: { label: 'Switch Off', icon: Power }
|
||||||
|
},
|
||||||
|
onConfirm: () => {
|
||||||
|
modals.close()
|
||||||
|
postSleep()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $features.sleep}
|
||||||
|
<div class="flex-none">
|
||||||
|
<button class="btn btn-square btn-ghost h-9 w-10" onclick={confirmSleep}>
|
||||||
|
<Power class="text-error h-9 w-9" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mode, modes } from '$lib/stores'
|
||||||
|
|
||||||
|
const deactivate = async () => {
|
||||||
|
mode.set(modes.indexOf('deactivated'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { MdiWeatherSunny, MdiMoonAndStars } from '../icons'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="swap swap-rotate">
|
||||||
|
<input type="checkbox" value="light" class="theme-controller" />
|
||||||
|
<MdiWeatherSunny class="swap-off h-7 w-7" />
|
||||||
|
<MdiMoonAndStars class="swap-on h-7 w-7" />
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Hamburger } from '../icons'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800">
|
||||||
|
<div class="flex gap-2 p-2">
|
||||||
|
<a href={resolve('/')}>
|
||||||
|
<Hamburger class="h-8 w-8" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.topbar {
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { modals } from 'svelte-modals'
|
||||||
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||||
|
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
|
||||||
|
import { compareVersions } from 'compare-versions'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api } from '$lib/api'
|
||||||
|
import type { GithubRelease } from '$lib/types/models'
|
||||||
|
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||||
|
import { Cancel, CloudDown, Firmware } from '../icons'
|
||||||
|
|
||||||
|
const features = useFeatureFlags()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
update?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let { update = $bindable(false) }: Props = $props()
|
||||||
|
|
||||||
|
let firmwareVersion: string = $state('')
|
||||||
|
let firmwareDownloadLink: string = $state('')
|
||||||
|
|
||||||
|
async function getGithubAPI() {
|
||||||
|
const headers = {
|
||||||
|
accept: 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28'
|
||||||
|
}
|
||||||
|
const result = await api.get<GithubRelease>(
|
||||||
|
`https://api.github.com/repos/${page.data.github}/releases/latest`,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
|
||||||
|
console.warn('Error: Could not find releases in the repository')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.isErr()) {
|
||||||
|
console.error('Error:', result.inner)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = result.inner
|
||||||
|
update = false
|
||||||
|
firmwareVersion = ''
|
||||||
|
|
||||||
|
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
|
||||||
|
// iterate over assets and find the correct one
|
||||||
|
for (let i = 0; i < results.assets.length; i++) {
|
||||||
|
// check if the asset is of type *.bin
|
||||||
|
if (
|
||||||
|
results.assets[i].name.includes('.bin') &&
|
||||||
|
results.assets[i].name.includes($features.firmware_built_target as string)
|
||||||
|
) {
|
||||||
|
update = true
|
||||||
|
firmwareVersion = results.tag_name
|
||||||
|
firmwareDownloadLink = results.assets[i].browser_download_url
|
||||||
|
notifications.info('Firmware update available.', 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postGithubDownload(url: string) {
|
||||||
|
const result = await api.post('/api/downloadUpdate', { download_url: url })
|
||||||
|
if (result.isErr()) {
|
||||||
|
console.error('Error:', result.inner)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if ($features.download_firmware) {
|
||||||
|
await getGithubAPI()
|
||||||
|
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function confirmGithubUpdate(url: string) {
|
||||||
|
modals.open(ConfirmDialog, {
|
||||||
|
title: 'Confirm flashing new firmware to the device',
|
||||||
|
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
||||||
|
labels: {
|
||||||
|
cancel: { label: 'Abort', icon: Cancel },
|
||||||
|
confirm: { label: 'Update', icon: CloudDown }
|
||||||
|
},
|
||||||
|
onConfirm: () => {
|
||||||
|
postGithubDownload(url)
|
||||||
|
modals.open(GithubUpdateDialog, {
|
||||||
|
onConfirm: () => modals.closeAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if update}
|
||||||
|
<div class="indicator flex-none">
|
||||||
|
<button
|
||||||
|
class="btn btn-square btn-ghost h-9 w-9"
|
||||||
|
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
|
||||||
|
>
|
||||||
|
{firmwareVersion}
|
||||||
|
</span>
|
||||||
|
<Firmware class="h-7 w-7" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { selectedView, views } from '$lib/stores/application'
|
||||||
|
import Selector from '../widget/Selector.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Selector bind:selectedOption={$selectedView} options={$views.map(v => v.name)} />
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { telemetry } from '$lib/stores/telemetry'
|
||||||
|
|
||||||
|
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
|
||||||
|
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
|
||||||
|
import SleepButton from './SleepButton.svelte'
|
||||||
|
import ThemeButton from './ThemeButton.svelte'
|
||||||
|
import FullscreenButton from './FullscreenButton.svelte'
|
||||||
|
import StopButton from './StopButton.svelte'
|
||||||
|
import ViewSelector from './ViewSelector.svelte'
|
||||||
|
import { Hamburger } from '../icons'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
|
||||||
|
<div class="flex flex-1 gap-2">
|
||||||
|
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
|
||||||
|
<Hamburger class="h-6 w-auto" />
|
||||||
|
</label>
|
||||||
|
{#if page.data.title === 'Controller'}
|
||||||
|
<ViewSelector />
|
||||||
|
{:else}
|
||||||
|
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UpdateIndicator />
|
||||||
|
|
||||||
|
<FullscreenButton />
|
||||||
|
|
||||||
|
<ThemeButton />
|
||||||
|
|
||||||
|
<RssiIndicator rssi={$telemetry.rssi.rssi} />
|
||||||
|
|
||||||
|
<SleepButton />
|
||||||
|
|
||||||
|
<StopButton />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script>
|
||||||
|
import { flip } from 'svelte/animate'
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
|
import { error, info, success, warning } from '../icons'
|
||||||
|
|
||||||
|
/** @type {{theme?: any, icon?: any}} */
|
||||||
|
let {
|
||||||
|
theme = {
|
||||||
|
error: 'alert-error',
|
||||||
|
success: 'alert-success',
|
||||||
|
warning: 'alert-warning',
|
||||||
|
info: 'alert-info'
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
error: error,
|
||||||
|
success: success,
|
||||||
|
warning: warning,
|
||||||
|
info: info
|
||||||
|
}
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toast toast-end mr-4 z-20">
|
||||||
|
{#each $notifications as notification (notification.id)}
|
||||||
|
{@const SvelteComponent = icon[notification.type]}
|
||||||
|
<div
|
||||||
|
animate:flip={{ duration: 400 }}
|
||||||
|
class="alert animate-none {theme[notification.type]}"
|
||||||
|
in:fly={{ y: 100, duration: 400 }}
|
||||||
|
out:fly={{ x: 100, duration: 400 }}
|
||||||
|
>
|
||||||
|
<SvelteComponent class="h-6 w-6 shrink-0" />
|
||||||
|
<span>{notification.message}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
|
type StateType = 'info' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
id: string
|
||||||
|
type: StateType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNotificationStore() {
|
||||||
|
const state: State[] = []
|
||||||
|
const notifications = writable(state)
|
||||||
|
const { subscribe } = notifications
|
||||||
|
|
||||||
|
function send(message: string, type: StateType = 'info', timeout: number) {
|
||||||
|
const id = generateId()
|
||||||
|
setTimeout(() => {
|
||||||
|
notifications.update(state => {
|
||||||
|
return state.filter(n => n.id !== id)
|
||||||
|
})
|
||||||
|
}, timeout)
|
||||||
|
notifications.update(state => {
|
||||||
|
return [...state, { id, type, message }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
send,
|
||||||
|
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
|
||||||
|
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
|
||||||
|
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
|
||||||
|
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return '_' + Math.random().toString(36).substr(2, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notifications = createNotificationStore()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { daisyColor } from '$lib/utilities'
|
||||||
|
import { Chart, registerables } from 'chart.js'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { cubicOut } from 'svelte/easing'
|
||||||
|
import { slide } from 'svelte/transition'
|
||||||
|
|
||||||
|
let chartElement: HTMLCanvasElement
|
||||||
|
let chart: Chart<'line', number[], number>
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
data: number[]
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, data, title }: Props = $props()
|
||||||
|
|
||||||
|
Chart.register(...registerables)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
chart = new Chart(chartElement, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
borderColor: daisyColor('--p'),
|
||||||
|
backgroundColor: daisyColor('--p', 50),
|
||||||
|
borderWidth: 2,
|
||||||
|
data,
|
||||||
|
yAxisID: 'y'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
point: {
|
||||||
|
radius: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: daisyColor('--bc', 10)
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: daisyColor('--bc')
|
||||||
|
},
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: title,
|
||||||
|
color: daisyColor('--bc'),
|
||||||
|
font: {
|
||||||
|
size: 16,
|
||||||
|
weight: 'bold'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
position: 'left',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
grid: { color: daisyColor('--bc', 10) },
|
||||||
|
ticks: {
|
||||||
|
color: daisyColor('--bc')
|
||||||
|
},
|
||||||
|
border: { color: daisyColor('--bc', 10) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
chart.data.labels = data
|
||||||
|
chart.data.datasets[0].data = data
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full h-full overflow-x-auto">
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col space-y-1 h-60"
|
||||||
|
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
<canvas bind:this={chartElement}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
options?: string[]
|
||||||
|
selectedOption?: string
|
||||||
|
change?: () => void
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<select
|
||||||
|
bind:value={selectedOption}
|
||||||
|
{...rest}
|
||||||
|
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
|
||||||
|
>
|
||||||
|
{#each options as option}
|
||||||
|
<option value={option}>{option}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import { get } from 'svelte/store'
|
||||||
|
import type { body_state_t } from './kinematic'
|
||||||
|
import { currentKinematic } from './stores/featureFlags'
|
||||||
|
|
||||||
|
export interface gait_state_t {
|
||||||
|
step_height: number
|
||||||
|
step_x: number
|
||||||
|
step_z: number
|
||||||
|
step_angle: number
|
||||||
|
step_velocity: number
|
||||||
|
step_depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerCommand {
|
||||||
|
lx: number
|
||||||
|
ly: number
|
||||||
|
rx: number
|
||||||
|
ry: number
|
||||||
|
h: number
|
||||||
|
s: number
|
||||||
|
s1: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class GaitState {
|
||||||
|
protected abstract name: string
|
||||||
|
|
||||||
|
protected dt = 0.02
|
||||||
|
protected body_state!: body_state_t
|
||||||
|
protected gait_state: gait_state_t = {
|
||||||
|
step_height: 0.4,
|
||||||
|
step_x: 0,
|
||||||
|
step_z: 0,
|
||||||
|
step_angle: 0,
|
||||||
|
step_velocity: 1,
|
||||||
|
step_depth: 0.002
|
||||||
|
}
|
||||||
|
|
||||||
|
public get default_feet_pos() {
|
||||||
|
return get(currentKinematic).getDefaultFeetPos()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get default_height() {
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
begin() {
|
||||||
|
console.log('Starting', this.name)
|
||||||
|
}
|
||||||
|
end() {
|
||||||
|
console.log('Ending', this.name)
|
||||||
|
}
|
||||||
|
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
|
||||||
|
this.map_command(command)
|
||||||
|
this.body_state = body_state
|
||||||
|
this.dt = dt / 1000
|
||||||
|
|
||||||
|
if (body_state.cumulative_x === undefined) {
|
||||||
|
body_state.cumulative_x = 0
|
||||||
|
body_state.cumulative_y = 0
|
||||||
|
body_state.cumulative_z = 0
|
||||||
|
body_state.cumulative_roll = 0
|
||||||
|
body_state.cumulative_pitch = 0
|
||||||
|
body_state.cumulative_yaw = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
|
|
||||||
|
map_command(command: ControllerCommand) {
|
||||||
|
const newCommand = {
|
||||||
|
step_height: 0.4 + (command.s1 + 1) / 2,
|
||||||
|
step_x: command.ly,
|
||||||
|
step_z: -command.lx,
|
||||||
|
step_velocity: command.s,
|
||||||
|
step_angle: command.rx,
|
||||||
|
step_depth: 0.002
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gait_state = newCommand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdleState extends GaitState {
|
||||||
|
protected name = 'Idle'
|
||||||
|
|
||||||
|
step(body_state: body_state_t, command: ControllerCommand) {
|
||||||
|
super.step(body_state, command)
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalibrationState extends GaitState {
|
||||||
|
protected name = 'Calibration'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
step(body_state: body_state_t, _command: ControllerCommand) {
|
||||||
|
super.step(body_state, _command)
|
||||||
|
body_state.omega = 0
|
||||||
|
body_state.phi = 0
|
||||||
|
body_state.psi = 0
|
||||||
|
body_state.xm = 0
|
||||||
|
body_state.ym = this.default_height * 10
|
||||||
|
body_state.zm = 0
|
||||||
|
body_state.feet = this.default_feet_pos
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RestState extends GaitState {
|
||||||
|
protected name = 'Rest'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
step(body_state: body_state_t, _command: ControllerCommand) {
|
||||||
|
super.step(body_state, _command)
|
||||||
|
body_state.omega = 0
|
||||||
|
body_state.phi = 0
|
||||||
|
body_state.psi = 0
|
||||||
|
body_state.xm = 0
|
||||||
|
body_state.ym = this.default_height / 2
|
||||||
|
body_state.zm = 0
|
||||||
|
body_state.feet = this.default_feet_pos
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StandState extends GaitState {
|
||||||
|
protected name = 'Stand'
|
||||||
|
|
||||||
|
step(body_state: body_state_t, command: ControllerCommand) {
|
||||||
|
super.step(body_state, command)
|
||||||
|
body_state.omega = 0
|
||||||
|
body_state.phi = command.rx * 10 * (Math.PI / 2)
|
||||||
|
body_state.psi = command.ry * 10 * (Math.PI / 2)
|
||||||
|
body_state.xm = command.ly / 4
|
||||||
|
body_state.zm = command.lx / 4
|
||||||
|
body_state.feet = this.default_feet_pos
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BezierState extends GaitState {
|
||||||
|
protected name = 'Bezier'
|
||||||
|
protected phase = 0
|
||||||
|
protected phase_num = 0
|
||||||
|
protected step_length = 0
|
||||||
|
protected stand_offset = 0.75
|
||||||
|
protected mode: 'crawl' | 'trot' = 'trot'
|
||||||
|
protected speed_factor = 1
|
||||||
|
offset = [0, 0.5, 0.75, 0.25]
|
||||||
|
|
||||||
|
protected shift_start_pos = { x: 0, z: 0 }
|
||||||
|
protected shift_target_pos = { x: 0, z: 0 }
|
||||||
|
protected shift_start_time = 0
|
||||||
|
protected current_shift_leg = -1
|
||||||
|
|
||||||
|
protected last_body_state: body_state_t | null = null
|
||||||
|
protected cumulative_position = { x: 0, y: 0, z: 0 }
|
||||||
|
protected cumulative_orientation = { roll: 0, pitch: 0, yaw: 0 }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.set_mode(this.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
begin() {
|
||||||
|
super.begin()
|
||||||
|
}
|
||||||
|
|
||||||
|
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) {
|
||||||
|
console.log('BezierState set_mode', mode)
|
||||||
|
|
||||||
|
this.mode = mode
|
||||||
|
if (mode === 'crawl') {
|
||||||
|
this.speed_factor = 0.5
|
||||||
|
this.stand_offset = duty ?? 0.85
|
||||||
|
const o = order ?? [3, 0, 2, 1]
|
||||||
|
const base = [0, 0.25, 0.5, 0.75]
|
||||||
|
const offsets = new Array(4).fill(0)
|
||||||
|
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
|
||||||
|
this.offset = offsets
|
||||||
|
} else {
|
||||||
|
this.speed_factor = 2
|
||||||
|
this.stand_offset = duty ?? 0.6
|
||||||
|
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
super.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
|
||||||
|
super.step(body_state, command, dt)
|
||||||
|
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
|
||||||
|
if (this.gait_state.step_x < 0) this.step_length = -this.step_length
|
||||||
|
this.update_phase()
|
||||||
|
this.update_body_position()
|
||||||
|
this.update_feet_positions()
|
||||||
|
this.update_cumulative_position()
|
||||||
|
return this.body_state
|
||||||
|
}
|
||||||
|
|
||||||
|
update_phase() {
|
||||||
|
const m = this.gait_state
|
||||||
|
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
|
||||||
|
this.phase = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.phase += this.dt * m.step_velocity * this.speed_factor
|
||||||
|
if (this.phase >= 1) {
|
||||||
|
this.phase_num = (this.phase_num + 1) % 2
|
||||||
|
this.phase = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_body_position() {
|
||||||
|
const m = this.gait_state
|
||||||
|
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
|
||||||
|
if (!moving) return
|
||||||
|
|
||||||
|
if (this.mode !== 'crawl') return
|
||||||
|
|
||||||
|
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
|
||||||
|
|
||||||
|
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
|
||||||
|
if (this.current_shift_leg !== next_swing) {
|
||||||
|
this.current_shift_leg = next_swing
|
||||||
|
this.shift_start_pos.x = this.body_state.xm
|
||||||
|
this.shift_start_pos.z = this.body_state.zm
|
||||||
|
|
||||||
|
const remaining_legs = stance.filter(leg => leg !== next_swing)
|
||||||
|
const target = this.stance_centroid(remaining_legs)
|
||||||
|
this.shift_target_pos.x = target[0]
|
||||||
|
this.shift_target_pos.z = target[2]
|
||||||
|
|
||||||
|
this.shift_start_time = time_to_lift
|
||||||
|
}
|
||||||
|
|
||||||
|
const total_time = this.shift_start_time
|
||||||
|
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
|
||||||
|
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
|
||||||
|
|
||||||
|
this.body_state.xm = this.lerp(
|
||||||
|
this.shift_start_pos.x,
|
||||||
|
this.shift_target_pos.x,
|
||||||
|
smooth_progress
|
||||||
|
)
|
||||||
|
this.body_state.zm = this.lerp(
|
||||||
|
this.shift_start_pos.z,
|
||||||
|
this.shift_target_pos.z,
|
||||||
|
smooth_progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
protected stance_centroid(legs: number[]): number[] {
|
||||||
|
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
|
||||||
|
|
||||||
|
let sx = 0,
|
||||||
|
sz = 0
|
||||||
|
for (const i of legs) {
|
||||||
|
sx += this.body_state.feet[i][0]
|
||||||
|
sz += this.body_state.feet[i][2]
|
||||||
|
}
|
||||||
|
return [sx / legs.length, 0, sz / legs.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get_leg_states(): {
|
||||||
|
stance: number[]
|
||||||
|
swing: number[]
|
||||||
|
next_swing: number
|
||||||
|
time_to_lift: number
|
||||||
|
} {
|
||||||
|
const stance: number[] = []
|
||||||
|
const swing: number[] = []
|
||||||
|
let next_swing = -1
|
||||||
|
let min_time_to_swing = Infinity
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let phase = this.phase + this.offset[i]
|
||||||
|
if (phase >= 1) phase -= 1
|
||||||
|
|
||||||
|
if (phase <= this.stand_offset) {
|
||||||
|
stance.push(i)
|
||||||
|
const time_to_swing = this.stand_offset - phase
|
||||||
|
if (time_to_swing < min_time_to_swing) {
|
||||||
|
min_time_to_swing = time_to_swing
|
||||||
|
next_swing = i
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
swing.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected smoothstep01(t: number): number {
|
||||||
|
const x = Math.max(0, Math.min(1, t))
|
||||||
|
return x * x * (3 - 2 * x)
|
||||||
|
}
|
||||||
|
|
||||||
|
update_feet_positions() {
|
||||||
|
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
update_foot_position(index: number): number[] {
|
||||||
|
let phase = this.phase + this.offset[index]
|
||||||
|
if (phase >= 1) phase -= 1
|
||||||
|
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
|
||||||
|
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
|
||||||
|
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
|
||||||
|
return phase <= this.stand_offset ?
|
||||||
|
this.stand_controller(index, phase / this.stand_offset)
|
||||||
|
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
stand_controller(index: number, phase: number) {
|
||||||
|
const depth = this.gait_state.step_depth
|
||||||
|
return this.controller(index, phase, stance_curve, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
swing_controller(index: number, phase: number) {
|
||||||
|
const height = this.gait_state.step_height
|
||||||
|
return this.controller(index, phase, bezier_curve, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
controller(
|
||||||
|
index: number,
|
||||||
|
phase: number,
|
||||||
|
controller: (length: number, angle: number, ...args: number[]) => number[],
|
||||||
|
...args: number[]
|
||||||
|
) {
|
||||||
|
let length = this.step_length / 2
|
||||||
|
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
|
||||||
|
const delta_pos = controller(length, angle, ...args, phase)
|
||||||
|
|
||||||
|
length = this.gait_state.step_angle * 2
|
||||||
|
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
|
||||||
|
|
||||||
|
const delta_rot = controller(length, angle, ...args, phase)
|
||||||
|
|
||||||
|
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
|
||||||
|
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
|
||||||
|
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
|
||||||
|
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
|
||||||
|
|
||||||
|
return this.body_state.feet[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
update_cumulative_position() {
|
||||||
|
if (this.last_body_state === null) {
|
||||||
|
this.last_body_state = { ...this.body_state }
|
||||||
|
this.body_state.cumulative_x = 0
|
||||||
|
this.body_state.cumulative_y = 0
|
||||||
|
this.body_state.cumulative_z = 0
|
||||||
|
this.body_state.cumulative_roll = 0
|
||||||
|
this.body_state.cumulative_pitch = 0
|
||||||
|
this.body_state.cumulative_yaw = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = this.gait_state
|
||||||
|
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
|
||||||
|
|
||||||
|
if (moving) {
|
||||||
|
const step_displacement_x_local =
|
||||||
|
m.step_x * m.step_velocity * this.dt * this.speed_factor
|
||||||
|
const step_displacement_z_local =
|
||||||
|
m.step_z * m.step_velocity * this.dt * this.speed_factor
|
||||||
|
const step_displacement_yaw =
|
||||||
|
m.step_angle * m.step_velocity * this.dt * this.speed_factor
|
||||||
|
|
||||||
|
const cos_yaw = Math.cos(this.cumulative_orientation.yaw)
|
||||||
|
const sin_yaw = Math.sin(this.cumulative_orientation.yaw)
|
||||||
|
const step_displacement_x =
|
||||||
|
step_displacement_x_local * cos_yaw - step_displacement_z_local * sin_yaw
|
||||||
|
const step_displacement_z =
|
||||||
|
step_displacement_x_local * sin_yaw + step_displacement_z_local * cos_yaw
|
||||||
|
|
||||||
|
this.cumulative_position.x += step_displacement_x
|
||||||
|
this.cumulative_position.z += step_displacement_z
|
||||||
|
this.cumulative_orientation.yaw += step_displacement_yaw
|
||||||
|
}
|
||||||
|
|
||||||
|
this.body_state.cumulative_x = this.cumulative_position.x
|
||||||
|
this.body_state.cumulative_y = this.cumulative_position.y
|
||||||
|
this.body_state.cumulative_z = this.cumulative_position.z
|
||||||
|
this.body_state.cumulative_roll = this.cumulative_orientation.roll
|
||||||
|
this.body_state.cumulative_pitch = this.cumulative_orientation.pitch
|
||||||
|
this.body_state.cumulative_yaw = this.cumulative_orientation.yaw
|
||||||
|
|
||||||
|
this.last_body_state = { ...this.body_state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
|
||||||
|
const X_POLAR = Math.cos(angle)
|
||||||
|
const Y_POLAR = Math.sin(angle)
|
||||||
|
|
||||||
|
const step = length * (1 - 2 * phase)
|
||||||
|
const X = step * X_POLAR
|
||||||
|
const Z = step * Y_POLAR
|
||||||
|
let Y = 0
|
||||||
|
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
|
||||||
|
return [X, Y, Z]
|
||||||
|
}
|
||||||
|
|
||||||
|
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
|
||||||
|
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
|
||||||
|
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
|
||||||
|
const offsets = [
|
||||||
|
current_foot_pos[0] - default_foot_pos[0],
|
||||||
|
current_foot_pos[2] - default_foot_pos[2],
|
||||||
|
current_foot_pos[1] - default_foot_pos[1]
|
||||||
|
]
|
||||||
|
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
|
||||||
|
const offset_mod = Math.atan2(offset_mag, foot_mag)
|
||||||
|
|
||||||
|
return Math.PI / 2.0 + foot_dir + offset_mod
|
||||||
|
}
|
||||||
|
|
||||||
|
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
|
||||||
|
const control_points = get_control_points(length, angle, height)
|
||||||
|
const n = control_points.length - 1
|
||||||
|
|
||||||
|
const point = [0, 0, 0]
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i)
|
||||||
|
point[0] += bernstein_poly * control_points[i][0]
|
||||||
|
point[1] += bernstein_poly * control_points[i][1]
|
||||||
|
point[2] += bernstein_poly * control_points[i][2]
|
||||||
|
}
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
|
||||||
|
const get_control_points = (length: number, angle: number, height: number): number[][] => {
|
||||||
|
const X_POLAR = Math.cos(angle)
|
||||||
|
const Z_POLAR = Math.sin(angle)
|
||||||
|
|
||||||
|
const STEP = [
|
||||||
|
-length,
|
||||||
|
-length * 1.4,
|
||||||
|
-length * 1.5,
|
||||||
|
-length * 1.5,
|
||||||
|
-length * 1.5,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
length * 1.5,
|
||||||
|
length * 1.5,
|
||||||
|
length * 1.4,
|
||||||
|
length
|
||||||
|
]
|
||||||
|
|
||||||
|
const Y = [
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
height * 0.9,
|
||||||
|
height * 0.9,
|
||||||
|
height * 0.9,
|
||||||
|
height * 0.9,
|
||||||
|
height * 0.9,
|
||||||
|
height * 1.1,
|
||||||
|
height * 1.1,
|
||||||
|
height * 1.1,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
|
||||||
|
const control_points: number[][] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < STEP.length; i++) {
|
||||||
|
const X = STEP[i] * X_POLAR
|
||||||
|
const Z = STEP[i] * Z_POLAR
|
||||||
|
control_points.push([X, Y[i], Z])
|
||||||
|
}
|
||||||
|
|
||||||
|
return control_points
|
||||||
|
}
|
||||||
|
|
||||||
|
const comb = (n: number, k: number): number => {
|
||||||
|
if (k < 0 || k > n) return 0
|
||||||
|
if (k === 0 || k === n) return 1
|
||||||
|
k = Math.min(k, n - k)
|
||||||
|
let c = 1
|
||||||
|
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
|
||||||
|
return c
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
+151
-391
@@ -1,393 +1,153 @@
|
|||||||
|
export interface body_state_t {
|
||||||
|
omega: number
|
||||||
|
phi: number
|
||||||
|
psi: number
|
||||||
|
xm: number
|
||||||
|
ym: number
|
||||||
|
zm: number
|
||||||
|
feet: number[][]
|
||||||
|
cumulative_x: number
|
||||||
|
cumulative_y: number
|
||||||
|
cumulative_z: number
|
||||||
|
cumulative_roll: number
|
||||||
|
cumulative_pitch: number
|
||||||
|
cumulative_yaw: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface position {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface target_position {
|
||||||
|
x: number
|
||||||
|
z: number
|
||||||
|
yaw: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KinematicParams {
|
||||||
|
coxa: number
|
||||||
|
coxa_offset: number
|
||||||
|
femur: number
|
||||||
|
tibia: number
|
||||||
|
L: number
|
||||||
|
W: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cos, sin, atan2, acos, sqrt, max, min } = Math
|
||||||
|
|
||||||
|
const DEG2RAD = 0.017453292519943
|
||||||
|
|
||||||
export default class Kinematic {
|
export default class Kinematic {
|
||||||
private l1: number;
|
coxa: number
|
||||||
private l2: number;
|
coxa_offset: number
|
||||||
private l3: number;
|
femur: number
|
||||||
private l4: number;
|
tibia: number
|
||||||
|
|
||||||
private L: number;
|
L: number
|
||||||
private W: number;
|
W: number
|
||||||
|
|
||||||
constructor() {
|
DEG2RAD = DEG2RAD
|
||||||
this.l1 = 50;
|
|
||||||
this.l2 = 20;
|
mountOffsets: number[][]
|
||||||
this.l3 = 120;
|
|
||||||
this.l4 = 155;
|
invMountRot = [
|
||||||
|
[0, 0, -1],
|
||||||
this.L = 140;
|
[0, 1, 0],
|
||||||
this.W = 75;
|
[1, 0, 0]
|
||||||
}
|
]
|
||||||
|
|
||||||
bodyIK(
|
constructor(params: KinematicParams) {
|
||||||
omega: number,
|
this.coxa = params.coxa
|
||||||
phi: number,
|
this.coxa_offset = params.coxa_offset
|
||||||
psi: number,
|
this.femur = params.femur
|
||||||
xm: number,
|
this.tibia = params.tibia
|
||||||
ym: number,
|
this.L = params.L
|
||||||
zm: number
|
this.W = params.W
|
||||||
): number[][][] {
|
|
||||||
const { cos, sin } = Math;
|
this.mountOffsets = [
|
||||||
|
[this.L / 2, 0, this.W / 2],
|
||||||
const Rx: number[][] = [
|
[this.L / 2, 0, -this.W / 2],
|
||||||
[1, 0, 0, 0],
|
[-this.L / 2, 0, this.W / 2],
|
||||||
[0, cos(omega), -sin(omega), 0],
|
[-this.L / 2, 0, -this.W / 2]
|
||||||
[0, sin(omega), cos(omega), 0],
|
]
|
||||||
[0, 0, 0, 1]
|
}
|
||||||
];
|
|
||||||
const Ry: number[][] = [
|
getDefaultFeetPos(): number[][] {
|
||||||
[cos(phi), 0, sin(phi), 0],
|
return this.mountOffsets.map((offset, i) => {
|
||||||
[0, 1, 0, 0],
|
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
|
||||||
[-sin(phi), 0, cos(phi), 0],
|
})
|
||||||
[0, 0, 0, 1]
|
}
|
||||||
];
|
|
||||||
const Rz: number[][] = [
|
calcIK(p: body_state_t): number[] {
|
||||||
[cos(psi), -sin(psi), 0, 0],
|
const roll = p.omega * this.DEG2RAD
|
||||||
[sin(psi), cos(psi), 0, 0],
|
const pitch = p.phi * this.DEG2RAD
|
||||||
[0, 0, 1, 0],
|
const yaw = p.psi * this.DEG2RAD
|
||||||
[0, 0, 0, 1]
|
const rot = this.euler2R(roll, pitch, yaw)
|
||||||
];
|
const inv_rot = [
|
||||||
const Rxyz: number[][] = this.matrixMultiply(this.matrixMultiply(Rx, Ry), Rz);
|
[rot[0][0], rot[1][0], rot[2][0]],
|
||||||
|
[rot[0][1], rot[1][1], rot[2][1]],
|
||||||
const T: number[][] = [
|
[rot[0][2], rot[1][2], rot[2][2]]
|
||||||
[0, 0, 0, xm],
|
]
|
||||||
[0, 0, 0, ym],
|
const inv_trans = [
|
||||||
[0, 0, 0, zm],
|
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
|
||||||
[0, 0, 0, 0]
|
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
|
||||||
];
|
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
|
||||||
const Tm: number[][] = this.matrixAdd(T, Rxyz);
|
]
|
||||||
|
return p.feet.flatMap((foot, i) => {
|
||||||
const sHp = sin(Math.PI / 2);
|
const [wx, wy, wz] = foot
|
||||||
const cHp = cos(Math.PI / 2);
|
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
|
||||||
const L = this.L;
|
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
|
||||||
const W = this.W;
|
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
|
||||||
|
|
||||||
return [
|
const [mx, my, mz] = this.mountOffsets[i]
|
||||||
this.matrixMultiply(Tm, [
|
const px = bx - mx,
|
||||||
[cHp, 0, sHp, L / 2],
|
py = by - my,
|
||||||
[0, 1, 0, 0],
|
pz = bz - mz
|
||||||
[-sHp, 0, cHp, W / 2],
|
|
||||||
[0, 0, 0, 1]
|
const lx =
|
||||||
]),
|
this.invMountRot[0][0] * px +
|
||||||
this.matrixMultiply(Tm, [
|
this.invMountRot[0][1] * py +
|
||||||
[cHp, 0, sHp, L / 2],
|
this.invMountRot[0][2] * pz
|
||||||
[0, 1, 0, 0],
|
const ly =
|
||||||
[-sHp, 0, cHp, -W / 2],
|
this.invMountRot[1][0] * px +
|
||||||
[0, 0, 0, 1]
|
this.invMountRot[1][1] * py +
|
||||||
]),
|
this.invMountRot[1][2] * pz
|
||||||
this.matrixMultiply(Tm, [
|
const lz =
|
||||||
[cHp, 0, sHp, -L / 2],
|
this.invMountRot[2][0] * px +
|
||||||
[0, 1, 0, 0],
|
this.invMountRot[2][1] * py +
|
||||||
[-sHp, 0, cHp, W / 2],
|
this.invMountRot[2][2] * pz
|
||||||
[0, 0, 0, 1]
|
|
||||||
]),
|
const xLocal = i % 2 === 1 ? -lx : lx
|
||||||
this.matrixMultiply(Tm, [
|
return this.legIK(xLocal, ly, lz)
|
||||||
[cHp, 0, sHp, -L / 2],
|
})
|
||||||
[0, 1, 0, 0],
|
}
|
||||||
[-sHp, 0, cHp, -W / 2],
|
|
||||||
[0, 0, 0, 1]
|
private legIK(x: number, y: number, z: number): [number, number, number] {
|
||||||
])
|
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
|
||||||
];
|
const G = F - this.coxa_offset
|
||||||
}
|
const H = sqrt(G * G + z * z)
|
||||||
|
const t1 = -atan2(y, x) - atan2(F, -this.coxa)
|
||||||
private legIK(point: number[]): number[] {
|
const D =
|
||||||
const [x, y, z] = point;
|
(H * H - this.femur * this.femur - this.tibia * this.tibia) /
|
||||||
const { atan2, cos, sin, sqrt, acos } = Math;
|
(2 * this.femur * this.tibia)
|
||||||
const { l1, l2, l3, l4 } = this;
|
const t3 = acos(max(-1, min(1, D)))
|
||||||
|
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
|
||||||
let F;
|
return [t1, t2, t3]
|
||||||
|
}
|
||||||
try {
|
|
||||||
F = sqrt(x ** 2 + y ** 2 - l1 ** 2);
|
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
|
||||||
if (isNaN(F)) throw new Error('F is NaN');
|
const cr = cos(roll),
|
||||||
} catch (error) {
|
sr = sin(roll)
|
||||||
//console.log(error)
|
const cp = cos(pitch),
|
||||||
F = l1;
|
sp = sin(pitch)
|
||||||
}
|
const cy = cos(yaw),
|
||||||
const G = F - l2;
|
sy = sin(yaw)
|
||||||
const H = sqrt(G ** 2 + z ** 2);
|
return [
|
||||||
|
[cp * cy, -cp * sy, sp],
|
||||||
const theta1 = -atan2(y, x) - atan2(F, -l1);
|
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
|
||||||
const D = (H ** 2 - l3 ** 2 - l4 ** 2) / (2 * l3 * l4);
|
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
|
||||||
let theta3: number;
|
]
|
||||||
try {
|
}
|
||||||
theta3 = acos(D);
|
|
||||||
if (isNaN(theta3)) throw new Error('theta3 is NaN');
|
|
||||||
} catch (error) {
|
|
||||||
theta3 = 0;
|
|
||||||
}
|
|
||||||
const theta2 = atan2(z, G) - atan2(l4 * sin(theta3), l3 + l4 * cos(theta3));
|
|
||||||
|
|
||||||
return [theta1, theta2, theta3];
|
|
||||||
}
|
|
||||||
|
|
||||||
matrixMultiply(a: number[][], b: number[][]): number[][] {
|
|
||||||
const result: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
const row: number[] = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < b[0].length; j++) {
|
|
||||||
let sum = 0;
|
|
||||||
|
|
||||||
for (let k = 0; k < a[i].length; k++) {
|
|
||||||
sum += a[i][k] * b[k][j];
|
|
||||||
}
|
|
||||||
|
|
||||||
row.push(sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
multiplyVector(matrix: number[][], vector: number[]): number[] {
|
|
||||||
const rows = matrix.length;
|
|
||||||
const cols = matrix[0].length;
|
|
||||||
const vectorLength = vector.length;
|
|
||||||
|
|
||||||
if (cols !== vectorLength) {
|
|
||||||
throw new Error('Matrix and vector dimensions do not match for multiplication.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < rows; i++) {
|
|
||||||
let sum = 0;
|
|
||||||
|
|
||||||
for (let j = 0; j < cols; j++) {
|
|
||||||
sum += matrix[i][j] * vector[j];
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private matrixAdd(a: number[][], b: number[][]): number[][] {
|
|
||||||
const result: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
const row: number[] = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < a[i].length; j++) {
|
|
||||||
row.push(a[i][j] + b[i][j]);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public calcLegPoints(angles: number[]): number[][] {
|
|
||||||
const [theta1, theta2, theta3] = angles;
|
|
||||||
const theta23 = theta2 + theta3;
|
|
||||||
|
|
||||||
const T0: number[] = [0, 0, 0, 1];
|
|
||||||
const T1: number[] = this.vectorAdd(T0, [
|
|
||||||
-this.l1 * Math.cos(theta1),
|
|
||||||
this.l1 * Math.sin(theta1),
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
]);
|
|
||||||
const T2: number[] = this.vectorAdd(T1, [
|
|
||||||
-this.l2 * Math.sin(theta1),
|
|
||||||
-this.l2 * Math.cos(theta1),
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
]);
|
|
||||||
const T3: number[] = this.vectorAdd(T2, [
|
|
||||||
-this.l3 * Math.sin(theta1) * Math.cos(theta2),
|
|
||||||
-this.l3 * Math.cos(theta1) * Math.cos(theta2),
|
|
||||||
this.l3 * Math.sin(theta2),
|
|
||||||
0
|
|
||||||
]);
|
|
||||||
const T4: number[] = this.vectorAdd(T3, [
|
|
||||||
-this.l4 * Math.sin(theta1) * Math.cos(theta23),
|
|
||||||
-this.l4 * Math.cos(theta1) * Math.cos(theta23),
|
|
||||||
this.l4 * Math.sin(theta23),
|
|
||||||
0
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [T0, T1, T2, T3, T4];
|
|
||||||
}
|
|
||||||
|
|
||||||
public calcIK(Lp: number[][], angles: number[], center: number[]): number[][] {
|
|
||||||
const [omega, phi, psi] = angles;
|
|
||||||
const [xm, ym, zm] = center;
|
|
||||||
|
|
||||||
const [Tlf, Trf, Tlb, Trb] = this.bodyIK(omega, phi, psi, xm, ym, zm);
|
|
||||||
|
|
||||||
const Ix: number[][] = [
|
|
||||||
[-1, 0, 0, 0],
|
|
||||||
[0, 1, 0, 0],
|
|
||||||
[0, 0, 1, 0],
|
|
||||||
[0, 0, 0, 1]
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
this.legIK(this.multiplyVector(this.matrixInverse(Tlf), Lp[0])),
|
|
||||||
this.legIK(this.multiplyVector(Ix, this.multiplyVector(this.matrixInverse(Trf), Lp[1]))),
|
|
||||||
this.legIK(this.multiplyVector(this.matrixInverse(Tlb), Lp[2])),
|
|
||||||
this.legIK(this.multiplyVector(Ix, this.multiplyVector(this.matrixInverse(Trb), Lp[3])))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private vectorAdd(a: number[], b: number[]): number[] {
|
|
||||||
return a.map((val, index) => val + b[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private matrixInverse(matrix: number[][]): number[][] {
|
|
||||||
const det = this.determinant(matrix);
|
|
||||||
const adjugate = this.adjugate(matrix);
|
|
||||||
const scalar = 1 / det;
|
|
||||||
const inverse: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++) {
|
|
||||||
const row: number[] = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < matrix[i].length; j++) {
|
|
||||||
row.push(adjugate[i][j] * scalar);
|
|
||||||
}
|
|
||||||
|
|
||||||
inverse.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
private determinant(matrix: number[][]): number {
|
|
||||||
if (matrix.length !== matrix[0].length) {
|
|
||||||
throw new Error('The matrix is not square.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matrix.length === 2) {
|
|
||||||
return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
let det = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++) {
|
|
||||||
const sign = i % 2 === 0 ? 1 : -1;
|
|
||||||
const subMatrix: number[][] = [];
|
|
||||||
|
|
||||||
for (let j = 1; j < matrix.length; j++) {
|
|
||||||
const row: number[] = [];
|
|
||||||
|
|
||||||
for (let k = 0; k < matrix.length; k++) {
|
|
||||||
if (k !== i) {
|
|
||||||
row.push(matrix[j][k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subMatrix.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
det += sign * matrix[0][i] * this.determinant(subMatrix);
|
|
||||||
}
|
|
||||||
|
|
||||||
return det;
|
|
||||||
}
|
|
||||||
|
|
||||||
private adjugate(matrix: number[][]): number[][] {
|
|
||||||
if (matrix.length !== matrix[0].length) {
|
|
||||||
throw new Error('The matrix is not square.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const adjugate: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++) {
|
|
||||||
const row: number[] = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < matrix[i].length; j++) {
|
|
||||||
const sign = (i + j) % 2 === 0 ? 1 : -1;
|
|
||||||
const subMatrix: number[][] = [];
|
|
||||||
|
|
||||||
for (let k = 0; k < matrix.length; k++) {
|
|
||||||
if (k !== i) {
|
|
||||||
const subRow: number[] = [];
|
|
||||||
|
|
||||||
for (let l = 0; l < matrix.length; l++) {
|
|
||||||
if (l !== j) {
|
|
||||||
subRow.push(matrix[k][l]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subMatrix.push(subRow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cofactor = sign * this.determinant(subMatrix);
|
|
||||||
row.push(cofactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
adjugate.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.transpose(adjugate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private transpose(matrix: number[][]): number[][] {
|
|
||||||
const transposed: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++) {
|
|
||||||
const row: number[] = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < matrix[i].length; j++) {
|
|
||||||
row.push(matrix[j][i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
transposed.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return transposed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ForwardKinematics {
|
|
||||||
private l1: number;
|
|
||||||
private l2: number;
|
|
||||||
private l3: number;
|
|
||||||
private l4: number;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.l1 = 50;
|
|
||||||
this.l2 = 20;
|
|
||||||
this.l3 = 120;
|
|
||||||
this.l4 = 155;
|
|
||||||
}
|
|
||||||
|
|
||||||
public calculateFootpoint(theta1: number, theta2: number, theta3: number): number[] {
|
|
||||||
const { cos, sin } = Math;
|
|
||||||
|
|
||||||
const x =
|
|
||||||
this.l1 * cos(theta1) +
|
|
||||||
this.l2 * cos(theta1) +
|
|
||||||
this.l3 * cos(theta1 + theta2) +
|
|
||||||
this.l4 * cos(theta1 + theta2 + theta3);
|
|
||||||
const y =
|
|
||||||
this.l1 * sin(theta1) +
|
|
||||||
this.l2 * sin(theta1) +
|
|
||||||
this.l3 * sin(theta1 + theta2) +
|
|
||||||
this.l4 * sin(theta1 + theta2 + theta3);
|
|
||||||
const z = 0;
|
|
||||||
|
|
||||||
return [x, y, z];
|
|
||||||
}
|
|
||||||
|
|
||||||
public calculateFootpoints(angles: number[]): number[][] {
|
|
||||||
const footpoints: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < angles.length; i += 3) {
|
|
||||||
const theta1 = angles[i];
|
|
||||||
const theta2 = angles[i + 1];
|
|
||||||
const theta3 = angles[i + 2];
|
|
||||||
const footpoint = this.calculateFootpoint(theta1, theta2, theta3);
|
|
||||||
footpoints.push(footpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
return footpoints;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export type vector = { x: number; y: number };
|
|
||||||
|
|
||||||
export interface ControllerInput {
|
|
||||||
left: vector;
|
|
||||||
right: vector;
|
|
||||||
height: number;
|
|
||||||
speed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type angles = number[] | Int16Array;
|
|
||||||
|
|
||||||
export type AnglesData = {
|
|
||||||
type: 'angles';
|
|
||||||
data: angles;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LogData = {
|
|
||||||
type: 'log';
|
|
||||||
data: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebSocketJsonMsg = AnglesData | LogData;
|
|
||||||
+308
-279
@@ -1,319 +1,348 @@
|
|||||||
import {
|
import {
|
||||||
Mesh,
|
Mesh,
|
||||||
PerspectiveCamera,
|
PerspectiveCamera,
|
||||||
PlaneGeometry,
|
PlaneGeometry,
|
||||||
Scene,
|
Scene,
|
||||||
ShadowMaterial,
|
WebGLRenderer,
|
||||||
WebGLRenderer,
|
AmbientLight,
|
||||||
AmbientLight,
|
DirectionalLight,
|
||||||
DirectionalLight,
|
PCFSoftShadowMap,
|
||||||
PCFSoftShadowMap,
|
type GridHelper,
|
||||||
GridHelper,
|
ArrowHelper,
|
||||||
ArrowHelper,
|
Vector3,
|
||||||
Vector3,
|
FogExp2,
|
||||||
LoaderUtils,
|
CanvasTexture,
|
||||||
Object3D,
|
type ColorRepresentation,
|
||||||
FogExp2,
|
type WebGLRendererParameters,
|
||||||
CanvasTexture,
|
MeshPhongMaterial,
|
||||||
type ColorRepresentation,
|
EquirectangularReflectionMapping,
|
||||||
type WebGLRendererParameters,
|
ACESFilmicToneMapping,
|
||||||
MeshPhongMaterial,
|
Group,
|
||||||
EquirectangularReflectionMapping,
|
MeshBasicMaterial,
|
||||||
ACESFilmicToneMapping,
|
RepeatWrapping,
|
||||||
MathUtils
|
Object3D
|
||||||
} from 'three';
|
} from 'three'
|
||||||
import { Sky } from 'three/addons/objects/Sky.js';
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
|
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||||
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader';
|
import { Reflector } from 'three/examples/jsm/objects/Reflector.js'
|
||||||
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls';
|
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'
|
||||||
|
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
|
||||||
|
|
||||||
export const addScene = () => new Scene();
|
export const addScene = () => new Scene()
|
||||||
|
|
||||||
interface position {
|
interface position {
|
||||||
x?: number;
|
x?: number
|
||||||
y?: number;
|
y?: number
|
||||||
z?: number;
|
z?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface light {
|
interface light {
|
||||||
color?: ColorRepresentation;
|
color?: ColorRepresentation
|
||||||
intensity?: number;
|
intensity?: number
|
||||||
}
|
|
||||||
|
|
||||||
interface gridOptions {
|
|
||||||
divisions?: number;
|
|
||||||
size?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface arrowOptions {
|
interface arrowOptions {
|
||||||
origin: position;
|
origin: position
|
||||||
direction: position;
|
direction: position
|
||||||
length?: number;
|
length?: number
|
||||||
color?: ColorRepresentation;
|
color?: ColorRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
type directionalLight = position & light;
|
type directionalLight = position & light
|
||||||
|
|
||||||
type gridHelperOptions = gridOptions & position;
|
|
||||||
|
|
||||||
function calculateCurrentSunElevation() {
|
|
||||||
let now = new Date();
|
|
||||||
let decimalTime = now.getHours() + now.getMinutes() / 60;
|
|
||||||
let normalizedTime = (decimalTime % 12) / 6 - 1;
|
|
||||||
return 10 * Math.sin(normalizedTime * Math.PI);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class SceneBuilder {
|
export default class SceneBuilder {
|
||||||
public scene: Scene;
|
public scene: Scene
|
||||||
public camera: PerspectiveCamera;
|
public camera!: PerspectiveCamera
|
||||||
public ground: Mesh;
|
public ground!: Mesh
|
||||||
public renderer: WebGLRenderer;
|
public renderer!: WebGLRenderer
|
||||||
public controls: OrbitControls;
|
public orbit: OrbitControls
|
||||||
public callback: Function;
|
public callback: (() => void) | undefined
|
||||||
public gridHelper: GridHelper;
|
public gridHelper!: GridHelper
|
||||||
public model: URDFRobot;
|
public model!: URDFRobot
|
||||||
public liveStreamTexture: CanvasTexture;
|
public liveStreamTexture!: CanvasTexture
|
||||||
private fog: FogExp2;
|
private fog!: FogExp2
|
||||||
private isLoaded: boolean = false;
|
private isLoaded: boolean = false
|
||||||
highlightMaterial: any;
|
public isDragging: boolean = false
|
||||||
|
transformControl: TransformControls
|
||||||
|
public modelGroup!: Group
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.scene = new Scene();
|
this.scene = new Scene()
|
||||||
if (this.scene.environment?.mapping) {
|
if (this.scene.environment?.mapping) {
|
||||||
this.scene.environment.mapping = EquirectangularReflectionMapping;
|
this.scene.environment.mapping = EquirectangularReflectionMapping
|
||||||
}
|
}
|
||||||
return this;
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
public addRenderer = (parameters?: WebGLRendererParameters) => {
|
public addRenderer = (parameters?: WebGLRendererParameters) => {
|
||||||
this.renderer = new WebGLRenderer(parameters);
|
this.renderer = new WebGLRenderer(parameters)
|
||||||
this.renderer.outputColorSpace = 'srgb';
|
this.renderer.outputColorSpace = 'srgb'
|
||||||
this.renderer.shadowMap.enabled = true;
|
this.renderer.shadowMap.enabled = true
|
||||||
this.renderer.shadowMap.type = PCFSoftShadowMap;
|
this.renderer.shadowMap.type = PCFSoftShadowMap
|
||||||
this.renderer.toneMapping = ACESFilmicToneMapping;
|
this.renderer.toneMapping = ACESFilmicToneMapping
|
||||||
this.renderer.toneMappingExposure = 0.85;
|
this.renderer.toneMappingExposure = 0.85
|
||||||
document.body.appendChild(this.renderer.domElement);
|
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
|
||||||
return this;
|
return this
|
||||||
};
|
}
|
||||||
|
|
||||||
public addSky = () => {
|
public addPerspectiveCamera = (options: position) => {
|
||||||
const sky = new Sky();
|
this.camera = new PerspectiveCamera()
|
||||||
sky.scale.setScalar(450000);
|
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
|
||||||
this.scene.add(sky);
|
this.scene.add(this.camera)
|
||||||
const effectController = {
|
return this
|
||||||
turbidity: 10,
|
}
|
||||||
rayleigh: 3,
|
|
||||||
mieCoefficient: 0.005,
|
|
||||||
mieDirectionalG: 0.7,
|
|
||||||
elevation: calculateCurrentSunElevation(),
|
|
||||||
azimuth: 180,
|
|
||||||
exposure: this.renderer.toneMappingExposure
|
|
||||||
};
|
|
||||||
const uniforms = sky.material.uniforms;
|
|
||||||
uniforms['turbidity'].value = effectController.turbidity;
|
|
||||||
uniforms['rayleigh'].value = effectController.rayleigh;
|
|
||||||
uniforms['mieCoefficient'].value = effectController.mieCoefficient;
|
|
||||||
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG;
|
|
||||||
this.renderer.toneMappingExposure = 0.5;
|
|
||||||
const phi = MathUtils.degToRad(90 - effectController.elevation);
|
|
||||||
const theta = MathUtils.degToRad(effectController.azimuth);
|
|
||||||
const sun = new Vector3();
|
|
||||||
|
|
||||||
sun.setFromSphericalCoords(1, phi, theta);
|
public addGroundPlane = (options?: position) => {
|
||||||
uniforms['sunPosition'].value.copy(sun);
|
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
|
||||||
return this;
|
checkerboardTexture.wrapS = RepeatWrapping
|
||||||
};
|
checkerboardTexture.wrapT = RepeatWrapping
|
||||||
|
checkerboardTexture.repeat.set(100, 100)
|
||||||
|
const checkerboardMat = new MeshBasicMaterial({
|
||||||
|
map: checkerboardTexture,
|
||||||
|
opacity: 0.1,
|
||||||
|
transparent: true
|
||||||
|
})
|
||||||
|
|
||||||
public addPerspectiveCamera = (options: position) => {
|
const plane = new PlaneGeometry(400, 400)
|
||||||
this.camera = new PerspectiveCamera();
|
|
||||||
this.camera.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
|
|
||||||
this.scene.add(this.camera);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addGroundPlane = (options?: position) => {
|
this.ground = new Mesh(plane, checkerboardMat)
|
||||||
this.ground = new Mesh(new PlaneGeometry(), new ShadowMaterial({ side: 2 }));
|
this.ground.rotation.x = -Math.PI / 2
|
||||||
this.ground.rotation.x = -Math.PI / 2;
|
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
|
||||||
this.ground.scale.setScalar(30);
|
this.ground.receiveShadow = true
|
||||||
this.ground.position.set(options?.x ?? 0, options?.y ?? 0, options?.z ?? 0);
|
this.scene.add(this.ground)
|
||||||
this.ground.receiveShadow = true;
|
|
||||||
this.scene.add(this.ground);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addOrbitControls = (minDistance: number, maxDistance: number) => {
|
const mirror = new Reflector(plane, {
|
||||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
clipBias: 0.003,
|
||||||
this.controls.minDistance = minDistance;
|
textureWidth: window.innerWidth * window.devicePixelRatio,
|
||||||
this.controls.maxDistance = maxDistance;
|
textureHeight: window.innerHeight * window.devicePixelRatio,
|
||||||
this.controls.update();
|
color: 0x00bfff
|
||||||
return this;
|
})
|
||||||
};
|
mirror.rotateX(-Math.PI / 2)
|
||||||
|
this.scene.add(mirror)
|
||||||
|
|
||||||
public addAmbientLight = (options: light) => {
|
return this
|
||||||
const ambientLight = new AmbientLight(options.color, options.intensity);
|
}
|
||||||
this.scene.add(ambientLight);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addDirectionalLight = (options: directionalLight) => {
|
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
|
||||||
const directionalLight = new DirectionalLight(options.color, options.intensity);
|
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
|
||||||
directionalLight.castShadow = true;
|
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
|
||||||
directionalLight.shadow.mapSize.setScalar(2048);
|
this.orbit.maxDistance = maxDistance
|
||||||
directionalLight.shadow.mapSize.width = 1024;
|
this.orbit.autoRotate = autoRotate
|
||||||
directionalLight.shadow.mapSize.height = 1024;
|
this.orbit.update()
|
||||||
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
|
this.orbit.minDistance = minDistance
|
||||||
directionalLight.shadow.radius = 5;
|
return this
|
||||||
this.scene.add(directionalLight);
|
}
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addGridHelper = (options: gridHelperOptions) => {
|
public addAmbientLight = (options: light) => {
|
||||||
this.gridHelper = new GridHelper(options.size, options.divisions);
|
const ambientLight = new AmbientLight(options.color, options.intensity)
|
||||||
this.gridHelper.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
|
this.scene.add(ambientLight)
|
||||||
this.gridHelper.material.opacity = 0.2;
|
return this
|
||||||
this.gridHelper.material.depthWrite = false;
|
}
|
||||||
this.gridHelper.material.transparent = true;
|
|
||||||
this.scene.add(this.gridHelper);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
|
public addDirectionalLight = (options: directionalLight) => {
|
||||||
this.scene.fog = new FogExp2(color, density);
|
const directionalLight = new DirectionalLight(options.color, options.intensity)
|
||||||
return this;
|
directionalLight.castShadow = true
|
||||||
};
|
directionalLight.shadow.camera.top = 10
|
||||||
|
directionalLight.shadow.camera.bottom = -10
|
||||||
|
directionalLight.shadow.camera.right = 10
|
||||||
|
directionalLight.shadow.camera.left = -10
|
||||||
|
directionalLight.shadow.mapSize.set(4096, 4096)
|
||||||
|
|
||||||
public handleResize = () => {
|
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
|
||||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
this.scene.add(directionalLight)
|
||||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
return this
|
||||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
}
|
||||||
this.camera.updateProjectionMatrix();
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addRenderCb = (callback: Function) => {
|
private createCheckerboardTexture = (size: number, squares: number) => {
|
||||||
this.callback = callback;
|
const canvas = document.createElement('canvas')
|
||||||
return this;
|
canvas.width = size
|
||||||
};
|
canvas.height = size
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
public startRenderLoop = () => {
|
const squareSize = size / squares
|
||||||
this.renderer.setAnimationLoop(() => {
|
|
||||||
this.controls.update();
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
|
||||||
this.handleRobotShadow();
|
|
||||||
if (this.callback) this.callback();
|
|
||||||
if (!this.liveStreamTexture) return;
|
|
||||||
});
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public addArrowHelper = (options?: arrowOptions) => {
|
for (let y = 0; y < squares; y++) {
|
||||||
const dir = new Vector3(
|
for (let x = 0; x < squares; x++) {
|
||||||
options?.direction.x ?? 0,
|
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
|
||||||
options?.direction.y ?? 0,
|
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
|
||||||
options?.direction.z ?? 0
|
}
|
||||||
);
|
}
|
||||||
const origin = new Vector3(
|
|
||||||
options?.origin.x ?? 0,
|
|
||||||
options?.origin.y ?? 0,
|
|
||||||
options?.origin.z ?? 0
|
|
||||||
);
|
|
||||||
const arrowHelper = new ArrowHelper(
|
|
||||||
dir,
|
|
||||||
origin,
|
|
||||||
options?.length ?? 1.5,
|
|
||||||
options?.color ?? 0xff0000
|
|
||||||
);
|
|
||||||
this.scene.add(arrowHelper);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
private setJointValue(jointName: string, angle: number) {
|
const texture = new CanvasTexture(canvas)
|
||||||
if (!this.model) return;
|
texture.wrapS = texture.wrapT = RepeatWrapping
|
||||||
if (!this.model.joints[jointName]) return;
|
texture.anisotropy = 16
|
||||||
this.model.joints[jointName].setJointValue(angle);
|
return texture
|
||||||
}
|
}
|
||||||
|
|
||||||
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed';
|
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
|
||||||
|
this.scene.fog = new FogExp2(color, density)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
|
public fillParent = () => {
|
||||||
const traverse = (c: any) => {
|
const parentElement = this.renderer.domElement.parentElement
|
||||||
if (c.type === 'Mesh') {
|
if (parentElement) {
|
||||||
if (revert) {
|
const width = parentElement.clientWidth
|
||||||
c.material = c.__origMaterial;
|
const height = parentElement.clientHeight
|
||||||
delete c.__origMaterial;
|
this.handleResize(width, height)
|
||||||
} else {
|
}
|
||||||
c.__origMaterial = c.material;
|
return this
|
||||||
c.material = material;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === m || !this.isJoint(c)) {
|
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
|
||||||
for (let i = 0; i < c.children.length; i++) {
|
this.renderer.setSize(width, height)
|
||||||
const child = c.children[i];
|
this.renderer.setPixelRatio(window.devicePixelRatio)
|
||||||
if (!child.isURDFCollider) {
|
this.camera.aspect = width / height
|
||||||
traverse(c.children[i]);
|
this.camera.updateProjectionMatrix()
|
||||||
}
|
return this
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
traverse(m);
|
|
||||||
};
|
|
||||||
|
|
||||||
public addModel = (model: any) => {
|
public addRenderCb = (callback: () => void) => {
|
||||||
this.model = model;
|
this.callback = callback
|
||||||
this.scene.add(model);
|
return this
|
||||||
return this;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
public addDragControl = (updateAngle: any) => {
|
public startRenderLoop = () => {
|
||||||
const highlightColor = '#FFFFFF';
|
this.renderer.setAnimationLoop(() => {
|
||||||
const highlightMaterial = new MeshPhongMaterial({
|
this.renderer.render(this.scene, this.camera)
|
||||||
shininess: 10,
|
this.orbit.update()
|
||||||
color: highlightColor,
|
this.handleRobotShadow()
|
||||||
emissive: highlightColor,
|
if (this.callback) this.callback()
|
||||||
emissiveIntensity: 0.25
|
if (!this.liveStreamTexture) return
|
||||||
});
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
const dragControls = new PointerURDFDragControls(
|
public addArrowHelper = (options?: arrowOptions) => {
|
||||||
this.scene,
|
const dir = new Vector3(
|
||||||
this.camera,
|
options?.direction.x ?? 0,
|
||||||
this.renderer.domElement
|
options?.direction.y ?? 0,
|
||||||
);
|
options?.direction.z ?? 0
|
||||||
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
|
)
|
||||||
this.setJointValue(joint.name, angle);
|
const origin = new Vector3(
|
||||||
updateAngle(joint.name, angle);
|
options?.origin.x ?? 0,
|
||||||
};
|
options?.origin.y ?? 0,
|
||||||
dragControls.onDragStart = () => (this.controls.enabled = false);
|
options?.origin.z ?? 0
|
||||||
dragControls.onDragEnd = () => (this.controls.enabled = true);
|
)
|
||||||
dragControls.onHover = (joint: URDFMimicJoint) =>
|
const arrowHelper = new ArrowHelper(
|
||||||
this.highlightLinkGeometry(joint, false, highlightMaterial);
|
dir,
|
||||||
dragControls.onUnhover = (joint: URDFMimicJoint) =>
|
origin,
|
||||||
this.highlightLinkGeometry(joint, true, highlightMaterial);
|
options?.length ?? 1.5,
|
||||||
|
options?.color ?? 0xff0000
|
||||||
|
)
|
||||||
|
this.scene.add(arrowHelper)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
this.renderer.domElement.addEventListener('touchstart', (data) =>
|
private setJointValue(jointName: string, angle: number) {
|
||||||
dragControls._mouseDown(data.touches[0])
|
if (!this.model) return
|
||||||
);
|
if (!this.model.joints[jointName]) return
|
||||||
this.renderer.domElement.addEventListener('touchmove', (data) =>
|
this.model.joints[jointName].setJointValue(angle)
|
||||||
dragControls._mouseMove(data.touches[0])
|
}
|
||||||
);
|
|
||||||
this.renderer.domElement.addEventListener('touchend', (data) =>
|
|
||||||
dragControls._mouseUp(data.touches[0])
|
|
||||||
);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
public toggleFog = () => {
|
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
|
||||||
this.scene.fog = this.scene.fog ? null : this.fog;
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleRobotShadow = () => {
|
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
|
||||||
if (this.isLoaded) return;
|
const traverse = (c: Object3D) => {
|
||||||
const intervalId = setInterval(() => {
|
if (c.type === 'Mesh') {
|
||||||
this.model?.traverse((c) => (c.castShadow = true));
|
if (revert) {
|
||||||
}, 10);
|
c.material = c.__origMaterial
|
||||||
setTimeout(() => {
|
delete c.__origMaterial
|
||||||
clearInterval(intervalId);
|
} else {
|
||||||
}, 1000);
|
c.__origMaterial = c.material
|
||||||
this.isLoaded = true;
|
c.material = material
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === m || !this.isJoint(c)) {
|
||||||
|
for (let i = 0; i < c.children.length; i++) {
|
||||||
|
const child = c.children[i]
|
||||||
|
if (!child.isURDFCollider) {
|
||||||
|
traverse(c.children[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
public addTransformControls = (model: Object3D) => {
|
||||||
|
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
|
||||||
|
this.transformControl.addEventListener('dragging-changed', (event: { value: boolean }) => {
|
||||||
|
this.orbit.enabled = !event.value
|
||||||
|
this.isDragging = !event.value
|
||||||
|
})
|
||||||
|
this.transformControl.attach(model)
|
||||||
|
this.scene.add(this.transformControl)
|
||||||
|
this.transformControl.setMode('rotate')
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addModel = (model: URDFRobot) => {
|
||||||
|
this.modelGroup = new Group()
|
||||||
|
this.modelGroup.add(model)
|
||||||
|
this.model = model
|
||||||
|
this.scene.add(this.modelGroup)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addDragControl = (updateAngle: (angles: Record<string, number>) => void) => {
|
||||||
|
const highlightColor = '#FFFFFF'
|
||||||
|
const highlightMaterial = new MeshPhongMaterial({
|
||||||
|
shininess: 10,
|
||||||
|
color: highlightColor,
|
||||||
|
emissive: highlightColor,
|
||||||
|
emissiveIntensity: 0.9
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragControls = new PointerURDFDragControls(
|
||||||
|
this.scene,
|
||||||
|
this.camera,
|
||||||
|
this.renderer.domElement
|
||||||
|
)
|
||||||
|
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
|
||||||
|
this.setJointValue(joint.name, angle)
|
||||||
|
updateAngle({ [joint.name]: angle })
|
||||||
|
}
|
||||||
|
dragControls.onDragStart = () => {
|
||||||
|
this.orbit.enabled = false
|
||||||
|
this.isDragging = true
|
||||||
|
}
|
||||||
|
dragControls.onDragEnd = () => {
|
||||||
|
this.orbit.enabled = true
|
||||||
|
this.isDragging = false
|
||||||
|
}
|
||||||
|
dragControls.onHover = (joint: URDFMimicJoint) =>
|
||||||
|
this.highlightLinkGeometry(joint, false, highlightMaterial)
|
||||||
|
dragControls.onUnhover = (joint: URDFMimicJoint) =>
|
||||||
|
this.highlightLinkGeometry(joint, true, highlightMaterial)
|
||||||
|
|
||||||
|
this.renderer.domElement.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
data => dragControls._mouseDown(data.touches[0]),
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
this.renderer.domElement.addEventListener(
|
||||||
|
'touchmove',
|
||||||
|
data => dragControls._mouseMove(data.touches[0]),
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
this.renderer.domElement.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
data => dragControls._mouseUp(data.touches[0]),
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleFog = () => {
|
||||||
|
this.scene.fog = this.scene.fog ? null : this.fog
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRobotShadow = () => {
|
||||||
|
if (this.isLoaded) return
|
||||||
|
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
|
||||||
|
setTimeout(() => clearInterval(intervalId), 1000)
|
||||||
|
this.isLoaded = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,53 @@
|
|||||||
import { Result } from '$lib/utilities/result';
|
import { Result } from '$lib/utilities/result'
|
||||||
|
import { browser } from '$app/environment'
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
private dbName = 'fileStorageDB';
|
private dbPromise: Promise<Result<IDBDatabase, string>> | null =
|
||||||
private dbVersion = 1;
|
browser ? this.openDatabase() : null
|
||||||
private storeName = 'files';
|
|
||||||
private dbPromise: Promise<Result<IDBDatabase, string>>;
|
|
||||||
|
|
||||||
constructor() {
|
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
|
||||||
this.dbPromise = this.openDatabase();
|
return new Promise(resolve => {
|
||||||
}
|
const request = indexedDB.open('fileStorageDB', 1)
|
||||||
|
|
||||||
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
|
request.onupgradeneeded = () => {
|
||||||
return new Promise((resolve) => {
|
request.result.createObjectStore('files')
|
||||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
}
|
||||||
|
request.onsuccess = () => resolve(Result.ok(request.result))
|
||||||
|
request.onerror = () => resolve(Result.err('Error opening database'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
request.onerror = () => resolve(Result.err('Error opening database'));
|
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
|
||||||
|
if (!browser || !this.dbPromise)
|
||||||
|
return Result.err('Not running in browser or DB not initialized')
|
||||||
|
const dbResult = await this.dbPromise
|
||||||
|
if (dbResult.isErr()) return Result.err('Database not initialized')
|
||||||
|
const store = dbResult.inner.transaction('files', mode).objectStore('files')
|
||||||
|
return Result.ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
request.onsuccess = () => resolve(Result.ok(request.result));
|
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
|
||||||
|
const storeResult = await this.getStore('readwrite')
|
||||||
|
if (storeResult.isErr()) return Result.err('Failed to access store')
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
return new Promise(resolve => {
|
||||||
const db = request.result;
|
const request = storeResult.inner.put(file, key)
|
||||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
request.onsuccess = () => resolve(Result.ok(request.result))
|
||||||
db.createObjectStore(this.storeName);
|
request.onerror = () => resolve(Result.err('Failed to save file'))
|
||||||
}
|
})
|
||||||
};
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
|
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
|
||||||
const dbResult = await this.dbPromise;
|
const storeResult = await this.getStore('readonly')
|
||||||
if (dbResult.isErr()) {
|
if (storeResult.isErr()) return Result.err('Failed to access store')
|
||||||
return Result.err('Database not initialized properly');
|
|
||||||
}
|
|
||||||
const db = dbResult.inner;
|
|
||||||
const transaction = db.transaction(this.storeName, mode);
|
|
||||||
return Result.ok(transaction.objectStore(this.storeName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
|
return new Promise(resolve => {
|
||||||
const storeResult = await this.getStore('readwrite');
|
const request = storeResult.inner.get(key)
|
||||||
if (storeResult.isErr()) {
|
request.onsuccess = () =>
|
||||||
return Result.err('Failed to access object store for writing');
|
resolve(request.result ? Result.ok(request.result) : Result.err('File not found'))
|
||||||
}
|
request.onerror = () => resolve(Result.err('Failed to retrieve file'))
|
||||||
const store = storeResult.inner;
|
})
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
|
||||||
const request = store.put(file, key);
|
|
||||||
request.onsuccess = () => resolve(Result.ok(request.result));
|
|
||||||
request.onerror = () => resolve(Result.err('Failed to save file'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
|
|
||||||
const storeResult = await this.getStore('readonly');
|
|
||||||
if (storeResult.isErr()) {
|
|
||||||
return Result.err('Failed to access object store for reading');
|
|
||||||
}
|
|
||||||
const store = storeResult.inner;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const request = store.get(key);
|
|
||||||
|
|
||||||
request.onsuccess = () =>
|
|
||||||
resolve(request.result ? Result.ok(request.result) : Result.err('File content not found'));
|
|
||||||
request.onerror = () => resolve(Result.err('Failed to retrieve file'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new FileService();
|
export default browser ? new FileService() : null
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { default as fileService } from './file-service';
|
export { default as fileService } from './file-service'
|
||||||
export { default as socketService } from './socket-service';
|
export { default as resultService } from './result-service'
|
||||||
export { default as resultService } from './result-service';
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { errorLogs, latestErrorLog } from '$lib/stores';
|
import { errorLogs, latestErrorLog } from '$lib/stores'
|
||||||
import type { Result } from '$lib/utilities';
|
import type { Result } from '$lib/utilities'
|
||||||
|
|
||||||
class ResultService {
|
class ResultService {
|
||||||
public handleResult(result: Result<unknown, string>, tag?: string) {
|
public handleResult(result: Result<unknown, string>, tag?: string) {
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
const errorLogEntry = { tag, message: result.inner, exception: result.exception };
|
const errorLogEntry = { tag, message: result.inner, exception: result.exception }
|
||||||
latestErrorLog.set(errorLogEntry);
|
latestErrorLog.set(errorLogEntry)
|
||||||
errorLogs.update((entries) => {
|
errorLogs.update(entries => {
|
||||||
entries.push(errorLogEntry);
|
entries.push(errorLogEntry)
|
||||||
return entries;
|
return entries
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ResultService();
|
export default new ResultService()
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { isConnected, socketData } from '$lib/stores';
|
|
||||||
import { Result, Ok } from '$lib/utilities';
|
|
||||||
import { resultService } from '$lib/services';
|
|
||||||
import { type WebSocketJsonMsg } from '$lib/models';
|
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
type WebsocketOutData = string | ArrayBufferLike | Blob | ArrayBufferView;
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
/**
|
|
||||||
* MOVE THE store to a store.ts file
|
|
||||||
*
|
|
||||||
* Make an object on the class that encapsulate all the stores
|
|
||||||
*
|
|
||||||
* Make the handle message function look up the type and set the value, to simplify the code
|
|
||||||
*/
|
|
||||||
|
|
||||||
class SocketService {
|
|
||||||
private socket!: WebSocket;
|
|
||||||
private url?:string
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
public connect(url: string): void {
|
|
||||||
this.url = url
|
|
||||||
this.socket = new WebSocket(url);
|
|
||||||
this.socket.binaryType = 'arraybuffer';
|
|
||||||
this.socket.onopen = () => this.handleConnected();
|
|
||||||
this.socket.onclose = () => this.handleDisconnected();
|
|
||||||
this.socket.onmessage = (event: MessageEvent) =>
|
|
||||||
resultService.handleResult(this.handleMessage(event), 'SocketService');
|
|
||||||
this.socket.onerror = (error: Event) => console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public send(data: WebsocketOutData): Result<void, string> {
|
|
||||||
if (this.socket.readyState === WebSocket.OPEN) {
|
|
||||||
this.socket.send(data);
|
|
||||||
return Ok.void();
|
|
||||||
}
|
|
||||||
return Result.err('The connection is not open');
|
|
||||||
}
|
|
||||||
|
|
||||||
public addPublisher(store: Writable<WebsocketOutData>, type?: string) {
|
|
||||||
const publish = (data: WebsocketOutData) =>
|
|
||||||
this.send(type ? JSON.stringify({ type, data }) : data);
|
|
||||||
store.subscribe(publish);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleConnected(): void {
|
|
||||||
isConnected.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDisconnected(): void {
|
|
||||||
isConnected.set(false);
|
|
||||||
setTimeout(() => this.connect(this.url as string), 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJsonFromMessage(msg: string): Result<WebSocketJsonMsg, string> {
|
|
||||||
try {
|
|
||||||
return Result.ok(JSON.parse(msg) as WebSocketJsonMsg);
|
|
||||||
} catch (error) {
|
|
||||||
return Result.err('Failed to parse socket message', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleBufferMessage(buffer: ArrayBuffer): Result<void, string> {
|
|
||||||
console.log(buffer);
|
|
||||||
return Ok.void();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMessage(event: MessageEvent): Result<void, string> {
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
|
||||||
return this.handleBufferMessage(event.data);
|
|
||||||
}
|
|
||||||
let msgRes = this.getJsonFromMessage(event.data);
|
|
||||||
if (msgRes.isErr()) {
|
|
||||||
return msgRes;
|
|
||||||
}
|
|
||||||
const msg = msgRes.inner;
|
|
||||||
|
|
||||||
if (msg.type === 'log') {
|
|
||||||
socketData.logs.update((entries) => {
|
|
||||||
entries.push(msg.data);
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
return Ok.void();
|
|
||||||
} else if (msg.data && msg.type in socketData) {
|
|
||||||
socketData[msg.type].set(msg.data);
|
|
||||||
return Ok.void();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.err(`Got invalid msg: ${JSON.stringify(msg)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new SocketService();
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type Analytics } from '$lib/types/models'
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
|
const analytics_data = {
|
||||||
|
uptime: <number[]>[],
|
||||||
|
free_heap: <number[]>[],
|
||||||
|
total_heap: <number[]>[],
|
||||||
|
used_heap: <number[]>[],
|
||||||
|
min_free_heap: <number[]>[],
|
||||||
|
max_alloc_heap: <number[]>[],
|
||||||
|
fs_used: <number[]>[],
|
||||||
|
fs_total: <number[]>[],
|
||||||
|
core_temp: <number[]>[],
|
||||||
|
cpu0_usage: <number[]>[],
|
||||||
|
cpu1_usage: <number[]>[],
|
||||||
|
cpu_usage: <number[]>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAnalyticsData = 100
|
||||||
|
|
||||||
|
function createAnalytics() {
|
||||||
|
const { subscribe, update } = writable(analytics_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
addData: (content: Analytics) => {
|
||||||
|
update(analytics_data => ({
|
||||||
|
...analytics_data,
|
||||||
|
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
|
||||||
|
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
used_heap: [
|
||||||
|
...analytics_data.used_heap,
|
||||||
|
(content.total_heap - content.free_heap) / 1000
|
||||||
|
].slice(-maxAnalyticsData),
|
||||||
|
min_free_heap: [
|
||||||
|
...analytics_data.min_free_heap,
|
||||||
|
content.min_free_heap / 1000
|
||||||
|
].slice(-maxAnalyticsData),
|
||||||
|
max_alloc_heap: [
|
||||||
|
...analytics_data.max_alloc_heap,
|
||||||
|
content.max_alloc_heap / 1000
|
||||||
|
].slice(-maxAnalyticsData),
|
||||||
|
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
core_temp: [...analytics_data.core_temp, content.core_temp].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(
|
||||||
|
-maxAnalyticsData
|
||||||
|
),
|
||||||
|
cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].slice(-maxAnalyticsData)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analytics = createAnalytics()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { persistentStore } from '$lib/utilities'
|
||||||
|
import { get, type Writable } from 'svelte/store'
|
||||||
|
|
||||||
|
import Visualization from '$lib/components/Visualization.svelte'
|
||||||
|
import Stream from '$lib/components/Stream.svelte'
|
||||||
|
import ChartWidget from '$lib/components/widget/ChartWidget.svelte'
|
||||||
|
|
||||||
|
export interface WidgetConfig {
|
||||||
|
id: string | number
|
||||||
|
component: keyof typeof WidgetComponents
|
||||||
|
props?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetContainerConfig {
|
||||||
|
id: string | number
|
||||||
|
layout?: 'row' | 'column' | 'wrap'
|
||||||
|
header?: string
|
||||||
|
widgets: Array<WidgetConfig | WidgetContainerConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isWidgetConfig = (
|
||||||
|
widget: WidgetConfig | WidgetContainerConfig
|
||||||
|
): widget is WidgetConfig => 'component' in widget
|
||||||
|
|
||||||
|
export const WidgetComponents = {
|
||||||
|
Visualization,
|
||||||
|
Stream,
|
||||||
|
ChartWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
interface View {
|
||||||
|
name: string
|
||||||
|
content: WidgetContainerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultViews: View[] = [
|
||||||
|
{
|
||||||
|
name: '3D representation',
|
||||||
|
content: {
|
||||||
|
id: 'root',
|
||||||
|
layout: 'column',
|
||||||
|
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Stream',
|
||||||
|
content: {
|
||||||
|
id: 'root',
|
||||||
|
layout: 'column',
|
||||||
|
widgets: [{ id: 2, component: 'Stream' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Split screen',
|
||||||
|
content: {
|
||||||
|
id: 'root',
|
||||||
|
widgets: [
|
||||||
|
{ id: 2, component: 'Stream' },
|
||||||
|
{ id: 2, component: 'Visualization', props: { debug: true } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const views: Writable<View[]> = persistentStore('views', defaultViews)
|
||||||
|
|
||||||
|
export const selectedView = persistentStore('selected_view', get(views)[0].name)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { api } from '$lib/api'
|
||||||
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
|
import Kinematic from '$lib/kinematic'
|
||||||
|
import { persistentStore } from '$lib/utilities'
|
||||||
|
import { derived, type Writable } from 'svelte/store'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
|
|
||||||
|
let featureFlagsStore: Writable<Record<string, boolean | string>>
|
||||||
|
|
||||||
|
export function useFeatureFlags() {
|
||||||
|
if (!featureFlagsStore) {
|
||||||
|
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
|
||||||
|
|
||||||
|
api.get<Record<string, boolean>>('/api/features').then(result => {
|
||||||
|
if (result.isOk()) featureFlagsStore.set(result.inner)
|
||||||
|
else {
|
||||||
|
notifications.error('Feature flag could not be fetched', 2500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return featureFlagsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = resolve('/')
|
||||||
|
|
||||||
|
export const variants = {
|
||||||
|
SPOTMICRO_ESP32: {
|
||||||
|
model: `${base}spot_micro.urdf.xacro`,
|
||||||
|
stl: `${base}stl.zip`,
|
||||||
|
kinematics: {
|
||||||
|
coxa: 60.5 / 100,
|
||||||
|
coxa_offset: 10 / 100,
|
||||||
|
femur: 111.7 / 100,
|
||||||
|
tibia: 118.5 / 100,
|
||||||
|
L: 207.5 / 100,
|
||||||
|
W: 78 / 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SPOTMICRO_YERTLE: {
|
||||||
|
model: `${base}yertle.URDF`,
|
||||||
|
stl: `${base}URDF.zip`,
|
||||||
|
kinematics: {
|
||||||
|
coxa: 35 / 100,
|
||||||
|
coxa_offset: 0 / 100,
|
||||||
|
femur: 130 / 100,
|
||||||
|
tibia: 130 / 100,
|
||||||
|
L: 240 / 100,
|
||||||
|
W: 78 / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVariant = derived(useFeatureFlags(), $flagStore => {
|
||||||
|
const variantFlag = $flagStore['variant'] as string
|
||||||
|
return variantFlag && variants[variantFlag as keyof typeof variants] ?
|
||||||
|
variants[variantFlag as keyof typeof variants]
|
||||||
|
: variants.SPOTMICRO_ESP32
|
||||||
|
})
|
||||||
|
|
||||||
|
export const currentKinematic = derived(
|
||||||
|
currentVariant,
|
||||||
|
$variant => new Kinematic($variant.kinematics)
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
|
export const isFullscreen = writable(false)
|
||||||
|
|
||||||
|
export function toggleFullscreen() {
|
||||||
|
isFullscreen.update(state => {
|
||||||
|
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen()
|
||||||
|
return !state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enterFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen()
|
||||||
|
isFullscreen.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exitFullscreen() {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen()
|
||||||
|
isFullscreen.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { readable, derived } from 'svelte/store'
|
||||||
|
|
||||||
|
export type GamepadState = {
|
||||||
|
available: boolean
|
||||||
|
gamepads: Gamepad[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEADZONE = 0.15
|
||||||
|
const dz = (x: number) => {
|
||||||
|
const a = Math.abs(x)
|
||||||
|
if (a < DEADZONE) return 0
|
||||||
|
return ((a - DEADZONE) / (1 - DEADZONE)) * Math.sign(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
|
||||||
|
const update = () => {
|
||||||
|
const pads = navigator.getGamepads?.() ?? []
|
||||||
|
const list = Array.from(pads)
|
||||||
|
.map(p => p || null)
|
||||||
|
.filter(Boolean) as Gamepad[]
|
||||||
|
set({ available: 'getGamepads' in navigator, gamepads: list })
|
||||||
|
raf = requestAnimationFrame(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConnect = () => update()
|
||||||
|
const onDisconnect = () => update()
|
||||||
|
const onVis = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
running = false
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
} else if (!running) {
|
||||||
|
running = true
|
||||||
|
raf = requestAnimationFrame(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('gamepadconnected', onConnect)
|
||||||
|
window.addEventListener('gamepaddisconnected', onDisconnect)
|
||||||
|
document.addEventListener('visibilitychange', onVis)
|
||||||
|
|
||||||
|
running = true
|
||||||
|
raf = requestAnimationFrame(update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
running = false
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('gamepadconnected', onConnect)
|
||||||
|
window.removeEventListener('gamepaddisconnected', onDisconnect)
|
||||||
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const gamepad = derived(gamepads, s =>
|
||||||
|
s.available && s.gamepads.length ? s.gamepads[0] : null
|
||||||
|
)
|
||||||
|
|
||||||
|
export const hasGamepad = derived(gamepads, s => s.available && s.gamepads.length > 0)
|
||||||
|
|
||||||
|
export const gamepadAxes = derived(gamepad, g => (g ? g.axes.map(dz) : [0, 0, 0, 0]))
|
||||||
|
|
||||||
|
type ButtonEdge = { pressed: boolean; value: number; justPressed: boolean; justReleased: boolean }
|
||||||
|
const prev = new Map<number, { pressed: boolean; value: number }[]>()
|
||||||
|
|
||||||
|
export const gamepadButtons = derived(gamepad, g => g?.buttons ?? [])
|
||||||
|
|
||||||
|
export const gamepadButtonsEdges = derived(gamepad, g => {
|
||||||
|
if (!g) return [] as ButtonEdge[]
|
||||||
|
const p = prev.get(g.index) || []
|
||||||
|
const out = g.buttons.map((b, i): ButtonEdge => {
|
||||||
|
const pr = p[i] || { pressed: false, value: 0 }
|
||||||
|
const pressed = !!b.pressed || b.value > 0.5
|
||||||
|
return {
|
||||||
|
pressed,
|
||||||
|
value: b.value,
|
||||||
|
justPressed: pressed && !pr.pressed,
|
||||||
|
justReleased: !pressed && pr.pressed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
prev.set(
|
||||||
|
g.index,
|
||||||
|
out.map(x => ({ pressed: x.pressed, value: x.value }))
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { writable } from 'svelte/store'
|
||||||
|
import type { IMUMsg } from '$lib/types/models'
|
||||||
|
|
||||||
|
const maxIMUData = 100
|
||||||
|
|
||||||
|
export const imu = (() => {
|
||||||
|
const { subscribe, update } = writable({
|
||||||
|
x: [] as number[],
|
||||||
|
y: [] as number[],
|
||||||
|
z: [] as number[],
|
||||||
|
heading: [] as number[],
|
||||||
|
altitude: [] as number[],
|
||||||
|
pressure: [] as number[],
|
||||||
|
bmp_temp: [] as number[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const addData = (content: IMUMsg) => {
|
||||||
|
update(data => {
|
||||||
|
if (content.imu && content.imu[4]) {
|
||||||
|
data.x = [...data.x, content.imu[0]].slice(-maxIMUData)
|
||||||
|
data.y = [...data.y, content.imu[1]].slice(-maxIMUData)
|
||||||
|
data.z = [...data.z, content.imu[2]].slice(-maxIMUData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.mag && content.mag[4]) {
|
||||||
|
data.heading = [...data.heading, content.mag[3]].slice(-maxIMUData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.bmp && content.bmp[3]) {
|
||||||
|
data.pressure = [...data.pressure, content.bmp[0]].slice(-maxIMUData)
|
||||||
|
data.altitude = [...data.altitude, content.bmp[1]].slice(-maxIMUData)
|
||||||
|
data.bmp_temp = [...data.bmp_temp, content.bmp[2]].slice(-maxIMUData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, addData }
|
||||||
|
})()
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
export * from './socket-store';
|
export * from './socket-store'
|
||||||
export * from './logging-store';
|
export * from './logging-store'
|
||||||
export * from './model-store';
|
export * from './model-store'
|
||||||
|
export * from './socket'
|
||||||
|
export * from './fullscreen'
|
||||||
|
export * from './telemetry'
|
||||||
|
export * from './analytics'
|
||||||
|
export * from './featureFlags'
|
||||||
|
export * from './location-store'
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { persistentStore } from '$lib/utilities'
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
|
||||||
|
|
||||||
|
export const apiLocation =
|
||||||
|
PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '')
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { writable, type Writable } from 'svelte/store';
|
import { writable, type Writable } from 'svelte/store'
|
||||||
|
|
||||||
export interface errorLog {
|
export interface errorLog {
|
||||||
message: unknown;
|
message: unknown
|
||||||
tag?: string;
|
tag?: string
|
||||||
exception?: unknown;
|
exception?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export const latestErrorLog: Writable<errorLog> = writable();
|
export const latestErrorLog: Writable<errorLog> = writable()
|
||||||
|
|
||||||
export const errorLogs: Writable<errorLog[]> = writable([]);
|
export const errorLogs: Writable<errorLog[]> = writable([])
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
import type { ControllerInput } from '$lib/models';
|
import type { ControllerInput } from '$lib/types/models'
|
||||||
import { persistentStore } from '$lib/utilities';
|
import { persistentStore } from '$lib/utilities/svelte-utilities'
|
||||||
import { writable, type Writable } from 'svelte/store';
|
import { writable, type Writable } from 'svelte/store'
|
||||||
|
|
||||||
export const emulateModel = writable(true);
|
export const emulateModel = writable(true)
|
||||||
|
|
||||||
export const jointNames = persistentStore('joint_names', []);
|
export const jointNames = persistentStore('joint_names', <string[]>[])
|
||||||
|
|
||||||
export const model = writable();
|
export const model = writable()
|
||||||
|
|
||||||
export const modes = ['idle', 'rest', 'stand', 'walk'] as const;
|
export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'walk'] as const
|
||||||
|
|
||||||
export type Modes = (typeof modes)[number];
|
export type Modes = (typeof modes)[number]
|
||||||
|
|
||||||
export const mode: Writable<Modes> = writable('idle');
|
export enum ModesEnum {
|
||||||
|
Deactivated = 0,
|
||||||
|
Idle = 1,
|
||||||
|
Calibration = 2,
|
||||||
|
Rest = 3,
|
||||||
|
Stand = 4,
|
||||||
|
Walk = 5
|
||||||
|
}
|
||||||
|
|
||||||
export const outControllerData = writable(new Int8Array([0, 0, 0, 0, 0, 0, 70, 0]));
|
export enum WalkGaits {
|
||||||
|
Trot = 0,
|
||||||
|
Crawl = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walkGaits = ['trot', 'crawl'] as const
|
||||||
|
|
||||||
|
export const walkGaitLabels: Record<WalkGaits, string> = {
|
||||||
|
[WalkGaits.Trot]: 'Trot',
|
||||||
|
[WalkGaits.Crawl]: 'Crawl'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => {
|
||||||
|
return gait === WalkGaits.Trot ? 'trot' : 'crawl'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated)
|
||||||
|
|
||||||
|
export const walkGait: Writable<WalkGaits> = writable(WalkGaits.Trot)
|
||||||
|
|
||||||
|
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0])
|
||||||
|
|
||||||
|
export const kinematicData = writable([0, 0, 0, 0, 1, 0])
|
||||||
|
|
||||||
export const input: Writable<ControllerInput> = writable({
|
export const input: Writable<ControllerInput> = writable({
|
||||||
left: { x: 0, y: 0 },
|
left: { x: 0, y: 0 },
|
||||||
right: { x: 0, y: 0 },
|
right: { x: 0, y: 0 },
|
||||||
height: 70,
|
height: 0.5,
|
||||||
speed: 0
|
speed: 0.5,
|
||||||
});
|
s1: 0.05
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import { writable, type Writable } from 'svelte/store';
|
import { writable, type Writable } from 'svelte/store'
|
||||||
import { type angles } from '$lib/models';
|
import { type angles } from '$lib/types/models'
|
||||||
|
|
||||||
export const isConnected = writable(false);
|
export const servoAnglesOut: Writable<number[]> = writable([
|
||||||
export const servoAngles: Writable<angles> = writable(new Int16Array(12).fill(0));
|
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
|
||||||
export const logs = writable([] as string[]);
|
])
|
||||||
export const battery = writable({});
|
export const servoAngles: Writable<number[]> = writable([
|
||||||
export const mpu = writable({ heading: 0 });
|
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
|
||||||
export const distances = writable({});
|
])
|
||||||
export const settings = writable({});
|
export const logs = writable([] as string[])
|
||||||
export const systemInfo = writable({} as number);
|
export const mpu = writable({ heading: 0 })
|
||||||
|
export const sonar = writable([0, 0])
|
||||||
|
export const distances = writable({})
|
||||||
|
|
||||||
export interface socketDataCollection {
|
export interface socketDataCollection {
|
||||||
angles: Writable<angles>;
|
angles: Writable<angles>
|
||||||
logs: Writable<string[]>;
|
logs: Writable<string[]>
|
||||||
battery: Writable<unknown>;
|
mpu: Writable<unknown>
|
||||||
mpu: Writable<unknown>;
|
distances: Writable<unknown>
|
||||||
distances: Writable<unknown>;
|
|
||||||
settings: Writable<unknown>;
|
|
||||||
systemInfo: Writable<unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const socketData = {
|
export const socketData = {
|
||||||
angles: servoAngles,
|
angles: servoAngles,
|
||||||
logs,
|
logs,
|
||||||
battery,
|
mpu,
|
||||||
mpu,
|
distances
|
||||||
distances,
|
}
|
||||||
settings,
|
|
||||||
systemInfo
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { writable } from 'svelte/store'
|
||||||
|
import { encode, decode } from '@msgpack/msgpack'
|
||||||
|
|
||||||
|
const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const
|
||||||
|
type SocketEvent = (typeof socketEvents)[number]
|
||||||
|
|
||||||
|
type SocketMessage = [number, string?, unknown?]
|
||||||
|
|
||||||
|
let useBinary = false
|
||||||
|
|
||||||
|
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
|
||||||
|
useBinary = data instanceof ArrayBuffer
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (useBinary) {
|
||||||
|
return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage
|
||||||
|
}
|
||||||
|
return JSON.parse(data as string)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeMessage = (data: unknown) => {
|
||||||
|
try {
|
||||||
|
return useBinary ? encode(data) : JSON.stringify(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Could not encode data: ${data} - ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebSocket() {
|
||||||
|
const listeners = new Map<string, Set<(data?: unknown) => void>>()
|
||||||
|
const { subscribe, set } = writable(false)
|
||||||
|
const reconnectTimeoutTime = 5000
|
||||||
|
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
|
||||||
|
let reconnectTimeoutId: ReturnType<typeof setTimeout>
|
||||||
|
let ws: WebSocket
|
||||||
|
let socketUrl: string | URL
|
||||||
|
|
||||||
|
function init(url: string | URL) {
|
||||||
|
socketUrl = url
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(reason: SocketEvent, event?: Event) {
|
||||||
|
ws.close()
|
||||||
|
set(false)
|
||||||
|
clearTimeout(unresponsiveTimeoutId)
|
||||||
|
clearTimeout(reconnectTimeoutId)
|
||||||
|
listeners.get(reason)?.forEach(listener => listener(event))
|
||||||
|
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(socketUrl)
|
||||||
|
ws.binaryType = 'arraybuffer'
|
||||||
|
ws.onopen = ev => {
|
||||||
|
ping()
|
||||||
|
useBinary = true
|
||||||
|
ping()
|
||||||
|
set(true)
|
||||||
|
clearTimeout(reconnectTimeoutId)
|
||||||
|
listeners.get('open')?.forEach(listener => listener(ev))
|
||||||
|
for (const event of listeners.keys()) {
|
||||||
|
if (socketEvents.includes(event as SocketEvent)) continue
|
||||||
|
subscribeToEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onmessage = frame => {
|
||||||
|
resetUnresponsiveCheck()
|
||||||
|
const message = decodeMessage(frame.data)
|
||||||
|
if (!message) return
|
||||||
|
const [, event, payload = undefined] = message
|
||||||
|
if (event) listeners.get(event)?.forEach(listener => listener(payload))
|
||||||
|
}
|
||||||
|
ws.onerror = ev => disconnect('error', ev)
|
||||||
|
ws.onclose = ev => disconnect('close', ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribe(event: string, listener?: (data: unknown) => void) {
|
||||||
|
const eventListeners = listeners.get(event)
|
||||||
|
if (!eventListeners) return
|
||||||
|
|
||||||
|
if (!eventListeners.size) {
|
||||||
|
unsubscribeToEvent(event)
|
||||||
|
}
|
||||||
|
if (listener) {
|
||||||
|
eventListeners?.delete(listener)
|
||||||
|
} else {
|
||||||
|
listeners.delete(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUnresponsiveCheck() {
|
||||||
|
clearTimeout(unresponsiveTimeoutId)
|
||||||
|
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEvent(event: string, data: unknown) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
send([2, event, data])
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeToEvent(event: string) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
send([1, event])
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToEvent(event: string) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
send([0, event])
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(data: unknown) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
const serialized = encodeMessage(data)
|
||||||
|
if (!serialized) {
|
||||||
|
console.error('Could not serialize data:', data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.send(serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ping() {
|
||||||
|
const serialized = encodeMessage([4])
|
||||||
|
if (!serialized) {
|
||||||
|
console.error('Could not serialize message')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.send(serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
sendEvent,
|
||||||
|
init,
|
||||||
|
on: <T>(event: string, listener: (data: T) => void): (() => void) => {
|
||||||
|
let eventListeners = listeners.get(event)
|
||||||
|
if (!eventListeners) {
|
||||||
|
if (!socketEvents.includes(event as SocketEvent)) {
|
||||||
|
subscribeToEvent(event)
|
||||||
|
}
|
||||||
|
eventListeners = new Set()
|
||||||
|
listeners.set(event, eventListeners)
|
||||||
|
}
|
||||||
|
eventListeners.add(listener as (data: unknown) => void)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe(event, listener as (data: unknown) => void)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
off: <T>(event: string, listener?: (data: T) => void) => {
|
||||||
|
unsubscribe(event, listener as (data: unknown) => void)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const socket = createWebSocket()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user