diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8467175 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/.vscode/settings.json b/.vscode/settings.json index 13f9f69..fa87916 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,4 +3,7 @@ "explorer.fileNesting.patterns": { "*.element.ts": "${capture}.element.scss, ${capture}.element.spec.ts", }, + "rust-analyzer.cargo.features": [ + "wasm" + ], } diff --git a/Cargo.lock b/Cargo.lock index 2b416bf..c8a666e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2429,9 +2429,9 @@ checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" [[package]] name = "js-sys" -version = "0.3.64" +version = "0.3.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" dependencies = [ "wasm-bindgen", ] @@ -3409,18 +3409,29 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.188" +version = "1.0.192" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" dependencies = [ "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.188" +name = "serde-wasm-bindgen" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +checksum = "17ba92964781421b6cef36bf0d7da26d201e96d84e1b10e7ae6ed416e516906d" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.192" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", @@ -3429,9 +3440,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.107" +version = "1.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" dependencies = [ "itoa", "ryu", @@ -3553,8 +3564,13 @@ dependencies = [ "bevy_rapier3d", "bitflags 2.4.0", "console_error_panic_hook", + "js-sys", "parking_lot", + "serde", + "serde-wasm-bindgen", + "serde_json", "wasm-bindgen", + "web-sys", ] [[package]] @@ -3904,9 +3920,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3914,9 +3930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" dependencies = [ "bumpalo", "log", @@ -3941,9 +3957,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3951,9 +3967,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" dependencies = [ "proc-macro2", "quote", @@ -3964,9 +3980,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" [[package]] name = "wayland-scanner" @@ -3981,9 +3997,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.64" +version = "0.3.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/framework/src/lib/decorators/on.ts b/framework/src/lib/decorators/on.ts index 364340a..e15ad57 100644 --- a/framework/src/lib/decorators/on.ts +++ b/framework/src/lib/decorators/on.ts @@ -29,7 +29,7 @@ export function on(eventSelector: string) { Object.defineProperty(proto, "connectedCallback", { value: function (this: HTMLElement) { target ??= this; - target.addEventListener(eventName, handler); + target.addEventListener(eventName, handler.bind(this)); connectedCb.call(this); }, configurable: true, @@ -39,7 +39,7 @@ export function on(eventSelector: string) { Object.defineProperty(proto, "disconnectedCallback", { value: function (this: HTMLElement) { target ??= this; - target.removeEventListener(eventName, handler); + target.removeEventListener(eventName, handler.bind(this)); disconnectedCb.call(this); }, configurable: true, diff --git a/package-lock.json b/package-lock.json index 8a35094..1dd5de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@swc/core": "~1.3.85", "@types/lodash": "^4.14.199", "@types/node": "18.7.1", + "@types/which": "^3.0.2", "@vitest/coverage-c8": "~0.32.0", "@vitest/ui": "~0.32.0", "chalk": "^5.3.0", @@ -30,6 +31,7 @@ "jsdom": "~22.1.0", "nx": "16.9.1", "prettier": "^2.6.2", + "rimraf": "^5.0.5", "sass": "^1.68.0", "swc-loader": "0.1.15", "typescript": "~5.1.3", @@ -38,7 +40,7 @@ "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2", "vitest": "~0.32.0", - "wasm-pack": "^0.12.1" + "which": "^4.0.0" } }, "node_modules/@ampproject/remapping": { @@ -3755,6 +3757,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.4.tgz", "integrity": "sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==" }, + "node_modules/@types/which": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.2.tgz", + "integrity": "sha512-UqCG7NjNyume6e+BHcFkOQOS8of/E18V2z/jTRkiD98YiiryYOFBVvPxqA/8PQCwkn7icKqz/hFflMIRN2HGhQ==", + "dev": true + }, "node_modules/@vitest/coverage-c8": { "version": "0.32.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-c8/-/coverage-c8-0.32.4.tgz", @@ -4426,29 +4434,6 @@ "node": ">=8" } }, - "node_modules/binary-install": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/binary-install/-/binary-install-1.1.0.tgz", - "integrity": "sha512-rkwNGW+3aQVSZoD0/o3mfPN6Yxh3Id0R/xzTVBVVpGNlVz8EGwusksxRlbk/A5iKTZt9zkMn3qIqmAt3vpfbzg==", - "dev": true, - "dependencies": { - "axios": "^0.26.1", - "rimraf": "^3.0.2", - "tar": "^6.1.11" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/binary-install/node_modules/axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.14.8" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4592,6 +4577,53 @@ "node": ">=8.0.0" } }, + "node_modules/c8/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/c8/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/c8/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/c8/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -4739,15 +4771,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -4937,6 +4960,27 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -5574,36 +5618,6 @@ "node": ">=14.14" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6141,10 +6155,13 @@ } }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "engines": { + "node": ">=16" + } }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", @@ -6738,37 +6755,6 @@ "node": ">=8" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -7678,52 +7664,23 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", "dev": true, "dependencies": { - "glob": "^7.1.3" + "glob": "^10.3.7" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rollup": { "version": "3.29.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", @@ -8183,23 +8140,6 @@ "node": ">=6" } }, - "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", - "dev": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -8216,24 +8156,6 @@ "node": ">=6" } }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/terser": { "version": "5.24.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", @@ -8387,6 +8309,53 @@ "node": ">=8.17.0" } }, + "node_modules/tmp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tmp/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -8984,19 +8953,6 @@ "node": ">=14" } }, - "node_modules/wasm-pack": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/wasm-pack/-/wasm-pack-0.12.1.tgz", - "integrity": "sha512-dIyKWUumPFsGohdndZjDXRFaokUT/kQS+SavbbiXVAvA/eN4riX5QNdB6AhXQx37zNxluxQkuixZUgJ8adKjOg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "binary-install": "^1.0.1" - }, - "bin": { - "wasm-pack": "run.js" - } - }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -9122,18 +9078,18 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "dev": true, "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/why-is-node-running": { diff --git a/package.json b/package.json index 355ce1e..3b75a37 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@swc/core": "~1.3.85", "@types/lodash": "^4.14.199", "@types/node": "18.7.1", + "@types/which": "^3.0.2", "@vitest/coverage-c8": "~0.32.0", "@vitest/ui": "~0.32.0", "chalk": "^5.3.0", @@ -20,6 +21,7 @@ "jsdom": "~22.1.0", "nx": "16.9.1", "prettier": "^2.6.2", + "rimraf": "^5.0.5", "sass": "^1.68.0", "swc-loader": "0.1.15", "typescript": "~5.1.3", @@ -28,7 +30,7 @@ "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2", "vitest": "~0.32.0", - "wasm-pack": "^0.12.1" + "which": "^4.0.0" }, "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", diff --git a/studio-frontend/index.html b/studio-frontend/index.html index 49ce5c6..009702c 100644 --- a/studio-frontend/index.html +++ b/studio-frontend/index.html @@ -14,6 +14,7 @@ + diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 1dec58c..cfaee13 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -96,3 +96,38 @@ top: 60px; right: 16px; } + +.hierarchy { + position: absolute; + top: 16px; + left: 16px; + overflow: auto; + + box-sizing: border-box; + width: 290px; + max-height: calc(100vh - 32px); + padding: 10px 15px { + inline-end: 30px; + } + + background: #0006; + backdrop-filter: blur(16px); + border: 8px solid transparent; + border-radius: 0.5rem; + + pointer-events: auto; + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + + &-thumb { + border-radius: 2px; + background: #FFF2; + + &:hover { + background: #FFF3; + } + } + } +} diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index a737a30..54e5ed1 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,16 +1,30 @@ import { on } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; -import { TransformSpace, TransformType, ViewportSize } from "@storyteller/studio"; +import { + type EntityDespawnEvent, + type EntityMultiSpawnEvent, + type EntitySelectEvent, + type EntitySpawnEvent, + TransformSpace, + TransformType, + ViewportSize, +} from "@storyteller/studio"; import { match } from "@storyteller/utility"; import { LitElement, html, unsafeCSS } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; import "./button.element"; import "./help.element"; import "./icon.element"; +import "./tree.element"; import styles from "./app.element.scss?inline"; +interface SceneObject extends Omit { + children: SceneObject[]; +} + @customElement("app-root") export class AppElement extends LitElement { static override styles = unsafeCSS(styles); @@ -20,6 +34,11 @@ export class AppElement extends LitElement { @state() transformSpace = TransformSpace.Local; @state() transformType = TransformType.Translate; + @state() sceneObjects: SceneObject[] = []; + @state() selection?: string | null; + + #sceneObjects: studio.SceneObject[] = []; + #entityMap = new Map(); #studioCanvas?: HTMLCanvasElement | null; override connectedCallback(): void { @@ -61,6 +80,34 @@ export class AppElement extends LitElement { })); } + @on("window:entity-select") + onEntitySelectionChange({ detail: entity }: EntitySelectEvent): void { + const sceneObject = this.#entityMap.get(entity!); + console.log("Entity selection changed:", sceneObject); + this.selection = entity; + } + + @on("window:entity-spawn") + onEntitySpawned({ detail: value }: EntitySpawnEvent): void { + console.log("Entity spawned:", value); + this.#sceneObjects = this.#sceneObjects.concat(value).sort(sortByName); + this.#updateHierarchy(); + } + + @on("window:entity-multi-spawn") + onEntitiesSpawned({ detail: values }: EntityMultiSpawnEvent): void { + console.log("Entities spawned:", values); + this.#sceneObjects = this.#sceneObjects.concat(values).sort(sortByName); + this.#updateHierarchy(); + } + + @on("window:entity-despawn") + onEntityDespawned({ detail: entity }: EntityDespawnEvent): void { + console.log("Entity despawned:", entity); + this.#sceneObjects = this.#sceneObjects.filter(({ id }) => id !== entity); + this.#updateHierarchy(); + } + toggleHelp(): void { this.showHelp = !this.showHelp; } @@ -99,6 +146,34 @@ export class AppElement extends LitElement { studio.setTransformType(this.transformType); } + // TODO: Get some help from someone who knows what an algorithm is + #updateHierarchy(): void { + this.#entityMap.clear(); + + for (let obj of this.#sceneObjects) + this.#entityMap.set(obj.id, { ...obj, children: [] }); + + for (let obj of this.#entityMap.values()) { + if (obj.parent != null) { + const parent = this.#entityMap.get(obj.parent); + if (!parent) + throw new Error(`Missing parent: ${obj.parent}`); + + parent.children.push(obj); + } + } + + for (let obj of this.#entityMap.values()) + obj.children.sort(sortByName); + + this.sceneObjects = Array + .from(this.#entityMap.values()) + .filter(obj => obj.parent == null) + .sort(sortByName); + + console.log("Hierarchy:", this.sceneObjects); + } + #initStudio(): HTMLCanvasElement | null { const width = window.innerWidth; const height = window.innerHeight; @@ -165,6 +240,16 @@ export class AppElement extends LitElement { +
+ ${this.sceneObjects.map(obj => html` + + `)} +
+ fasCircleInfo, + "caret-right": () => fasCaretRight, "cube": () => fasCube, "globe": () => fasGlobe, "up-down-left-right": () => fasUpDownLeftRight, diff --git a/studio-frontend/src/app/tree.element.scss b/studio-frontend/src/app/tree.element.scss new file mode 100644 index 0000000..800cf56 --- /dev/null +++ b/studio-frontend/src/app/tree.element.scss @@ -0,0 +1,64 @@ +:host, .children { + display: flex; + flex-direction: column; +} + +header { + display: flex; + padding-block: 5px; + + font: { + family: "Fira Sans", sans-serif; + size: 14px; + weight: 400; + } + line-height: 1; + white-space: nowrap; + + pointer-events: auto; + cursor: default; + user-select: none; + + color: #FFFC; + + &.selected { + font-weight: 500; + color: #FFF; + } +} + +.button { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 16px; + height: 24px; + margin-block: -5px; + padding-inline-start: 4px; + + appearance: none; + background: transparent; + border: none; + font: inherit; + + color: #FFFC; + + &:hover { + color: #FFF; + } +} + +.icon.expanded { + transform: rotate(90deg); +} + +.name { + padding-inline-start: 5px; +} + +.children { + margin-inline-start: 7px; + padding-inline-start: 9px; + border-inline-start: 1px solid #FFF2; +} diff --git a/studio-frontend/src/app/tree.element.ts b/studio-frontend/src/app/tree.element.ts new file mode 100644 index 0000000..237ed99 --- /dev/null +++ b/studio-frontend/src/app/tree.element.ts @@ -0,0 +1,61 @@ +import { LitElement, html, nothing, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +import "./icon.element"; + +import styles from "./tree.element.scss?inline"; + +export interface Tree { + id?: string; + name: string; + children?: Tree[]; +} + +@customElement("sts-tree") +export class TreeElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property() name = ""; + @property() treeChildren?: Tree[]; + @property() expanded = false; + + toggleExpanded(): void { + this.expanded = !this.expanded; + } + + // TODO: A11y + protected override render = () => html` +
+ ${this.treeChildren?.length ? html` + + ` : nothing} + + ${this.name} + +
+ + ${this.expanded && this.treeChildren?.length ? html` +
+ ${this.treeChildren?.map(tree => html` + + `)} +
+ ` : nothing} + `; +} diff --git a/studio/Cargo.toml b/studio/Cargo.toml index f1f4006..2324107 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -19,12 +19,28 @@ bevy_rapier3d = "0.22.0" # bevy_mod_outline = "0.5.0" bitflags = "2.4.0" console_error_panic_hook = { version = "0.1.7", optional = true } +js-sys = { version = "0.3.65", optional = true } parking_lot = "0.12.1" +serde = { version = "1.0.192", optional = true } +serde-wasm-bindgen = { version = "0.6.1", optional = true } +serde_json = { version = "1.0.108", optional = true } wasm-bindgen = "0.2.84" +web-sys = { version = "0.3.65", optional = true, features = [ + "CustomEvent", + "CustomEventInit", + "Document", + "HtmlElement", + "Window", +] } [features] wasm = [ "dep:console_error_panic_hook", + "dep:js-sys", + "dep:serde", + "dep:serde_json", + "dep:serde-wasm-bindgen", + "dep:web-sys", "bevy_rapier3d/wasm-bindgen", ] diff --git a/studio/package.json b/studio/package.json new file mode 100644 index 0000000..1e6e7db --- /dev/null +++ b/studio/package.json @@ -0,0 +1,16 @@ +{ + "name": "@storyteller/studio", + "version": "0.1.0", + "files": [ + "studio_bg.wasm", + "studio.js", + "studio_bg.js", + "studio.d.ts" + ], + "module": "studio.js", + "types": "studio.d.ts", + "sideEffects": [ + "./studio.js", + "./snippets/*" + ] +} diff --git a/studio/project.json b/studio/project.json index 1b3d0a0..7ec0755 100644 --- a/studio/project.json +++ b/studio/project.json @@ -23,13 +23,8 @@ "executor": "nx:run-commands", "inputs": ["assets"], "options": { - "commands": [ - "wasm-pack build --out-dir ../dist/studio/ -- --features wasm", - "node scripts/copy-assets.mjs" - ], - "cwd": "studio", - "envFile": "studio/.env", - "parallel": false + "command": "node scripts/build-web.mjs", + "cwd": "studio" } }, "dev": { diff --git a/studio/scripts/build-web.mjs b/studio/scripts/build-web.mjs new file mode 100644 index 0000000..bb65eea --- /dev/null +++ b/studio/scripts/build-web.mjs @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import url from "node:url"; +import path from "node:path"; +import { rimraf } from "rimraf"; +import which from "which"; + +import copyAssets from "./copy-assets.mjs"; +import handleError from "./handle-error.mjs"; +import run from "./run.mjs"; + +async function main() { + try { + const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + const workspaceRoot = path.resolve(__dirname, "../.."); + + /** @type import("node:child_process").StdioOptions */ + const stdio = ["ignore", "inherit", "inherit"]; + + const outDir = path.join(workspaceRoot, "dist/studio"); + + if (fs.existsSync(outDir)) + await rimraf(outDir); + + await run("cargo", [ + "build", + "-p", "studio", + "--lib", + "--release", + "--features", "wasm", + "--target", "wasm32-unknown-unknown", + ], { + shell: true, + cwd: workspaceRoot, + stdio, + }); + + const wasmBindgenInstalled = Boolean(await which("wasm-bindgen", { nothrow: true })); + if (!wasmBindgenInstalled) { + await run("cargo", [ + "install", "-f", "wasm-bindgen-cli", + "--version", "0.2.88", + ], { + shell: true, + cwd: workspaceRoot, + stdio, + }); + } + + await run("wasm-bindgen", [ + "--out-dir", "./dist/studio", + "--target", "bundler", + "./target/wasm32-unknown-unknown/release/studio.wasm", + ], { + shell: true, + cwd: workspaceRoot, + stdio, + }); + + await copyAssets(); + + await fs.promises.copyFile( + path.join(workspaceRoot, "studio/package.json"), + path.join(outDir, "package.json"), + ); + } + catch (err) { + handleError(err); + } +} + +main(); diff --git a/studio/scripts/copy-assets.mjs b/studio/scripts/copy-assets.mjs index 34e59ca..5afbf70 100644 --- a/studio/scripts/copy-assets.mjs +++ b/studio/scripts/copy-assets.mjs @@ -4,12 +4,13 @@ import fs from "node:fs"; import path from "node:path"; import url from "node:url"; -async function main() { +export default async function main() { const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const assetsSrc = path.resolve(__dirname, "../assets"); const assetsDest = path.resolve(__dirname, "../../dist/studio/public/assets"); - console.log(`\nCopying assets from ${assetsSrc} to ${assetsDest}`); + console.log(""); + console.log(`Copying assets from "${assetsSrc}" to "${assetsDest}"`); let count = 0; const start = performance.now(); @@ -76,5 +77,3 @@ function handleError(err) { process.exit(1); } } - -main(); diff --git a/studio/scripts/handle-error.mjs b/studio/scripts/handle-error.mjs new file mode 100644 index 0000000..1bd5291 --- /dev/null +++ b/studio/scripts/handle-error.mjs @@ -0,0 +1,33 @@ +import chalk from "chalk"; +import util from "node:util"; + +/** + * @param {any} err + * @returns {never} + */ +export default function (err) { + if (typeof err === "number") { + console.log(`${ + chalk.bold.redBright.inverse(" ERROR ") + } Child process exited with code ${err}`); + + process.exit(err); + } + + if (err != null && err instanceof Error) { + console.log(`${ + chalk.bold.redBright.inverse(" ERROR ") + } ${err.message}`); + + if (err.stack) + console.log(chalk.dim(err.stack)); + + process.exit(1); + } + + console.log(`${ + chalk.bold.redBright.inverse(" ERROR ") + } Child process threw an exception: ${util.inspect(err)}`); + + process.exit(1); +} diff --git a/studio/scripts/run.mjs b/studio/scripts/run.mjs new file mode 100644 index 0000000..91ffe08 --- /dev/null +++ b/studio/scripts/run.mjs @@ -0,0 +1,27 @@ +import chalk from "chalk"; +import cp from "node:child_process"; + +/** + * @param {string} command + * @param {readonly string[]} args + * @param {import("node:child_process").SpawnOptions} options + * + * @returns {Promise} + */ +export default function run(command, args, options) { + console.log(""); + console.log(chalk.dim(`> ${command} ${args.join(" ")}`)); + + return new Promise((resolve, reject) => { + cp.spawn(command, args, options) + .on("exit", code => { + if (code) reject(code) + else resolve(); + }) + .on("close", code => { + if (code) reject(code) + else resolve(); + }) + .on("error", reject); + }); +} diff --git a/studio/src/interaction/selection.rs b/studio/src/interaction/selection.rs index 5d6677e..2848720 100644 --- a/studio/src/interaction/selection.rs +++ b/studio/src/interaction/selection.rs @@ -15,6 +15,14 @@ impl Plugin for SelectionPlugin { PostUpdate, draw_selection_outline.run_if(resource_changed_or_removed::()), ); + + #[cfg(feature = "wasm")] + app.add_systems( + PostUpdate, + wasm::notify_entity_selection_change + .pipe(error) + .run_if(resource_changed_or_removed::()), + ); } } @@ -24,7 +32,7 @@ pub struct Selectable; #[derive(Component, Clone, Copy)] pub struct Selected; -#[derive(Resource, Deref, DerefMut)] +#[derive(Resource, Deref, DerefMut, Debug)] pub struct Selection(Entity); fn update_selection( @@ -105,3 +113,41 @@ fn draw_selection_outline( } } } + +#[cfg(feature = "wasm")] +mod wasm { + use bevy::{ + prelude::{Query, Res, With}, + window::{PrimaryWindow, Window}, + }; + use wasm_bindgen::prelude::*; + + use super::Selection; + use crate::wasm; + + #[wasm_bindgen(typescript_custom_section)] + const EVENT_DECLARATION: &str = r#" +/** + * An event indicating that an entity has been selected or that the selection + * has been cleared. + */ +export interface EntitySelectEvent extends CustomEvent { + /** The selected entity ID. If `null`, the selection has been cleared. */ + detail: string | null; +} + +declare global { + export interface HTMLElementEventMap { + "entity-select": EntitySelectEvent; + } +} + "#; + + pub(super) fn notify_entity_selection_change( + r_selection: Option>, + q_window: Query<&Window, With>, + ) -> Result<(), String> { + let entity = r_selection.map(|selection| **selection); + wasm::dispatch_to_js(q_window.single(), "entity-select", &entity) + } +} diff --git a/studio/src/interaction/transformation.rs b/studio/src/interaction/transformation.rs index 2f5f027..5295e1d 100644 --- a/studio/src/interaction/transformation.rs +++ b/studio/src/interaction/transformation.rs @@ -12,7 +12,7 @@ impl Plugin for TransformationPlugin { fn build(&self, app: &mut App) { app.insert_resource(TransformMode::default()); app.add_plugins(GizmoPlugin); - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "wasm")] app.add_systems(PreUpdate, wasm::update_transform_mode); } } @@ -83,7 +83,7 @@ impl fmt::Debug for TransformType { } } -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "wasm")] mod wasm { use bevy::prelude::*; use wasm_bindgen::prelude::*; diff --git a/studio/src/lib.rs b/studio/src/lib.rs index 7de541c..d7a06eb 100644 --- a/studio/src/lib.rs +++ b/studio/src/lib.rs @@ -24,7 +24,7 @@ mod skybox; mod ui; mod viewport; -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "wasm")] mod wasm; #[derive(Component)] @@ -32,12 +32,12 @@ pub(crate) struct MainCamera; #[wasm_bindgen] pub fn init(viewport_size: ViewportSize) { - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "wasm")] console_error_panic_hook::set_once(); let mut app = App::new(); - #[cfg(target_arch = "wasm32")] + #[cfg(feature = "wasm")] app.add_plugins(viewport::wasm::ViewportPlugin); app.add_plugins(( @@ -45,6 +45,7 @@ pub fn init(viewport_size: ViewportSize) { primary_window: Some(Window { resolution: viewport_size.into(), title: "Storyteller Studio".into(), + canvas: Some("#studio_canvas".into()), ..default() }), ..default() diff --git a/studio/src/scene.rs b/studio/src/scene.rs index ac37565..a31c510 100644 --- a/studio/src/scene.rs +++ b/studio/src/scene.rs @@ -39,6 +39,9 @@ impl Plugin for ScenePlugin { ), ) .add_systems(PostUpdate, move_plane_with_camera.after(CameraMotionSystem)); + + #[cfg(feature = "wasm")] + app.add_systems(PostUpdate, wasm::notify_entity_spawn_changes.pipe(error)); } } @@ -48,6 +51,9 @@ struct WorldGridPlane; #[derive(Resource, Deref, DerefMut)] struct NewGltf(pub Handle); +#[derive(Component)] +struct Inspectable; + fn initialize( mut cmd: Commands, assets: Res, @@ -61,6 +67,7 @@ fn initialize( cmd.insert_resource(NewGltf(assets.load("gltf/sample-room.gltf"))); cmd.spawn(( Name::new("Scene"), + Inspectable, SceneBundle { scene: assets.load("gltf/sample-room.gltf#Scene0"), ..default() @@ -99,6 +106,8 @@ fn initialize( // TODO: get Blender to export enough metadata to automate light placements cmd.spawn(( Name::new("Ceiling Light"), + Selectable, + Inspectable, PointLightBundle { point_light: PointLight { color: Color::hex("FFE5B4").unwrap(), @@ -115,6 +124,8 @@ fn initialize( // Monitor glow cmd.spawn(( Name::new("Monitor Glow"), + Selectable, + Inspectable, PointLightBundle { point_light: PointLight { color: Color::WHITE, @@ -202,6 +213,10 @@ fn mark_scene_objects( let gltf = ra_gltfs.get(&r_gltf).unwrap(); for (ent_parent, name, children) in q_parents.iter() { + if gltf.named_nodes.contains_key(&**name) { + cmd.entity(ent_parent).insert(Inspectable); + } + // See if this name matches any of the meshes or nodes in our glTF scene let Some(gltf_mesh) = gltf .named_meshes @@ -219,6 +234,9 @@ fn mark_scene_objects( continue; }; + // FIXME: These hashsets don't actually contain anything. Ever. -_- + // This has "worked" this whole time because {} == {} + // Collect all of the mesh handles in the glTF mesh let prims_from_gltf = gltf_mesh .primitives @@ -237,6 +255,7 @@ fn mark_scene_objects( }) .collect::>(); + // If the sets match, this is the entity that represents the glTF mesh if prims_from_gltf == prims_from_ecs { cmd.entity(ent_parent).insert(Selectable); @@ -277,3 +296,262 @@ impl Material for ProceduralGridMaterial { AlphaMode::Blend } } + +#[cfg(feature = "wasm")] +mod wasm { + use std::collections::VecDeque; + + use bevy::{prelude::*, utils::HashSet, window::PrimaryWindow}; + use serde::Serialize; + use wasm_bindgen::prelude::*; + + use crate::wasm::{self, AsJson}; + + use super::Inspectable; + + #[wasm_bindgen(typescript_custom_section)] + const TYPE_DECLARATIONS: &str = r#" +export interface SceneObject { + /** The entity ID. */ + id: string; + /** A user-friendly name for displaying in the UI */ + name: string; + /** The IDs of this object's children in the hierarchy */ + children: string[]; + /** The ID of this object's parent in the hierarchy, if it has one */ + parent: string | null; + /** The local-space transform */ + transform: Transform; + /** The world-space transform */ + globalTransform: GlobalTransform; +} + +/** + * An event indicating that a new entity has spawned. + */ +export interface EntitySpawnEvent extends CustomEvent { + type: "entity-spawn"; + detail: SceneObject; +} + +/** + * An event indicating that an entity has been de-spawned. + */ +export interface EntityDespawnEvent extends CustomEvent { + type: "entity-despawn"; + /** The entity ID */ + detail: string; +} + +/** + * An event indicating that multiple entities have spawned. + */ +export interface EntityMultiSpawnEvent extends CustomEvent { + type: "entity-multi-spawn"; + detail: SceneObject[]; +} + +declare global { + export interface HTMLElementEventMap { + "entity-spawn": EntitySpawnEvent; + "entity-despawn": EntityDespawnEvent; + "entity-multi-spawn": EntityMultiSpawnEvent; + } +} + "#; + + #[derive(Serialize)] + pub(super) struct SceneObject { + id: String, + name: String, + children: Vec, + parent: Option, + transform: serde_json::Value, + #[serde(rename(serialize = "globalTransform"))] + global_transform: serde_json::Value, + } + + struct SceneObjectBuilder<'a> { + id: Entity, + name: Option, + children: Option>, + parent: Option, + transform: Option<&'a Transform>, + global_transform: Option<&'a GlobalTransform>, + } + + impl<'a> SceneObjectBuilder<'a> { + fn new(id: Entity) -> Self { + Self { + id, + name: None, + children: None, + parent: None, + transform: None, + global_transform: None, + } + } + fn finish(self) -> SceneObject { + SceneObject { + id: format!("{:?}", self.id), + name: self.name.unwrap(), + children: self + .children + .map(|children| children.iter().map(|ent| format!("{ent:?}")).collect()) + .unwrap_or_default(), + parent: self.parent.map(|ent| format!("{ent:?}")), + transform: self.transform.unwrap().as_json(), + global_transform: self.global_transform.unwrap().as_json(), + } + } + fn with_name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + fn with_children(mut self, children: Vec) -> Self { + self.children = Some(children); + self + } + fn with_parent(mut self, parent: Entity) -> Self { + self.parent = Some(parent); + self + } + fn with_transform(mut self, transform: &'a Transform) -> Self { + self.transform = Some(transform); + self + } + fn with_global_transform(mut self, global_transform: &'a GlobalTransform) -> Self { + self.global_transform = Some(global_transform); + self + } + } + + impl<'a> SceneObject { + fn build(id: Entity) -> SceneObjectBuilder<'a> { + SceneObjectBuilder::new(id) + } + } + + pub(super) fn notify_entity_spawn_changes( + mut l_notified: Local>, + // Note: We use locals for these data structures purely to reuse + // existing allocations across multiple invocations of this system + mut l_new_objects: Local>, + mut l_despawned_entities: Local>, + mut l_missing_parents: Local>, + q_window: Query<&Window, With>, + q_inspectables: Query<(Entity, &Transform, &GlobalTransform), With>, + q_others: Query<(Entity, &Transform, &GlobalTransform), Without>, + q_names: Query<&Name>, + q_parents: Query<&Children>, + q_children: Query<&Parent>, + ) -> Result<(), String> { + l_missing_parents.clear(); + + l_new_objects.clear(); + l_new_objects.extend( + q_inspectables + .iter() + .filter_map(|(ent, xform, world_xform)| { + build_scene_object( + &mut l_notified, + &mut l_missing_parents, + ent, + xform, + world_xform, + &q_inspectables, + &q_names, + &q_parents, + &q_children, + ) + }), + ); + + while let Some(parent) = l_missing_parents.pop_front() { + let Ok((ent, xform, world_xform)) = q_others.get(parent) else { + error!("Failed to find missing parent: {parent:?}"); + l_notified.insert(parent); + + continue; + }; + + if let Some(object) = build_scene_object( + &mut l_notified, + &mut l_missing_parents, + ent, + xform, + world_xform, + &q_inspectables, + &q_names, + &q_parents, + &q_children, + ) { + l_new_objects.push(object); + } + } + + l_despawned_entities.clear(); + l_despawned_entities.extend( + l_notified + .iter() + .filter(|ent| !q_inspectables.contains(**ent) && !q_others.contains(**ent)) + .cloned(), + ); + + let win = q_window.single(); + + for ent in l_despawned_entities.iter() { + // TODO: Make this take an array like the multi-spawn event + wasm::dispatch_to_js(win, "entity-despawn", ent)?; + l_notified.remove(ent); + } + + if !l_new_objects.is_empty() { + // TODO: Remove the single-spawn event + wasm::dispatch_to_js(win, "entity-multi-spawn", &*l_new_objects)?; + } + + Ok(()) + } + + fn build_scene_object( + notified: &mut HashSet, + missing_parents: &mut VecDeque, + ent: Entity, + xform: &Transform, + world_xform: &GlobalTransform, + q_inspectables: &Query<(Entity, &Transform, &GlobalTransform), With>, + q_names: &Query<&Name>, + q_parents: &Query<&Children>, + q_children: &Query<&Parent>, + ) -> Option { + if notified.contains(&ent) { + return None; + } + + notified.insert(ent); + + let mut result = SceneObject::build(ent) + .with_transform(xform) + .with_global_transform(world_xform); + + if let Ok(children) = q_parents.get(ent) { + result = result.with_children(children.iter().cloned().collect()); + } + if let Ok(parent) = q_children.get(ent) { + let parent_ent = **parent; + result = result.with_parent(parent_ent); + + if !notified.contains(&parent_ent) && !q_inspectables.contains(parent_ent) { + missing_parents.push_back(parent_ent); + } + } + + result = result.with_name(match q_names.get(ent) { + Ok(name) => name.into(), + Err(_) => format!("{ent:?}"), + }); + + Some(result.finish()) + } +} diff --git a/studio/src/viewport.rs b/studio/src/viewport.rs index 5382755..b06f37a 100644 --- a/studio/src/viewport.rs +++ b/studio/src/viewport.rs @@ -45,7 +45,7 @@ impl fmt::Debug for ViewportSize { } } -#[cfg(target_arch = "wasm32")] +#[cfg(feature = "wasm")] pub mod wasm { use bevy::prelude::*; use wasm_bindgen::prelude::*; diff --git a/studio/src/wasm.rs b/studio/src/wasm.rs deleted file mode 100644 index 7e227a6..0000000 --- a/studio/src/wasm.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::{cell::RefCell, sync::OnceLock}; - -use parking_lot::ReentrantMutex; - -pub(crate) type WasmMessageBuffer = OnceLock>>>; - -macro_rules! get_buffer { - ($buffer:ident) => { - $buffer.get_or_init(|| { - ::parking_lot::ReentrantMutex::new(::std::cell::RefCell::new( - ::core::option::Option::None, - )) - }) - }; -} - -macro_rules! write_message { - ($buffer:ident, $expr:expr) => {{ - let buffer = crate::wasm::get_buffer!($buffer); - if let Some(lock) = buffer.try_lock() { - *lock.borrow_mut() = Some($expr); - } - }}; -} - -macro_rules! read_buffer { - ($buffer:ident, |$msg:ident| $stmt:stmt) => {{ - let buffer = crate::wasm::get_buffer!($buffer); - if let Some(lock) = buffer.try_lock() { - if let Some($msg) = lock.borrow_mut().take() { - $stmt - } - } - }}; -} - -pub(crate) use get_buffer; -pub(crate) use read_buffer; -pub(crate) use write_message; diff --git a/studio/src/wasm/conv.rs b/studio/src/wasm/conv.rs new file mode 100644 index 0000000..da30b98 --- /dev/null +++ b/studio/src/wasm/conv.rs @@ -0,0 +1,134 @@ +use bevy::prelude::{Entity, GlobalTransform, Quat, Transform, Vec3}; +use serde::ser::Serialize; +use serde_wasm_bindgen::Serializer; +use wasm_bindgen::prelude::*; + +pub type JsonValue = serde_json::Value; + +#[wasm_bindgen(typescript_custom_section)] +const BEVY_TYPES: &str = r#" +export interface BevyObject { + type: Tag; + value: T; +}; + +export type Vec3 = BevyObject<"Vec3", [number, number, number]>; +export type Quat = BevyObject<"Quat", [number, number, number, number]>; + +export interface ITransform { + translation: Vec3; + rotation: Quat; + scale: Vec3; +} +export type Transform = BevyObject<"Transform", ITransform>; +export type GlobalTransform = BevyObject<"GlobalTransform", ITransform>; +"#; + +pub(crate) const SERIALIZER: Serializer = Serializer::new() + .serialize_missing_as_null(true) + .serialize_maps_as_objects(true) + .serialize_large_number_types_as_bigints(true); + +pub trait AsJsValue { + fn as_js(&self) -> Result; +} + +pub trait AsJson { + fn as_json(&self) -> JsonValue; +} + +impl AsJson for Vec3 { + fn as_json(&self) -> JsonValue { + serde_json::json! {{ + "type": "Vec3", + "value": [self.x, self.y, self.z], + }} + } +} + +impl AsJson for Quat { + fn as_json(&self) -> JsonValue { + serde_json::json! {{ + "type": "Quat", + "value": [self.x, self.y, self.z, self.w], + }} + } +} + +impl AsJson for Transform { + fn as_json(&self) -> JsonValue { + serde_json::json! {{ + "type": "Transform", + "value": { + "translation": self.translation.as_json(), + "rotation": self.rotation.as_json(), + "scale": self.scale.as_json(), + }, + }} + } +} + +impl AsJson for GlobalTransform { + fn as_json(&self) -> JsonValue { + let (scale, rotation, translation) = self.to_scale_rotation_translation(); + + serde_json::json! {{ + "type": "GlobalTransform", + "value": { + "translation": translation.as_json(), + "rotation": rotation.as_json(), + "scale": scale.as_json(), + }, + }} + } +} + +impl AsJsValue for T +where + T: AsJson, +{ + fn as_js(&self) -> Result { + self.as_json() + .serialize(&SERIALIZER) + .map_err(|err| format!("{err}")) + } +} + +impl AsJsValue for Vec +where + T: Serialize, +{ + fn as_js(&self) -> Result { + self.serialize(&SERIALIZER).map_err(|err| format!("{err}")) + } +} + +impl AsJsValue for JsonValue { + fn as_js(&self) -> Result { + self.serialize(&SERIALIZER).map_err(|err| format!("{err}")) + } +} + +impl AsJsValue for Entity { + fn as_js(&self) -> Result { + Ok(JsValue::from_str(&format!("{self:?}"))) + } +} + +impl AsJsValue for String { + fn as_js(&self) -> Result { + Ok(JsValue::from_str(self)) + } +} + +impl AsJsValue for Option +where + T: AsJsValue, +{ + fn as_js(&self) -> Result { + match self.as_ref() { + Some(value) => Ok(value.as_js()?), + None => Ok(JsValue::NULL), + } + } +} diff --git a/studio/src/wasm/mod.rs b/studio/src/wasm/mod.rs new file mode 100644 index 0000000..fc3f8b0 --- /dev/null +++ b/studio/src/wasm/mod.rs @@ -0,0 +1,90 @@ +use std::{cell::RefCell, sync::OnceLock}; + +use bevy::window::Window; +use parking_lot::ReentrantMutex; +use web_sys::{CustomEvent, CustomEventInit, Element}; + +mod conv; + +pub use conv::*; + +pub(crate) type WasmMessageBuffer = OnceLock>>>; + +pub(crate) fn dispatch_to_js( + window: &Window, + event_type: &str, + // NOTE: We use dynamic dispatch here to keep the wasm binary small(-ish) + value: &dyn AsJsValue, +) -> Result<(), String> { + let canvas = try_get_canvas(window)?; + + let detail = value.as_js()?; + let event = CustomEvent::new_with_event_init_dict( + event_type, + CustomEventInit::new() + .bubbles(true) + .cancelable(true) + .detail(&detail), + ) + .map_err(|err| format!("{err:?}"))?; + + canvas + .dispatch_event(&event) + .map_err(|err| format!("{err:?}"))?; + + Ok(()) +} + +fn try_get_canvas(window: &Window) -> Result { + let canvas_selector = window + .canvas + .as_ref() + .ok_or("Canvas selector not set on Bevy Window".to_string())?; + + let window = web_sys::window().ok_or("web_sys::window() returned None".to_string())?; + let document = window + .document() + .ok_or("window.document() returned None".to_string())?; + + match document.query_selector(canvas_selector) { + Ok(Some(canvas)) => Ok(canvas), + Ok(None) => Err(format!( + "Failed to find element with selector '{canvas_selector}'" + )), + Err(err) => Err(format!("JavaScript threw an Error: {err:?}")), + } +} + +macro_rules! get_buffer { + ($buffer:ident) => { + $buffer.get_or_init(|| { + ::parking_lot::ReentrantMutex::new(::std::cell::RefCell::new( + ::core::option::Option::None, + )) + }) + }; +} + +macro_rules! write_message { + ($buffer:ident, $expr:expr) => {{ + let buffer = crate::wasm::get_buffer!($buffer); + if let Some(lock) = buffer.try_lock() { + *lock.borrow_mut() = Some($expr); + } + }}; +} + +macro_rules! read_buffer { + ($buffer:ident, |$msg:ident| $stmt:stmt) => {{ + let buffer = crate::wasm::get_buffer!($buffer); + if let Some(lock) = buffer.try_lock() { + if let Some($msg) = lock.borrow_mut().take() { + $stmt + } + } + }}; +} + +pub(crate) use get_buffer; +pub(crate) use read_buffer; +pub(crate) use write_message; diff --git a/utility/src/index.ts b/utility/src/index.ts index ac86eb2..1c552cf 100644 --- a/utility/src/index.ts +++ b/utility/src/index.ts @@ -1,2 +1,3 @@ +export * from "./lib/exists"; export * from "./lib/match"; export * from "./lib/types"; diff --git a/utility/src/lib/exists.ts b/utility/src/lib/exists.ts new file mode 100644 index 0000000..b164fde --- /dev/null +++ b/utility/src/lib/exists.ts @@ -0,0 +1,3 @@ +export function exists(value: T): value is Exclude { + return value != null; +} diff --git a/utility/src/lib/types.ts b/utility/src/lib/types.ts index e95bcc3..34b592b 100644 --- a/utility/src/lib/types.ts +++ b/utility/src/lib/types.ts @@ -1,3 +1,8 @@ export interface Fn { (...args: Args): R; } + +export type WithOpt + = Omit + & Partial> + ;