From 8bf4abc3a469587b5382311b69ec1093574bf8e5 Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Fri, 10 Nov 2023 21:07:39 -0500 Subject: [PATCH] Scene Inspector (#11) This PR adds the bones of a user-facing scene inspector. ### Work Done To facilitate this work, the Bevy/frontend interface has been expanded to accommodate two-way communications: - The Bevy app can now dispatch JavaScript `CustomEvent`s via `wasm::dispatch_to_js(...)` - The frontend application can receive those events by listening for the given event type on the `window` object In its current state, the inspection system works by attaching the `Inspectable` marker component to any entities which should be exposed to the user. A Bevy system runs on `Update` to check for newly spawned or despawned entities with that component, and emits events to notify the frontend. The frontend listens for those events and updates its model of the scene accordingly, which is then displayed with a simple tree view. ### Future Enhancements There are many improvements that must be made to make this system actually useful, but this PR is being expedited to focus on migrating to Bevy 0.12. A shortlist of planned updates: - Identify and expose the "type" of entity (e.g. lights, cameras, meshes, skeletons, etc.) via a field on the `Inspectable` component - Highlight the currently selected entity in the frontend hierarchy - Ask the Bevy app to update the selection when the user selects an entry in the hierarchy - Add a separate UI panel to inspect and modify the selected entity's components via the frontend --- .cargo/config.toml | 2 + .vscode/settings.json | 3 + Cargo.lock | 58 ++-- framework/src/lib/decorators/on.ts | 4 +- package-lock.json | 334 ++++++++++------------ package.json | 4 +- studio-frontend/index.html | 1 + studio-frontend/src/app/app.element.scss | 35 +++ studio-frontend/src/app/app.element.ts | 91 +++++- studio-frontend/src/app/icon.element.ts | 2 + studio-frontend/src/app/tree.element.scss | 64 +++++ studio-frontend/src/app/tree.element.ts | 61 ++++ studio/Cargo.toml | 16 ++ studio/package.json | 16 ++ studio/project.json | 9 +- studio/scripts/build-web.mjs | 71 +++++ studio/scripts/copy-assets.mjs | 7 +- studio/scripts/handle-error.mjs | 33 +++ studio/scripts/run.mjs | 27 ++ studio/src/interaction/selection.rs | 48 +++- studio/src/interaction/transformation.rs | 4 +- studio/src/lib.rs | 7 +- studio/src/scene.rs | 278 ++++++++++++++++++ studio/src/viewport.rs | 2 +- studio/src/wasm.rs | 39 --- studio/src/wasm/conv.rs | 134 +++++++++ studio/src/wasm/mod.rs | 90 ++++++ utility/src/index.ts | 1 + utility/src/lib/exists.ts | 3 + utility/src/lib/types.ts | 5 + 30 files changed, 1178 insertions(+), 271 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 studio-frontend/src/app/tree.element.scss create mode 100644 studio-frontend/src/app/tree.element.ts create mode 100644 studio/package.json create mode 100644 studio/scripts/build-web.mjs create mode 100644 studio/scripts/handle-error.mjs create mode 100644 studio/scripts/run.mjs delete mode 100644 studio/src/wasm.rs create mode 100644 studio/src/wasm/conv.rs create mode 100644 studio/src/wasm/mod.rs create mode 100644 utility/src/lib/exists.ts 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> + ;