mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +00:00
Merge branch 'main' into add-inspector-v2
This commit is contained in:
@@ -15,7 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.76.0
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -35,7 +37,9 @@ jobs:
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-lint-and-test-${{ hashFiles('**/Cargo.toml') }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.76
|
||||
with:
|
||||
components: clippy
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
Vendored
+1
-3
@@ -4,9 +4,7 @@
|
||||
"*.element.ts": "${capture}.element.scss, ${capture}.element.spec.ts",
|
||||
"*.tsx": "${capture}.module.scss, ${capture}.spec.tsx",
|
||||
},
|
||||
"rust-analyzer.cargo.features": [
|
||||
"wasm"
|
||||
],
|
||||
"rust-analyzer.cargo.features": ["wasm"],
|
||||
"files.associations": {
|
||||
"*.meta": "ron"
|
||||
}
|
||||
|
||||
Generated
+447
-38
@@ -431,7 +431,7 @@ checksum = "5ec77479f56958ed1b2802c3129ebc704d12245627b2d4dae61f11e6ed5c21b1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -531,7 +531,7 @@ dependencies = [
|
||||
"bevy_macro_utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -606,7 +606,7 @@ checksum = "f484318350462c58ba3942a45a656c1fd6b6e484a6b6b7abc3a787ad1a51e500"
|
||||
dependencies = [
|
||||
"bevy_macro_utils",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -664,7 +664,7 @@ dependencies = [
|
||||
"bevy_macro_utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -720,7 +720,7 @@ checksum = "c6a13f388de6f4e809c15130c3251f60921ab4d0db37cc9ccce36f2296eeb73d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -882,7 +882,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc-hash",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
"toml_edit 0.20.7",
|
||||
]
|
||||
|
||||
@@ -1184,7 +1184,7 @@ dependencies = [
|
||||
"bevy_macro_utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
"bevy_macro_utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1413,7 +1413,7 @@ checksum = "7aafecc952b6b8eb1a93c12590bd867d25df2f4ae1033a01dfdfc3c35ebccfff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1483,7 +1483,7 @@ dependencies = [
|
||||
"fxhash",
|
||||
"glam",
|
||||
"indexmap 2.0.0",
|
||||
"itertools",
|
||||
"itertools 0.11.0",
|
||||
"nalgebra",
|
||||
"parry3d",
|
||||
]
|
||||
@@ -1495,7 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e1ef1d5e328abe1b76df974245f78e17fd17867583883d5e77444c6a8223a64"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1515,7 +1515,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1660,7 +1660,7 @@ checksum = "965ab7eb5f8f97d2a083c799f3a1b994fc397b2fe2da5d1da1626ce15a39f2b1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1760,7 +1760,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2063,7 +2063,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"socket2",
|
||||
"socket2 0.4.9",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -2235,7 +2235,7 @@ checksum = "3fe2568f851fd6144a45fa91cfed8fe5ca8fc0b56ba6797bfc1ed2771b90e37c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2277,6 +2277,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "2.3.1"
|
||||
@@ -2400,7 +2410,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2525,7 +2535,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2689,7 +2699,7 @@ dependencies = [
|
||||
"inflections",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2783,6 +2793,25 @@ dependencies = [
|
||||
"svg_fmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http",
|
||||
"indexmap 2.0.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.3.1"
|
||||
@@ -2867,6 +2896,66 @@ dependencies = [
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.4",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.4.0"
|
||||
@@ -2955,6 +3044,12 @@ dependencies = [
|
||||
"mach2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
|
||||
|
||||
[[package]]
|
||||
name = "isahc"
|
||||
version = "1.7.2"
|
||||
@@ -2991,6 +3086,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.9"
|
||||
@@ -3210,6 +3314,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.10"
|
||||
@@ -3411,6 +3521,24 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.7.0"
|
||||
@@ -3607,7 +3735,7 @@ dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3722,6 +3850,32 @@ version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
|
||||
dependencies = [
|
||||
"bitflags 2.4.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.5"
|
||||
@@ -3730,9 +3884,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.99"
|
||||
version = "0.9.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae"
|
||||
checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3746,7 +3900,7 @@ version = "0.3.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8378ac0dfbd4e7895f2d2c1f1345cab3836910baf3a300b000d04250f0c8428f"
|
||||
dependencies = [
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.3.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3788,7 +3942,7 @@ checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.3.5",
|
||||
"smallvec",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
@@ -3859,7 +4013,7 @@ checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3971,9 +4125,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.67"
|
||||
version = "1.0.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328"
|
||||
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -3995,9 +4149,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.33"
|
||||
version = "1.0.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
|
||||
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -4088,6 +4242,15 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.9.5"
|
||||
@@ -4138,6 +4301,48 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "216080ab382b992234dda86873c18d4c48358f5cfcb70fd693d7f6f2131b628b"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.11.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251"
|
||||
dependencies = [
|
||||
"base64 0.21.4",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "robust"
|
||||
version = "1.1.0"
|
||||
@@ -4187,6 +4392,28 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3"
|
||||
dependencies = [
|
||||
"bitflags 2.4.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||
dependencies = [
|
||||
"base64 0.21.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.4.0"
|
||||
@@ -4237,6 +4464,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation-sys 0.8.4",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
|
||||
dependencies = [
|
||||
"core-foundation-sys 0.8.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.21"
|
||||
@@ -4271,7 +4521,7 @@ checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4285,6 +4535,29 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.4"
|
||||
@@ -4376,6 +4649,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "space_bevy_xpbd_plugin"
|
||||
version = "0.3.1"
|
||||
@@ -4541,11 +4824,14 @@ dependencies = [
|
||||
"futures-intrusive",
|
||||
"futures-lite 2.2.0",
|
||||
"image",
|
||||
"itertools 0.12.1",
|
||||
"js-sys",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"space_editor",
|
||||
"studio-bvh",
|
||||
"wasm-bindgen",
|
||||
@@ -4582,15 +4868,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.37"
|
||||
version = "2.0.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8"
|
||||
checksum = "6ab617d94515e94ae53b8406c628598680aa0c9587474ecbe58188f7b345d66c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.29.10"
|
||||
@@ -4605,6 +4897,27 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
|
||||
dependencies = [
|
||||
"core-foundation-sys 0.8.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.3.13"
|
||||
@@ -4623,6 +4936,19 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand 2.0.1",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.3.0"
|
||||
@@ -4669,7 +4995,7 @@ checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4708,6 +5034,45 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.4",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.5"
|
||||
@@ -4736,6 +5101,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.37"
|
||||
@@ -4757,7 +5128,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4820,6 +5191,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.19.2"
|
||||
@@ -4948,6 +5325,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.0+wasi-snapshot-preview1"
|
||||
@@ -4975,7 +5361,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -5009,7 +5395,7 @@ checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -5020,6 +5406,19 @@ version = "0.2.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.29.5"
|
||||
@@ -5509,7 +5908,7 @@ dependencies = [
|
||||
"orbclient",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.3.5",
|
||||
"wasm-bindgen",
|
||||
"wayland-scanner",
|
||||
"web-sys",
|
||||
@@ -5526,6 +5925,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.2.0"
|
||||
@@ -5594,7 +6003,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.37",
|
||||
"syn 2.0.51",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Install build dependencies
|
||||
rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Emit versions
|
||||
rustup show active-toolchain
|
||||
npm version
|
||||
|
||||
# Install project dependencies
|
||||
npm ci
|
||||
|
||||
# Build projects
|
||||
#npx nx run studio:build:image-generator
|
||||
npx nx run studio:build:web
|
||||
npx nx run image-generator-demo:build:production
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Install dependencies
|
||||
# Install build dependencies
|
||||
rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Emit versions
|
||||
rustup show active-toolchain
|
||||
npm version
|
||||
|
||||
# Install project dependencies
|
||||
npm ci
|
||||
|
||||
# Build projects
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"space_editor_core::hotkeys::HotkeySet<space_editor_ui::tools::gizmo::GizmoHotkey>": "{\"space_editor_core::hotkeys::HotkeySet<space_editor_ui::tools::gizmo::GizmoHotkey>\":(bindings:{Rotate:[R],Translate:[G],Scale:[S],Multiple:[ShiftLeft],Clone:[AltLeft],Delete:[X]},name:\"GizmoHotkey\")}",
|
||||
"space_undo::ChangeChainSettings": "{\"space_undo::ChangeChainSettings\":(max_change_chain_size:200)}",
|
||||
"space_persistence::PersistenceSettings": "{\"space_persistence::PersistenceSettings\":(load_on_startup:true,save_on_close:true)}",
|
||||
"space_editor_core::hotkeys::HotkeySet<space_editor_ui::tools::gizmo::GizmoHotkey>": "{\"space_editor_core::hotkeys::HotkeySet<space_editor_ui::tools::gizmo::GizmoHotkey>\":(bindings:{Rotate:[R],Translate:[G],Scale:[S],Multiple:[ShiftLeft],Clone:[AltLeft],Delete:[X]},name:\"GizmoHotkey\")}",
|
||||
"space_editor_ui::settings::NewWindowSettings": "{\"space_editor_ui::settings::NewWindowSettings\":(new_tab:SameNode)}",
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./lib/decorators";
|
||||
export * from "./lib/di";
|
||||
export { default as spread } from "./lib/directives/spread";
|
||||
export { default as drag, type Props as DragProps } from "./lib/directives/drag";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Fn } from "@storyteller/utility";
|
||||
import { LitElement } from "lit";
|
||||
import { debounce as _debounce, type DebounceSettings } from "lodash";
|
||||
|
||||
import { getMethodDescriptor } from "./internal";
|
||||
|
||||
export function debounce(wait: number, options?: DebounceSettings) {
|
||||
return <T extends LitElement>(proto: T, propName: keyof T, desc: PropertyDescriptor) => {
|
||||
const method = (desc?.value as Fn<any[], any>)
|
||||
?? getMethodDescriptor(proto, propName)?.value
|
||||
?? (() => {});
|
||||
|
||||
const updated: PropertyDescriptor = {
|
||||
value: _debounce(method, wait, options),
|
||||
}
|
||||
|
||||
Object.defineProperty(proto, propName, updated);
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./debounce";
|
||||
export * from "./on";
|
||||
|
||||
@@ -65,7 +65,7 @@ export class ProviderRemoval<T> extends CustomEvent<Provider<T>> {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
[DIEvent.InjectionRequest]: InjectionRequest<any>;
|
||||
[DIEvent.DependencyProvision]: DependencyProvision<any>;
|
||||
[DIEvent.ProviderRemoval]: ProviderRemoval<any>;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ElementPart, noChange, nothing } from "lit";
|
||||
import { directive, Directive } from "lit/directive.js";
|
||||
|
||||
export interface Props {
|
||||
button?: number;
|
||||
start?(event: PointerEvent): void;
|
||||
move?(event: PointerEvent): void;
|
||||
end?(event: PointerEvent): void;
|
||||
}
|
||||
|
||||
class Drag extends Directive {
|
||||
#initialized = false;
|
||||
#pointerButton = 0;
|
||||
|
||||
#startHandler?: (event: PointerEvent) => void;
|
||||
#moveHandler?: (event: PointerEvent) => void;
|
||||
|
||||
#lastProps: Props = {};
|
||||
|
||||
override render(_: Props) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
override update(part: ElementPart, [props]: [Props]) {
|
||||
const element = part.element as HTMLElement;
|
||||
const host = part.options?.host;
|
||||
|
||||
// Disable the default drag-event handlers, which can interfere with this directive
|
||||
if (!this.#initialized) {
|
||||
element.addEventListener("dragstart", event => event.preventDefault());
|
||||
element.addEventListener("drag", event => event.preventDefault());
|
||||
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
// Return early if there are no changes in the passed props
|
||||
if (
|
||||
this.#lastProps.button === props.button
|
||||
&& this.#lastProps.start === props.start
|
||||
&& this.#lastProps.move === props.move
|
||||
&& this.#lastProps.end === props.end
|
||||
) {
|
||||
return noChange;
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// This makes it impossible to replace the move handler on an element with
|
||||
// a different one on subsequent renders, but this may be the desired
|
||||
// behavior? This allows the move handler to be bound as a lambda that
|
||||
// will keep firing even after the DOM re-renders. See
|
||||
// studio-frontend/src/app/anim-timeline.element.ts for the use case.
|
||||
if (this.#moveHandler != null)
|
||||
return noChange;
|
||||
|
||||
this.#lastProps = { ...props };
|
||||
|
||||
// Clean up previous event handlers
|
||||
if (this.#startHandler)
|
||||
element.removeEventListener("pointerdown", this.#startHandler);
|
||||
|
||||
if (this.#moveHandler)
|
||||
// TODO: This statement is unreachable due to the early-return above
|
||||
window.removeEventListener("pointermove", this.#moveHandler);
|
||||
|
||||
// Set the pointer button which should activate dragging
|
||||
this.#pointerButton = (props.button != null) ? props.button : 0;
|
||||
|
||||
// Main event-handling behavior
|
||||
//
|
||||
// It's important to not bind any `window` events unconditionally here,
|
||||
// because those handlers could easily outlive the element this directive
|
||||
// is attached to (the "host" element), which would cause a memory leak.
|
||||
//
|
||||
// Instead, we add a single `pointerdown` listener to the host element,
|
||||
// and inside that listener, we add the `pointermove` listener to the
|
||||
// window. By removing the `pointermove` listener on `pointerup`, we
|
||||
// shouldn't have any long-lived event listeners holding (potentially
|
||||
// stale) references to the host element.
|
||||
|
||||
this.#moveHandler = (event: PointerEvent) => {
|
||||
props.move?.call(host ?? event, event);
|
||||
}
|
||||
|
||||
this.#startHandler = (event: PointerEvent) => {
|
||||
if (event.button !== this.#pointerButton)
|
||||
return;
|
||||
|
||||
window.addEventListener("pointermove", this.#moveHandler!);
|
||||
|
||||
window.addEventListener("pointerup", event => {
|
||||
props.end?.call(host ?? event, event);
|
||||
window.removeEventListener("pointermove", this.#moveHandler!);
|
||||
}, {
|
||||
once: true,
|
||||
});
|
||||
|
||||
props.start?.call(host ?? event, event);
|
||||
}
|
||||
|
||||
element.addEventListener("pointerdown", this.#startHandler);
|
||||
|
||||
return noChange;
|
||||
}
|
||||
}
|
||||
|
||||
export default directive(Drag);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript"
|
||||
},
|
||||
"target": "ES2020"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Image Generator Demo</title>
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/src/styles.scss" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "image-generator-demo",
|
||||
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "image-generator-demo/src",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/image-generator-demo"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"mode": "development"
|
||||
},
|
||||
"production": {
|
||||
"mode": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/vite:dev-server",
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": ["studio"],
|
||||
"target": "build:web"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"buildTarget": "image-generator-demo:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "image-generator-demo:build:development",
|
||||
"hmr": true
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "image-generator-demo:build:production",
|
||||
"hmr": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"executor": "@nx/vite:preview-server",
|
||||
"defaultConfiguration": "production",
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": ["studio"],
|
||||
"target": "build:web"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"buildTarget": "image-generator-demo:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "image-generator-demo:build:development"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "image-generator-demo:build:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,182 @@
|
||||
.app-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
background: #242433;
|
||||
}
|
||||
|
||||
// Layout utilities
|
||||
.v-start, .v-end, .h-start, .h-end, .h-center {
|
||||
position: absolute;
|
||||
}
|
||||
.v-start { top: 1rem; }
|
||||
.v-end { bottom: 1rem; }
|
||||
.h-start { left: 1rem; }
|
||||
.h-end { right: 1rem; }
|
||||
.h-center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.hierarchy-wrapper {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: 100vh;
|
||||
}
|
||||
.hierarchy {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-inline-end: none;
|
||||
background: transparent;
|
||||
}
|
||||
.hierarchy-resize-handle {
|
||||
width: 0.5rem;
|
||||
cursor: ew-resize;
|
||||
background: transparent;
|
||||
transition: background 66.666ms linear;
|
||||
|
||||
&:hover, &.resizing {
|
||||
background: #39394c;
|
||||
}
|
||||
}
|
||||
|
||||
.split-screen {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.studio-scene,
|
||||
.image-capture {
|
||||
flex: 0 0 50%;
|
||||
}
|
||||
.studio-scene {
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-capture {
|
||||
width: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.studio-scene.resizing::part(canvas) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.config {
|
||||
padding: 1rem;
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.config-prompts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.config-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
color: #FFFC;
|
||||
transition: color 0.1s linear;
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
size: 15px;
|
||||
weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #FFFF;
|
||||
}
|
||||
}
|
||||
|
||||
.config-field {
|
||||
background: #0004;
|
||||
border: 1px solid #FFF2;
|
||||
color: #FFFC;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
size: 1rem;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #FFF6;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
border-color: #FFF4;
|
||||
background: #0006;
|
||||
color: #FFFF;
|
||||
}
|
||||
|
||||
&.seed {
|
||||
width: 96px;
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.config-field-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
|
||||
.pre-icon {
|
||||
&.red {
|
||||
color: rgb(220, 53, 69);
|
||||
font-size: 1.333rem;
|
||||
}
|
||||
&.green {
|
||||
color: rgb(25, 135, 84);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
width: 768px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.seed-refresh {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0.5rem;
|
||||
bottom: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #FFFC;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #222;
|
||||
color: #FFF;
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
weight: 700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { debounce, drag, on } from "@storyteller/framework";
|
||||
import * as studio from "@storyteller/studio";
|
||||
import {
|
||||
SceneState,
|
||||
type SceneStateEvent,
|
||||
type ScreenshotReadyEvent,
|
||||
StudioMode,
|
||||
} from "@storyteller/studio";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, type PropertyValues, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
import { ImageInfo, LcmClient } from "./lcm-client";
|
||||
|
||||
import "@storyteller/studio-web/canvas-provider";
|
||||
import "@storyteller/studio-web/scene-hierarchy";
|
||||
import "@storyteller/studio-web/studio";
|
||||
import "@storyteller/studio-web/transform-toolbar";
|
||||
|
||||
import "./app.element.scss"
|
||||
|
||||
@customElement("app-root")
|
||||
export class AppElement extends LitElement {
|
||||
@state() hierarchyWidth = 256;
|
||||
@state() resizing = false;
|
||||
|
||||
@state() loading = true;
|
||||
|
||||
@state() blob?: Blob;
|
||||
@state() prompt = "";
|
||||
@state() negPrompt = "";
|
||||
@state() seed = randomSeed();
|
||||
|
||||
@state() inputUrl?: string;
|
||||
@state() outputUrl?: string;
|
||||
|
||||
#lcmClient = new LcmClient(crypto.randomUUID());
|
||||
#outputImageRef = createRef<HTMLImageElement>();
|
||||
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
this.#lcmClient.addEventListener("lcm-image-ready", event => {
|
||||
console.log("lcm-image-ready", event.detail);
|
||||
|
||||
const prevUrl = this.outputUrl;
|
||||
this.outputUrl = URL.createObjectURL(event.detail);
|
||||
|
||||
if (prevUrl)
|
||||
URL.revokeObjectURL(prevUrl);
|
||||
});
|
||||
|
||||
this.#lcmClient.addEventListener("lcm-image-url-ready", event => {
|
||||
console.log("lcm-image-url-ready", event.detail);
|
||||
this.outputUrl = event.detail;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
protected override createRenderRoot = () => this;
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
if (this.inputUrl)
|
||||
URL.revokeObjectURL(this.inputUrl);
|
||||
|
||||
if (this.outputUrl)
|
||||
URL.revokeObjectURL(this.outputUrl);
|
||||
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override willUpdate(changes: PropertyValues<this>): void {
|
||||
let updates: Partial<ImageInfo> = {};
|
||||
|
||||
if (changes.has("blob"))
|
||||
updates.blob = this.blob;
|
||||
|
||||
if (changes.has("prompt"))
|
||||
updates.prompt = this.prompt;
|
||||
|
||||
if (changes.has("negPrompt"))
|
||||
updates.negativePrompt = this.negPrompt;
|
||||
|
||||
if (changes.has("seed"))
|
||||
updates.seed = this.seed;
|
||||
|
||||
if (
|
||||
changes.has("blob")
|
||||
|| changes.has("prompt")
|
||||
|| changes.has("negPrompt")
|
||||
|| changes.has("seed")
|
||||
) {
|
||||
this.#lcmClient.update(updates);
|
||||
}
|
||||
|
||||
super.willUpdate(changes);
|
||||
}
|
||||
|
||||
@on("scene-state")
|
||||
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
||||
console.log("scene state change:", SceneState[state]);
|
||||
|
||||
this.loading = match (state, {
|
||||
[SceneState.Active]: () => false,
|
||||
_: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
@on("entity-changes")
|
||||
@debounce(1000 / 24)
|
||||
onStudioChanges(): void {
|
||||
studio.requestScreenshot();
|
||||
}
|
||||
|
||||
@on("viewport-resized")
|
||||
// This handler requires a longer debounce to avoid crashing the render piptline
|
||||
@debounce(1000 / 5)
|
||||
onViewportResized(): void {
|
||||
studio.requestScreenshot();
|
||||
}
|
||||
|
||||
@on("screenshot-ready")
|
||||
onScreenshotReady({ detail: screenshot }: ScreenshotReadyEvent) {
|
||||
this.blob = new Blob([screenshot.data.buffer], { type: "image/jpg" });
|
||||
|
||||
const prevUrl = this.inputUrl;
|
||||
this.inputUrl = URL.createObjectURL(this.blob);
|
||||
|
||||
if (prevUrl != null)
|
||||
URL.revokeObjectURL(prevUrl);
|
||||
}
|
||||
|
||||
updatePrompt(event: Event): void {
|
||||
this.prompt = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
updateNegPrompt(event: Event): void {
|
||||
this.negPrompt = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
refreshSeed(): void {
|
||||
this.seed = randomSeed();
|
||||
}
|
||||
|
||||
stopTextInputBubbling(event: KeyboardEvent): void {
|
||||
// We want modifier keys like "Alt" to always be received by the Bevy
|
||||
// canvas because they affect the camera controller behavior, but we do
|
||||
// NOT want Bevy to receive typing inputs like W, A, S, and D while the
|
||||
// user is trying to type into the Prompt field.
|
||||
if (/^(Alt|Control)$/.test(event.key))
|
||||
return;
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onResizeStart(): void {
|
||||
this.resizing = true;
|
||||
}
|
||||
|
||||
onResizeEnd(): void {
|
||||
this.resizing = false;
|
||||
}
|
||||
|
||||
onResize(event: PointerEvent): void {
|
||||
let updated = this.hierarchyWidth + event.movementX;
|
||||
this.hierarchyWidth = Math.max(Math.min(updated, 512), 128);
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<sts-canvas-provider class="app-root">
|
||||
<div
|
||||
class="hierarchy-wrapper"
|
||||
style=${styleMap({ width: `${this.hierarchyWidth}px` })}
|
||||
>
|
||||
<sts-scene-hierarchy class="hierarchy"></sts-scene-hierarchy>
|
||||
<div
|
||||
class=${classMap({
|
||||
"hierarchy-resize-handle": true,
|
||||
"resizing": this.resizing,
|
||||
})}
|
||||
${drag({
|
||||
start: this.onResizeStart,
|
||||
move: this.onResize,
|
||||
end: this.onResizeEnd,
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="split-screen">
|
||||
<sts-studio
|
||||
class=${classMap({
|
||||
"studio-scene": true,
|
||||
"resizing": this.resizing,
|
||||
})}
|
||||
objectId="base-human-female.gltf"
|
||||
skyboxId="test_scene"
|
||||
.mode=${StudioMode.Editor}
|
||||
>
|
||||
<sts-transform-toolbar
|
||||
class="xform-toolbar h-center v-start"
|
||||
></sts-transform-toolbar>
|
||||
</sts-studio>
|
||||
|
||||
${this.inputUrl || this.outputUrl ? html`
|
||||
<img
|
||||
${ref(this.#outputImageRef)}
|
||||
class="image-capture"
|
||||
src=${(this.outputUrl ?? this.inputUrl)!}
|
||||
alt="Image capture"
|
||||
/>
|
||||
` : html`
|
||||
<div class="image-capture placeholder"></div>
|
||||
`}
|
||||
|
||||
<div class="config config-prompts h-center v-end"
|
||||
@keydown=${this.stopTextInputBubbling}
|
||||
@keyup=${this.stopTextInputBubbling}
|
||||
>
|
||||
<div class="config-field-group prompt">
|
||||
<sts-icon class="pre-icon green" icon="check"></sts-icon>
|
||||
<input
|
||||
class="config-field prompt"
|
||||
type="text"
|
||||
aria-label="Affirmative Prompt"
|
||||
placeholder="Enter an affirmative prompt to customize the output"
|
||||
.value=${this.prompt}
|
||||
@change=${this.updatePrompt}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.updatePrompt(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-field-group prompt">
|
||||
<sts-icon class="pre-icon red" icon="xmark"></sts-icon>
|
||||
<input
|
||||
class="config-field prompt"
|
||||
type="text"
|
||||
aria-label="Negative Prompt"
|
||||
placeholder="Enter a negative prompt to customize the output"
|
||||
.value=${this.negPrompt}
|
||||
@change=${this.updateNegPrompt}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.updateNegPrompt(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="config-field-group">
|
||||
<input
|
||||
class="config-field seed"
|
||||
type="number"
|
||||
aria-label="Seed"
|
||||
.value=${this.seed.toString()}
|
||||
min="1"
|
||||
max="99999"
|
||||
/>
|
||||
<button
|
||||
class="seed-refresh"
|
||||
@click=${this.refreshSeed}
|
||||
>
|
||||
<sts-icon icon="rotate"></sts-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${/*
|
||||
FIXME: Real-time capture is not really feasible until we can come up
|
||||
with a more elegant way to exclude the world grid, gizmos, etc.
|
||||
from the captured image
|
||||
|
||||
<div class="config v-start h-end">
|
||||
<label class="config-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.realtimeCapture}
|
||||
@input=${this.toggleRealtimeCapture}
|
||||
/>
|
||||
Realtime Capture
|
||||
</label>
|
||||
</div>
|
||||
*/
|
||||
nothing}
|
||||
|
||||
${this.loading ? html`
|
||||
<!-- TODO: Animated spinner or something -- talk to our designers -->
|
||||
<div class="loading">
|
||||
<h1>Loading</h1>
|
||||
</div>
|
||||
` : nothing}
|
||||
</sts-canvas-provider>
|
||||
`
|
||||
}
|
||||
|
||||
function randomSeed(): number {
|
||||
return Math.round(Math.random() * 99999);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
export enum LcmLiveStatus {
|
||||
None = "NONE",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
Wait = "wait",
|
||||
SendFrame = "send_frame",
|
||||
Timeout = "timeout",
|
||||
}
|
||||
|
||||
export interface ImageInfo {
|
||||
blob: Blob;
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
seed: number;
|
||||
guidanceScale: number;
|
||||
}
|
||||
|
||||
interface LcmMessageData {
|
||||
status: LcmLiveStatus | "error";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class LcmClient extends WebSocket {
|
||||
static readonly Api = "//b3000.pancake-chimaera.ts.net/api";
|
||||
|
||||
#userId: string;
|
||||
|
||||
get status() { return this.#status; }
|
||||
#status = LcmLiveStatus.None;
|
||||
|
||||
#waiting = false;
|
||||
|
||||
#blob?: Blob;
|
||||
#prompt?: string;
|
||||
#negativePrompt?: string;
|
||||
#seed?: number;
|
||||
#guidanceScale = 0.8;
|
||||
|
||||
constructor (userId: string) {
|
||||
super(`wss:${LcmClient.Api}/ws/${userId}`);
|
||||
this.#userId = userId;
|
||||
|
||||
this.addEventListener("open", () => this.#onOpen());
|
||||
this.addEventListener("close", () => this.#onClose());
|
||||
this.addEventListener("message", event => this.#onMessage(event))
|
||||
}
|
||||
|
||||
update(data: Partial<ImageInfo>): void {
|
||||
if ("blob" in data)
|
||||
this.#blob = data.blob;
|
||||
|
||||
if ("prompt" in data)
|
||||
this.#prompt = data.prompt;
|
||||
|
||||
if ("negativePrompt" in data)
|
||||
this.#negativePrompt = data.negativePrompt;
|
||||
|
||||
if ("seed" in data)
|
||||
this.#seed = data.seed;
|
||||
|
||||
this.#update();
|
||||
}
|
||||
|
||||
#onOpen(): void {
|
||||
console.log(`Marking connection to websocket as open for user ${this.#userId}`);
|
||||
this.#status = LcmLiveStatus.Connected;
|
||||
}
|
||||
|
||||
#onClose(): void {
|
||||
console.log(`Marking connection to websocket as closed for user ${this.#userId}`);
|
||||
this.#status = LcmLiveStatus.Disconnected;
|
||||
}
|
||||
|
||||
#onMessage(event: MessageEvent<string>): void {
|
||||
console.log("onMessage:", event);
|
||||
const data: LcmMessageData = JSON.parse(event.data);
|
||||
|
||||
if (data.status === "error") {
|
||||
this.#status = LcmLiveStatus.Disconnected;
|
||||
console.error(data.message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#status = data.status;
|
||||
|
||||
if (this.#status === LcmLiveStatus.SendFrame && this.#waiting)
|
||||
this.#sendFrame();
|
||||
}
|
||||
|
||||
#update(): void {
|
||||
if (this.#status === LcmLiveStatus.SendFrame) {
|
||||
this.#sendFrame();
|
||||
} else {
|
||||
this.#waiting = true;
|
||||
}
|
||||
}
|
||||
|
||||
async #sendFrame(): Promise<void> {
|
||||
if (!this.#blob || !this.#prompt || !this.#negativePrompt || this.#seed == null) {
|
||||
this.#waiting = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Sending next frame...");
|
||||
|
||||
this.send(JSON.stringify({ status: "next_frame" }));
|
||||
this.send(JSON.stringify({
|
||||
prompt: this.#prompt,
|
||||
negativePrompt: this.#negativePrompt,
|
||||
seed: this.#seed,
|
||||
guidanceScale: this.#guidanceScale,
|
||||
}));
|
||||
this.send(this.#blob);
|
||||
|
||||
this.#waiting = false;
|
||||
|
||||
try {
|
||||
const url = `https:${LcmClient.Api}/stream/${this.#userId}`;
|
||||
|
||||
// FIXME: This only actually renders an image in the DOM about ~10% of the time
|
||||
this.dispatchEvent(new CustomEvent("lcm-image-url-ready", { detail: url }));
|
||||
|
||||
// const response = await fetch(url, { method: "GET" });
|
||||
// console.log("response:", response);
|
||||
|
||||
// if (!response.ok || !response.body) {
|
||||
// console.error(`GET failed with status ${response.status}: "${response.statusText}"`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const blobParts = [] as Uint8Array[];
|
||||
// const reader = response.body.getReader();
|
||||
|
||||
// // FIXME: This doesn't appear to be an image
|
||||
// // const { value } = await reader.read();
|
||||
// // if (value) blobParts.push(value);
|
||||
|
||||
// // FIXME: this just stops responding after the first `read()` call even
|
||||
// // though `done` is false
|
||||
// while (true) {
|
||||
// console.log("Reading next chunk...");
|
||||
//
|
||||
// const { value, done } = await reader.read();
|
||||
// console.log({ value, done });
|
||||
//
|
||||
// if (value) blobParts.push(value);
|
||||
// if (done) break;
|
||||
// }
|
||||
|
||||
// this.dispatchEvent(new CustomEvent<Blob>("lcm-image-ready", {
|
||||
// detail: new Blob(blobParts, { type: "image/jpg" }),
|
||||
// }));
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface LcmClientEventMap extends WebSocketEventMap {
|
||||
"lcm-image-ready": CustomEvent<Blob>;
|
||||
"lcm-image-url-ready": CustomEvent<string>;
|
||||
}
|
||||
|
||||
export interface LcmClient {
|
||||
addEventListener<K extends keyof LcmClientEventMap>(
|
||||
type: K,
|
||||
listener: (this: LcmClient, ev: LcmClientEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "./app/app.element";
|
||||
@@ -0,0 +1,10 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"typings/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"files": [],
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.scss?inline" {
|
||||
const styles: import("lit").CSSResult;
|
||||
export default styles;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.svg?raw" {
|
||||
const svg: string;
|
||||
export default svg;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import mergeAssetsDirs from "../vite-multiple-assets";
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: "../node_modules/.vite/image-generator-demo",
|
||||
server:{
|
||||
port: 4200,
|
||||
host: "localhost",
|
||||
},
|
||||
preview:{
|
||||
port: 4300,
|
||||
host: "localhost",
|
||||
},
|
||||
plugins: [
|
||||
nxViteTsPaths(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
mergeAssetsDirs([
|
||||
"image-generator-demo/public",
|
||||
"dist/studio/public",
|
||||
]),
|
||||
],
|
||||
});
|
||||
Generated
+9
-2
@@ -14,7 +14,8 @@
|
||||
"lodash": "^4.17.21",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tslib": "^2.3.0"
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "16.9.1",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@types/node": "18.14.2",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/which": "^3.0.2",
|
||||
"@vitejs/plugin-react": "~4.0.0",
|
||||
"@vitest/coverage-c8": "~0.32.0",
|
||||
@@ -4410,6 +4412,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/uuid": {
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/which": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.2.tgz",
|
||||
@@ -10194,7 +10202,6 @@
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@
|
||||
"@types/node": "18.14.2",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/which": "^3.0.2",
|
||||
"@vitejs/plugin-react": "~4.0.0",
|
||||
"@vitest/coverage-c8": "~0.32.0",
|
||||
@@ -45,6 +46,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tslib": "^2.3.0"
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import fs from "fs";
|
||||
import {NormalizedOutputOptions} from "rollup";
|
||||
|
||||
function copyRecursively(src: string, dst: string) {
|
||||
if (fs.statSync(src).isDirectory()) {
|
||||
// folder
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.mkdirSync(dst);
|
||||
}
|
||||
for (const file of fs.readdirSync(src)) {
|
||||
copyRecursively(src + "/" + file, dst + "/" + file);
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMiddleWare(options: NormalizedOutputOptions, assets: string[] = []) {
|
||||
const dst = options.dir;
|
||||
if (!dst) throw new Error("options.dir is undefined");
|
||||
for (const dir of assets) {
|
||||
copyRecursively(dir, dst)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// FIXME: Oh my god, did Danny seriously just copy and paste this entire folder
|
||||
// from `studio-frontend` instead of creating a separate project for it
|
||||
// and importing it like a decent, law-abiding citizen? Wow, dude really
|
||||
// needs to get his shit together.
|
||||
|
||||
// NOTE: This is an inline fork of this npm package:
|
||||
// https://github.com/nguyenbatranvan/vite-multiple-assets
|
||||
//
|
||||
// The published version at time of writing doesn't include mime types for many
|
||||
// of the asset types Bevy needs to load.
|
||||
|
||||
import {PluginOption, ViteDevServer} from "vite";
|
||||
import {ServerMiddleWare} from "./server";
|
||||
import {buildMiddleWare} from "./build";
|
||||
|
||||
export default function DynamicPublicDirectory(assets: string[]): PluginOption {
|
||||
return {
|
||||
configureServer(server: ViteDevServer) {
|
||||
return ServerMiddleWare(server, assets)
|
||||
},
|
||||
async writeBundle(options) {
|
||||
buildMiddleWare(options, assets)
|
||||
},
|
||||
name: "dynamic assets"
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ViteDevServer } from "vite";
|
||||
|
||||
interface IPropsFile {
|
||||
name: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function getFiles(dir, files_): string[] {
|
||||
files_ = files_ || [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const i in files) {
|
||||
const name = dir + "/" + files[i];
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
getFiles(name, files_);
|
||||
} else {
|
||||
files_.push(name);
|
||||
}
|
||||
}
|
||||
return files_;
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
".7z": "application/x-7z-compressed",
|
||||
".bin": "application/octet-stream",
|
||||
".css": "text/css",
|
||||
".eot": "font/eot",
|
||||
".gif": "image/gif",
|
||||
".gltf": "model/gltf+json",
|
||||
".gz": "application/gzip",
|
||||
".hdr": "image/vnd.radiance",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".ktx": "image/ktx",
|
||||
".ktx2": "image/ktx",
|
||||
".meta": "text/plain",
|
||||
".mjs": "text/javascript",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".otf": "font/otf",
|
||||
".png": "image/png",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".svg": "image/svg+xml",
|
||||
".tiff": "image/tiff",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain",
|
||||
".wasm": "application/wasm",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wgsl": "text/wgsl",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xml": "text/xml",
|
||||
".zip": "application/zip",
|
||||
}
|
||||
|
||||
export function ServerMiddleWare(server: ViteDevServer, assets: string[] = []) {
|
||||
if (!assets || !assets.length)
|
||||
return;
|
||||
|
||||
const fileObject: IPropsFile[] = [];
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
const files = getFiles(path.join(process.cwd(), `/${assets[i]}`), []);
|
||||
fileObject.push({
|
||||
name: assets[i],
|
||||
files
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
for (let i = 0; i < fileObject.length; i++) {
|
||||
const file = path.join(process.cwd(), `${fileObject[i].name}/${req.originalUrl}`);
|
||||
if (fileObject[i].files.some(f => path.relative(f, file) === "")) {
|
||||
const extension = file.substring(file.lastIndexOf("."));
|
||||
res.setHeader("Cache-Control", "max-age=31536000, immutable");
|
||||
res.setHeader("Content-Type", mimeTypes[extension]);
|
||||
res.writeHead(200);
|
||||
res.write(fs.readFileSync(path.join(process.cwd(), "/" + fileObject[i].name + req.originalUrl)));
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
import mergeAssetsDirs from "./vite-plugins/multiple-assets";
|
||||
import mergeAssetsDirs from "../vite-multiple-assets";
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: "../node_modules/.vite/react-frontend",
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
channel = "1.76.0"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
:host {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.sidebar, .timeline { padding: 8px; }
|
||||
.sidebar { padding-inline-end: 0; }
|
||||
.timeline { padding-inline-start: 0; }
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 auto;
|
||||
width: 256px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-keyframe {
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
|
||||
background: #7A2C34E3;
|
||||
color: #FFF;
|
||||
|
||||
&:hover {
|
||||
background: #90363F;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-nav {
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
|
||||
sts-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent !important;
|
||||
backdrop-filter: initial;
|
||||
color: #FFFC;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #FFFF;
|
||||
}
|
||||
|
||||
&.play {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
flex: 0 0 8px;
|
||||
margin: {
|
||||
right: -3px;
|
||||
left: -3px;
|
||||
}
|
||||
cursor: ew-resize;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 3px;
|
||||
bottom: 0;
|
||||
left: 3px;
|
||||
background: #FFF2;
|
||||
opacity: 0;
|
||||
transition: opacity 66.666ms linear;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { type CameraKeyframesEvent } from "@storyteller/studio";
|
||||
import { drag, on } from "@storyteller/framework";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
import "@storyteller/studio-ui/button";
|
||||
import "@storyteller/studio-ui/toolbar";
|
||||
import "@storyteller/studio-ui/tree";
|
||||
import "./anim-timeline.element";
|
||||
|
||||
import styles from "./anim-editor.element.scss?inline";
|
||||
|
||||
@customElement("sts-anim-editor")
|
||||
export class AnimEditorElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@state() sidebarWidth = 256;
|
||||
|
||||
@state() timelineStart = 0;
|
||||
@state() timelineEnd = 5_000;
|
||||
|
||||
@state() clipStart = 0;
|
||||
@state() clipEnd = 15_000;
|
||||
|
||||
@state() playHead = 0;
|
||||
@state() keyframes: Record<string, number> = {};
|
||||
|
||||
protected override update(changes: PropertyValues<this>): void {
|
||||
if (changes.has("playHead"))
|
||||
studio.seekTime(this.playHead);
|
||||
|
||||
super.update(changes);
|
||||
}
|
||||
|
||||
@on("window:camera-keyframes")
|
||||
onCameraKeyframeChanges({ detail: keyframes }: CameraKeyframesEvent): void {
|
||||
this.keyframes = keyframes;
|
||||
}
|
||||
|
||||
onSidebarResize(event: PointerEvent): void {
|
||||
this.sidebarWidth = Math.max(Math.min(this.sidebarWidth + event.movementX, 512), 128);
|
||||
}
|
||||
|
||||
addKeyframe(): void {
|
||||
studio.addCameraKeyframe(this.playHead);
|
||||
}
|
||||
|
||||
jumpToStart(): void {
|
||||
this.playHead = this.clipStart;
|
||||
}
|
||||
|
||||
prevKeyframe(): void {
|
||||
let pos = this.playHead;
|
||||
|
||||
const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => b - a);
|
||||
for (let keyframe of keyframes) {
|
||||
pos = keyframe;
|
||||
if (keyframe < this.playHead)
|
||||
break;
|
||||
}
|
||||
|
||||
this.playHead = pos;
|
||||
}
|
||||
|
||||
nextKeyframe(): void {
|
||||
let pos = this.playHead;
|
||||
|
||||
const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => a - b);
|
||||
for (let keyframe of keyframes) {
|
||||
pos = keyframe;
|
||||
if (keyframe > this.playHead)
|
||||
break;
|
||||
}
|
||||
|
||||
this.playHead = pos;
|
||||
}
|
||||
|
||||
jumpToEnd(): void {
|
||||
this.playHead = this.clipEnd;
|
||||
}
|
||||
|
||||
#onTimelineStartChange(event: CustomEvent<number>): void {
|
||||
this.timelineStart = event.detail;
|
||||
}
|
||||
|
||||
#onTimelineEndChange(event: CustomEvent<number>): void {
|
||||
this.timelineEnd = event.detail;
|
||||
}
|
||||
|
||||
#onPlayHeadChange(event: CustomEvent<number>): void {
|
||||
this.playHead = event.detail;
|
||||
}
|
||||
|
||||
#onKeyframesChange(event: CustomEvent<Record<string, number>>): void {
|
||||
this.keyframes = event.detail;
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<div class="sidebar"
|
||||
style=${styleMap({
|
||||
width: `${this.sidebarWidth}px`,
|
||||
})}
|
||||
>
|
||||
<sts-toolbar>
|
||||
<sts-toolbar-group>
|
||||
<sts-button class="btn-keyframe"
|
||||
icon="key"
|
||||
aria-label="Add Keyframe"
|
||||
title="Add Keyframe"
|
||||
@click=${this.addKeyframe}
|
||||
></sts-button>
|
||||
</sts-toolbar-group>
|
||||
<sts-toolbar-group class="timeline-nav">
|
||||
<sts-button
|
||||
icon="bwd-fast"
|
||||
aria-label="Jump to Start"
|
||||
title="Jump to Start"
|
||||
@click=${this.jumpToStart}
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="bwd-step"
|
||||
aria-label="Previous Keyframe"
|
||||
title="Previous Keyframe"
|
||||
@click=${this.prevKeyframe}
|
||||
></sts-button>
|
||||
<sts-button class="play"
|
||||
icon="play"
|
||||
aria-label="Play"
|
||||
title="Play"
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="fwd-step"
|
||||
aria-label="Next Keyframe"
|
||||
title="Next Keyframe"
|
||||
@click=${this.nextKeyframe}
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="fwd-fast"
|
||||
aria-label="Jump to End"
|
||||
title="Jump to End"
|
||||
@click=${this.jumpToEnd}
|
||||
></sts-button>
|
||||
</sts-toolbar-group>
|
||||
</sts-toolbar>
|
||||
<div class="tracklist" role="list">
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera"
|
||||
icon="video"
|
||||
></sts-tree>
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera Target"
|
||||
icon="crosshairs"
|
||||
></sts-tree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"
|
||||
${drag({
|
||||
move: this.onSidebarResize,
|
||||
})}
|
||||
></div>
|
||||
|
||||
<sts-anim-timeline class="timeline"
|
||||
.rangeStart=${this.timelineStart}
|
||||
@rangeStartChange=${this.#onTimelineStartChange}
|
||||
.rangeEnd=${this.timelineEnd}
|
||||
@rangeEndChange=${this.#onTimelineEndChange}
|
||||
|
||||
.clipStart=${this.clipStart}
|
||||
.clipEnd=${this.clipEnd}
|
||||
|
||||
.playHead=${this.playHead}
|
||||
@playHeadChange=${this.#onPlayHeadChange}
|
||||
|
||||
.keyframes=${this.keyframes}
|
||||
@keyframesChange=${this.#onKeyframesChange}
|
||||
></sts-anim-timeline>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
:host {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.clip-region {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #0004;
|
||||
}
|
||||
|
||||
.hash {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #FFF1;
|
||||
|
||||
&.strong {
|
||||
background: #FFF3;
|
||||
}
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
color: #FFF6;
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
size: 14px;
|
||||
}
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
.frac {
|
||||
color: #FFF3;
|
||||
}
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
transform: translateX(-3px);
|
||||
cursor: ew-resize;
|
||||
color: #7A2C34E3;
|
||||
transition: color 66.667ms linear;
|
||||
|
||||
&:hover {
|
||||
color: #90363F;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 32px;
|
||||
background: currentColor;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 12px;
|
||||
border: 4px solid transparent {
|
||||
top: 8px solid currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
bottom: 0;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.keyframe {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateX(-4.5px) translateY(-4.5px);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: #AAA;
|
||||
transform: rotate(45deg) {
|
||||
origin: center center;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected::before {
|
||||
background: #0FF; // TODO
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { type EntitySelectEvent } from "@storyteller/studio";
|
||||
import { drag, on } from "@storyteller/framework";
|
||||
import { LitElement, PropertyValues, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
import styles from "./anim-timeline.element.scss?inline";
|
||||
|
||||
// TODO: This is basically a [signal](https://www.solidjs.com/tutorial/introduction_signals)
|
||||
// that requires manual dependency tracking. Given that requirement, it's
|
||||
// questionable whether this is even a valuable abstraction to have.
|
||||
//
|
||||
// We should either make it more obviously valuable (e.g. by coming up
|
||||
// with a way to automatically handle dependencies) and move it to
|
||||
// `@storyteller/framework`, or just get rid of it.
|
||||
//
|
||||
// We could maybe add a decorator that could automatically override the
|
||||
// `update` method to invalidate the cache when dependencies are changed,
|
||||
// which would look something like:
|
||||
// ```
|
||||
// @memo(["rangeStart", "rangeEnd", "_renderWidth"])
|
||||
// msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
// ```
|
||||
// But that feels a little obtuse and overengineered. In this particular
|
||||
// case we're probably only saving on the order of nanoseconds, so I'm
|
||||
// leaning toward just nuking the idea.
|
||||
class Computed extends Number {
|
||||
#value?: number;
|
||||
#compute: () => number;
|
||||
|
||||
constructor (compute: () => number) {
|
||||
const value = compute();
|
||||
super(value);
|
||||
this.#compute = compute;
|
||||
}
|
||||
|
||||
override valueOf(): number {
|
||||
return this.#value ??= this.#compute();
|
||||
}
|
||||
|
||||
get(): number {
|
||||
return this.valueOf();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.#value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("sts-anim-timeline")
|
||||
export class AnimTimelineElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
/** The time (in ms) at the left edge of the timeline view */
|
||||
@property({ type: Number }) rangeStart = 0;
|
||||
/** The time (in ms) at the right edge of the timeline view */
|
||||
@property({ type: Number }) rangeEnd = 5_000;
|
||||
|
||||
/** The time (in ms) at the beginning of the active clip range */
|
||||
@property({ type: Number }) clipStart = 0;
|
||||
/** The time (in ms) at the end of the active clip range */
|
||||
@property({ type: Number }) clipEnd = 15_000;
|
||||
|
||||
/** The time (in ms) of the current play-head position */
|
||||
@property({ type: Number }) playHead = 0;
|
||||
/** Keyframe positions (in ms) */
|
||||
@property({ attribute: false }) keyframes: Record<string, number> = {};
|
||||
|
||||
@state() _selectedKeyframe: string | null = null;
|
||||
|
||||
/** The width (in px) of the timeline view */
|
||||
@state() _renderWidth!: number;
|
||||
|
||||
/** The offset from 0ms of the left edge of the timeline view, in px */
|
||||
@state() _offsetX = 0;
|
||||
/** The vertical scroll offset (in px) */
|
||||
@state() _offsetY = 0;
|
||||
|
||||
/**
|
||||
* `true` when the timeline is being mouse-panned. Primarily useful for
|
||||
* setting the cursor style to the grabby hand.
|
||||
*/
|
||||
@state() _panning = false;
|
||||
|
||||
/** How many ms are represented by each px of horizontal space. */
|
||||
msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
|
||||
#resizeObserver: ResizeObserver;
|
||||
|
||||
constructor () {
|
||||
super();
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#updateRenderWidth());
|
||||
this.#resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#updateRenderWidth();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
protected override update(changes: PropertyValues<this>): void {
|
||||
if (
|
||||
changes.has("rangeStart")
|
||||
|| changes.has("rangeEnd")
|
||||
|| changes.has("_renderWidth")
|
||||
) {
|
||||
this.msPerPx.invalidate();
|
||||
}
|
||||
|
||||
super.update(changes);
|
||||
}
|
||||
|
||||
@on("wheel")
|
||||
onWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.ctrlKey) {
|
||||
this.#zoomTimeline(event.offsetX, event.deltaY);
|
||||
} else if (event.deltaX) {
|
||||
this.#pan(-event.deltaX);
|
||||
} else if (event.shiftKey && event.deltaY) {
|
||||
this.#pan(-event.deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
@on("window:entity-select")
|
||||
onEntitySelected({ detail: id }: EntitySelectEvent): void {
|
||||
if (!id) {
|
||||
this._selectedKeyframe = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.from(Object.keys(this.keyframes)).includes(id)) {
|
||||
this._selectedKeyframe = id;
|
||||
this.dispatchEvent(new CustomEvent("playHeadChange", { detail: this.keyframes[id]! }));
|
||||
} else {
|
||||
this._selectedKeyframe = null;
|
||||
}
|
||||
}
|
||||
|
||||
selectKeyframe(id: string): void {
|
||||
studio.select(id);
|
||||
}
|
||||
|
||||
#zoomTimeline(origin: number, delta: number): void {
|
||||
const currentRange = this.rangeEnd - this.rangeStart;
|
||||
const deltaRange = delta * 4;
|
||||
|
||||
// FIXME: When zooming the timeline, the transform origin should be
|
||||
// centered on the mouse cursor. Currently this does not work as
|
||||
// intended, always centering on the 00:00 timestamp instead.
|
||||
const t = ((this._offsetX + origin) * this.msPerPx.get()) / currentRange;
|
||||
console.log(`t: ${t}`);
|
||||
console.log(`deltaStart: ${-deltaRange * t}`);
|
||||
console.log(`deltaEnd: ${deltaRange * (1 - t)}`);
|
||||
const rangeStart = this.rangeStart - deltaRange * t;
|
||||
const rangeEnd = this.rangeEnd + deltaRange * (1 - t);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("rangeStartChange", { detail: rangeStart }));
|
||||
this.dispatchEvent(new CustomEvent("rangeEndChange", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
#onDragStart(): void {
|
||||
this._panning = true;
|
||||
}
|
||||
|
||||
#onDragEnd(): void {
|
||||
this._panning = false;
|
||||
}
|
||||
|
||||
#onDrag(event: PointerEvent): void {
|
||||
this.#pan(event.movementX);
|
||||
}
|
||||
|
||||
#pan(deltaPx: number): void {
|
||||
this._offsetX += deltaPx;
|
||||
|
||||
const msPerPx = this.msPerPx.get();
|
||||
const rangeStart = this.rangeStart - deltaPx * msPerPx;
|
||||
const rangeEnd = this.rangeEnd - deltaPx * msPerPx;
|
||||
|
||||
this.dispatchEvent(new CustomEvent("rangeStartChange", { detail: rangeStart }));
|
||||
this.dispatchEvent(new CustomEvent("rangeEndChange", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
#onPlayheadDrag(event: PointerEvent): void {
|
||||
const deltaMs = event.movementX * this.msPerPx.get();
|
||||
const updated = this.playHead + deltaMs;
|
||||
|
||||
this.dispatchEvent(new CustomEvent("playHeadChange", { detail: updated }));
|
||||
}
|
||||
|
||||
#onKeyframeDrag(id: string, event: PointerEvent): void {
|
||||
const deltaMs = event.movementX * this.msPerPx.get();
|
||||
const updated = this.keyframes[id] + deltaMs;
|
||||
|
||||
studio.updateKeyframe(id, updated);
|
||||
|
||||
const updatedKeyframes = Object.fromEntries(
|
||||
Object
|
||||
.entries(this.keyframes)
|
||||
.map(entry => entry[0] === id
|
||||
? [id, updated]
|
||||
: entry
|
||||
)
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("keyframesChange", { detail: updatedKeyframes }));
|
||||
}
|
||||
|
||||
#updateRenderWidth(): void {
|
||||
this._renderWidth = this.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time value (in ms) for displaying in the timeline view
|
||||
*/
|
||||
#timestamp(ms: number) {
|
||||
const sign = ms < 0 ? "-" : "";
|
||||
ms = Math.abs(ms);
|
||||
|
||||
let s = Math.floor(ms / 1000);
|
||||
let frac = Math.round(ms % 1000);
|
||||
if (frac === 1000) {
|
||||
frac = 0;
|
||||
s += 1;
|
||||
}
|
||||
|
||||
let m = 0;
|
||||
if (s >= 60) {
|
||||
m = Math.floor(s / 60);
|
||||
s %= 60;
|
||||
}
|
||||
|
||||
return html`${
|
||||
sign
|
||||
}${
|
||||
m.toString(10).padStart(2, "0")
|
||||
}:${
|
||||
s.toString(10).padStart(2, "0")
|
||||
}<!--<span class="frac">.${
|
||||
frac.toString(10).padEnd(3, "0")
|
||||
}</span>-->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a time value (in ms) to a px offset from the left edge of the view
|
||||
*/
|
||||
#msToPx(ms: number): number {
|
||||
return this._offsetX + ms / this.msPerPx.get();
|
||||
}
|
||||
|
||||
protected override render = () => {
|
||||
const msPerPx = this.msPerPx.get();
|
||||
const range = this.rangeEnd - this.rangeStart;
|
||||
|
||||
let subdivsPerSec = 32;
|
||||
let subdivsCount = (range / 1000) * subdivsPerSec;
|
||||
let pxPerSubdiv = this._renderWidth / subdivsCount;
|
||||
|
||||
while (pxPerSubdiv < 64) {
|
||||
subdivsPerSec /= 2;
|
||||
subdivsCount = (range / 1000) * subdivsPerSec;
|
||||
pxPerSubdiv = this._renderWidth / subdivsCount;
|
||||
}
|
||||
|
||||
const msPerSubdiv = msPerPx * pxPerSubdiv;
|
||||
const subdivs = new Array(Math.ceil(subdivsCount) + 2).fill(null);
|
||||
|
||||
const fracPxOffset = this._offsetX % pxPerSubdiv;
|
||||
|
||||
let startingIdx = -Math.floor(this._offsetX / pxPerSubdiv);
|
||||
if (fracPxOffset < 0)
|
||||
startingIdx -= 2;
|
||||
else
|
||||
startingIdx -= 1;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
wrapper: true,
|
||||
panning: this._panning,
|
||||
})}
|
||||
${drag({
|
||||
button: 1,
|
||||
start: this.#onDragStart,
|
||||
move: this.#onDrag,
|
||||
end: this.#onDragEnd,
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="clip-region"
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(this.clipStart)}px`,
|
||||
right: `${this._renderWidth - this.#msToPx(this.clipEnd)}px`,
|
||||
})}
|
||||
></div>
|
||||
|
||||
${subdivs.map((_, idx) => {
|
||||
const pxOffset = (idx - 1) * pxPerSubdiv + fracPxOffset;
|
||||
const ms = (startingIdx + idx) * msPerSubdiv;
|
||||
|
||||
const msFrac = Math.abs(Math.round(ms) % 1000);
|
||||
const strong = msFrac === 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
hash: true,
|
||||
strong,
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${pxOffset}px`
|
||||
})}
|
||||
></div>
|
||||
${strong ? html`
|
||||
<span class="timestamp"
|
||||
style=${styleMap({
|
||||
left: `${pxOffset + 8}px`
|
||||
})}
|
||||
>
|
||||
${this.#timestamp(ms)}
|
||||
</span>
|
||||
` : nothing}
|
||||
`;
|
||||
})}
|
||||
|
||||
<div class="playhead"
|
||||
${drag({
|
||||
move: this.#onPlayheadDrag
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(this.playHead)}px`
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="head"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
|
||||
${repeat(
|
||||
Object.entries(this.keyframes),
|
||||
([id]) => id,
|
||||
([id, keyframe]) => html`
|
||||
<div id=${id}
|
||||
class=${classMap({
|
||||
keyframe: true,
|
||||
selected: this._selectedKeyframe === id,
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(keyframe)}px`
|
||||
})}
|
||||
tabindex="-1"
|
||||
${drag({
|
||||
move: event => this.#onKeyframeDrag(id, event),
|
||||
})}
|
||||
@click=${() => this.selectKeyframe(id)}
|
||||
></div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
$hierarchy-width: 290px;
|
||||
|
||||
.app-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -8,12 +10,19 @@
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
}
|
||||
.v-end {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
}
|
||||
.v-after-toolbar {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
}
|
||||
.h-start { left: 16px }
|
||||
.h-end { right: 16px }
|
||||
.h-before-hierarchy {
|
||||
right: $hierarchy-width + 32px;
|
||||
}
|
||||
.h-center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
@@ -26,33 +35,9 @@ sts-button.active {
|
||||
|
||||
// Hierarchy (TODO: Probably move this)
|
||||
.hierarchy {
|
||||
overflow: auto;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 290px;
|
||||
width: $hierarchy-width;
|
||||
max-height: calc(100vh - 32px);
|
||||
padding: 10px 15px;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -1,84 +1,41 @@
|
||||
import { on } from "@storyteller/framework";
|
||||
import * as studio from "@storyteller/studio";
|
||||
import {
|
||||
type EntityDespawnEvent,
|
||||
type EntityMultiSpawnEvent,
|
||||
type EntitySelectEvent,
|
||||
type EntitySpawnEvent,
|
||||
type SceneSavedEvent,
|
||||
type SceneStateEvent,
|
||||
TransformSpace,
|
||||
TransformType,
|
||||
SceneState,
|
||||
StudioMode,
|
||||
} from "@storyteller/studio";
|
||||
import { StudioURLParams } from "@storyteller/studio-web";
|
||||
import { CanvasProvider } from "@storyteller/studio-web/canvas-provider";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit-html/directives/class-map.js";
|
||||
import { Ref, createRef, ref } from "lit-html/directives/ref.js";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import "@storyteller/studio-ui/button";
|
||||
import "@storyteller/studio-ui/icon";
|
||||
import "@storyteller/studio-ui/toolbar";
|
||||
import "@storyteller/studio-ui/tree";
|
||||
import "@storyteller/studio-web/canvas-provider";
|
||||
import "@storyteller/studio-web/viewer";
|
||||
import "@storyteller/studio-web/bvh-viewer";
|
||||
import "@storyteller/studio-web/mixamo-viewer";
|
||||
import "./button.element";
|
||||
import "@storyteller/studio-web/scene-hierarchy";
|
||||
import "@storyteller/studio-web/studio";
|
||||
import "@storyteller/studio-web/transform-toolbar";
|
||||
import "./anim-editor.element";
|
||||
import "./help.element";
|
||||
import "./icon.element";
|
||||
import "./toolbar.element";
|
||||
import "./transform-toolbar.element";
|
||||
import "./tree.element";
|
||||
|
||||
import "./app.element.scss";
|
||||
|
||||
interface SceneObject extends Omit<studio.SceneObject, "children"> {
|
||||
children: SceneObject[];
|
||||
}
|
||||
|
||||
@customElement("app-root")
|
||||
export class AppElement extends LitElement {
|
||||
@state() showHelp = false;
|
||||
@state() loading = true;
|
||||
|
||||
@state() transformSpace = TransformSpace.Local;
|
||||
@state() transformType = TransformType.Translate;
|
||||
|
||||
@state() sceneObjects: SceneObject[] = [];
|
||||
@state() selection?: string | null;
|
||||
|
||||
@state() bvhPath?: string;
|
||||
@state() mixamoPath?: string;
|
||||
@state() mode?: StudioMode;
|
||||
|
||||
#sceneObjects: studio.SceneObject[] = [];
|
||||
#entityMap = new Map<string, SceneObject>();
|
||||
#params = new StudioURLParams();
|
||||
#canvasProvider: Ref<CanvasProvider> = createRef();
|
||||
|
||||
get #studioCanvas(): HTMLCanvasElement | null {
|
||||
const provider = this.#canvasProvider.value;
|
||||
if (!provider) return null;
|
||||
|
||||
return document.getElementById(provider.canvasId) as HTMLCanvasElement | null;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const params = new URLSearchParams(document.location.search);
|
||||
|
||||
if (params.has("bvh"))
|
||||
this.bvhPath = params.get("bvh")!;
|
||||
|
||||
if (params.has("mixamo"))
|
||||
this.mixamoPath = params.get("mixamo")!;
|
||||
|
||||
this.mode = match (params.get("mode"), {
|
||||
"studio": () => StudioMode.Editor,
|
||||
"viewer": () => StudioMode.Viewer,
|
||||
_: () => StudioMode.Viewer,
|
||||
});
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): Element | ShadowRoot {
|
||||
return this;
|
||||
}
|
||||
@@ -89,33 +46,30 @@ export class AppElement extends LitElement {
|
||||
console.log("Inspector changed:", data);
|
||||
}
|
||||
|
||||
@on("keydown")
|
||||
@on("keyup")
|
||||
pipeKeyboardEvents(event: KeyboardEvent): void {
|
||||
// When the user interacts with one of the web UI elements, the Bevy
|
||||
// canvas will lose focus, preventing it from receiving keyboard events.
|
||||
// As a workaround, we can listen for any events that have bubbled up to
|
||||
// the app root and repeat them for the canvas.
|
||||
//
|
||||
// This does unfortunately burden the web front-end with some additional
|
||||
// responsibility: when we handle a keyboard event and _don't_ want the
|
||||
// Bevy app to receive it, we'll need to call `event.stopPropagation()` in
|
||||
// the handler to prevent it from reaching this listener.
|
||||
const {
|
||||
type,
|
||||
code, charCode, key, keyCode, location, repeat,
|
||||
altKey, ctrlKey, metaKey, shiftKey,
|
||||
} = event;
|
||||
|
||||
this.#studioCanvas?.dispatchEvent(new KeyboardEvent(type, {
|
||||
code, charCode, key, keyCode, location, repeat,
|
||||
altKey, ctrlKey, metaKey, shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}));
|
||||
@on("scene-saved")
|
||||
async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise<void> {
|
||||
const file = new File([scene], "scene.scn.ron", { type: "text/ron" });
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("uuid_idempotency_token", uuid());
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.fakeyou.com/v1/engine/create_scene", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Accept": "text/plain" },
|
||||
body: formData,
|
||||
});
|
||||
console.log(response);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
@on("scene-state")
|
||||
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
||||
console.log("scene state change:", SceneState[state]);
|
||||
|
||||
@@ -125,107 +79,40 @@ 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;
|
||||
}
|
||||
|
||||
onTransformSpaceChange({ detail: space }: CustomEvent<TransformSpace>): void {
|
||||
this.transformSpace = space;
|
||||
studio.setTransformSpace(space);
|
||||
}
|
||||
|
||||
onTransformTypeChange({ detail: type }: CustomEvent<TransformType>): void {
|
||||
this.transformType = type;
|
||||
studio.setTransformType(type);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<sts-canvas-provider
|
||||
class="app-root"
|
||||
${ref(this.#canvasProvider as any)}
|
||||
>
|
||||
${this.bvhPath ? html`
|
||||
<sts-bvh-viewer
|
||||
bvhPath=${this.bvhPath}
|
||||
skyboxId="test_scene"
|
||||
@scene-state=${this.onSceneStateChange}
|
||||
></sts-bvh-viewer>
|
||||
` : this.mixamoPath ? html`
|
||||
<sts-mixamo-viewer
|
||||
mixamoPath=${this.mixamoPath}
|
||||
skyboxId="test_scene"
|
||||
@scene-state=${this.onSceneStateChange}
|
||||
></sts-mixamo-viewer>
|
||||
` : html`
|
||||
<sts-viewer
|
||||
objectId="sample-room.gltf"
|
||||
skyboxId="test_scene"
|
||||
.mode=${this.mode}
|
||||
@scene-state=${this.onSceneStateChange}
|
||||
></sts-viewer>
|
||||
`}
|
||||
<!-- Scene view -->
|
||||
<sts-studio
|
||||
.objectId=${this.#params.objectId}
|
||||
.bvhPath=${this.#params.bvh}
|
||||
.mixamoPath=${this.#params.mixamo}
|
||||
.sceneImport=${this.#params.sceneImport}
|
||||
.storytellerScene=${this.#params.scene}
|
||||
.skyboxId=${this.#params.skybox ?? "test_scene"}
|
||||
.mode=${this.#params.mode}
|
||||
></sts-studio>
|
||||
|
||||
<!-- Actions toolbar -->
|
||||
<sts-toolbar class="v-start h-start">
|
||||
<sts-toolbar-group>
|
||||
${this.#params.mode === StudioMode.Editor ? html`
|
||||
<sts-button
|
||||
icon="floppy-disk"
|
||||
@click=${() => studio.saveScene()}
|
||||
>
|
||||
Save
|
||||
</sts-button>
|
||||
` : nothing}
|
||||
<sts-button
|
||||
class=${classMap({ active: this.showHelp })}
|
||||
icon="circle-info"
|
||||
icon="question"
|
||||
@click=${this.toggleHelp}
|
||||
>
|
||||
Help
|
||||
@@ -233,26 +120,14 @@ export class AppElement extends LitElement {
|
||||
</sts-toolbar-group>
|
||||
</sts-toolbar>
|
||||
|
||||
${this.mode === StudioMode.Editor ? html`
|
||||
<sts-transform-toolbar
|
||||
class="v-start h-center"
|
||||
.type=${this.transformType}
|
||||
@type-change=${this.onTransformTypeChange}
|
||||
.space=${this.transformSpace}
|
||||
@space-change=${this.onTransformSpaceChange}
|
||||
></sts-transform-toolbar>
|
||||
|
||||
<div class="hierarchy v-start h-end">
|
||||
${this.sceneObjects.map(obj => html`
|
||||
<sts-tree class="scene-object"
|
||||
id=${obj.id}
|
||||
.name=${obj.name}
|
||||
.treeChildren=${obj.children}
|
||||
></sts-tree>
|
||||
`)}
|
||||
</div>
|
||||
<!-- Studio-mode-only elements -->
|
||||
${this.#params.mode === StudioMode.Editor ? html`
|
||||
<sts-transform-toolbar class="v-start h-center"></sts-transform-toolbar>
|
||||
<sts-scene-hierarchy class="hierarchy v-start h-end"></sts-scene-hierarchy>
|
||||
<sts-anim-editor class="timeline v-end h-start h-before-hierarchy"></sts-anim-editor>
|
||||
` : nothing}
|
||||
|
||||
<!-- Dialogs and modals -->
|
||||
${this.showHelp ? html`
|
||||
<sts-help class="help v-after-toolbar h-start"></sts-help>
|
||||
` : nothing}
|
||||
@@ -266,7 +141,3 @@ export class AppElement extends LitElement {
|
||||
</sts-canvas-provider>
|
||||
`;
|
||||
}
|
||||
|
||||
function sortByName(a: { name: string }, b: { name: string }): number {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export class HelpElement extends LitElement {
|
||||
${section("Navigation", [
|
||||
{ name: "Pan View", controls: "MMB" },
|
||||
{ name: "Orbit around point", controls: "Alt + LMB" },
|
||||
{ name: "Zoom", controls: ["Alt + RMB", "Mouse Wheel"]},
|
||||
{ name: "Zoom", controls: ["Alt + RMB", "Scroll"]},
|
||||
{ name: "Free fly", controls: "W, A, S, D, E, Q" },
|
||||
{ name: "Free look", controls: ["RMB", "Shift"] },
|
||||
])}
|
||||
@@ -21,6 +21,11 @@ export class HelpElement extends LitElement {
|
||||
{ name: "Select", controls: "LMB" },
|
||||
{ name: "Clear selection", controls: "Esc" },
|
||||
{ name: "Focus selection", controls: "F" },
|
||||
{ name: "Delete selection", controls: "Delete" },
|
||||
])}
|
||||
${section("Animation Timeline", [
|
||||
{ name: "Pan", controls: ["MMB", "Scroll"] },
|
||||
{ name: "Zoom", controls: "Ctrl + Scroll" },
|
||||
])}
|
||||
${isDevEnvironment()
|
||||
? section("Developer", [
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { spread } from "@storyteller/framework";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, TemplateResult, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeSVG } from "lit/directives/unsafe-svg.js";
|
||||
|
||||
import fasCircleInfo from "@fortawesome/fontawesome-pro/svgs/regular/circle-info.svg?raw";
|
||||
import fasCube from "@fortawesome/fontawesome-pro/svgs/regular/cube.svg?raw";
|
||||
import fasGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw";
|
||||
import fasUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw";
|
||||
import fasRotate from "@fortawesome/fontawesome-pro/svgs/regular/arrows-rotate.svg?raw";
|
||||
import fasScale from "@fortawesome/fontawesome-pro/svgs/regular/arrow-up-right-and-arrow-down-left-from-center.svg?raw";
|
||||
import fasCaretRight from "@fortawesome/fontawesome-pro/svgs/solid/caret-right.svg?raw";
|
||||
|
||||
import styles from "./icon.element.scss?inline";
|
||||
|
||||
@customElement("sts-icon")
|
||||
export class IconElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property() icon = "";
|
||||
|
||||
protected override render = () => html`
|
||||
<span class="a11y-label">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<div class="svg-wrapper" role="presentation">
|
||||
${renderSvg(this.icon)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const RENDER_RESULTS = new Map<string, TemplateResult>();
|
||||
|
||||
function renderSvg(iconId: string) {
|
||||
if (RENDER_RESULTS.has(iconId))
|
||||
return RENDER_RESULTS.get(iconId)!;
|
||||
|
||||
const svgSrc = match (iconId, {
|
||||
"circle-info": () => fasCircleInfo,
|
||||
"caret-right": () => fasCaretRight,
|
||||
"cube": () => fasCube,
|
||||
"globe": () => fasGlobe,
|
||||
"up-down-left-right": () => fasUpDownLeftRight,
|
||||
"rotate": () => fasRotate,
|
||||
"scale": () => fasScale,
|
||||
_: () => null,
|
||||
});
|
||||
|
||||
if (!svgSrc) return nothing;
|
||||
|
||||
const [, attrsString, body] = svgSrc.match(/<svg(.+?)>(.+?)<\/svg>/)!;
|
||||
const attrs = Object.fromEntries(
|
||||
Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g))
|
||||
.map(([, attr, value]) => [attr, value])
|
||||
);
|
||||
|
||||
const result = html`
|
||||
<svg ${spread(attrs)}>
|
||||
${unsafeSVG(body)}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
RENDER_RESULTS.set(iconId, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import fs from "fs";
|
||||
import {NormalizedOutputOptions} from "rollup";
|
||||
|
||||
function copyRecursively(src: string, dst: string) {
|
||||
if (fs.statSync(src).isDirectory()) {
|
||||
// folder
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.mkdirSync(dst);
|
||||
}
|
||||
for (const file of fs.readdirSync(src)) {
|
||||
copyRecursively(src + "/" + file, dst + "/" + file);
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMiddleWare(options: NormalizedOutputOptions, assets: string[] = []) {
|
||||
const dst = options.dir;
|
||||
if (!dst) throw new Error("options.dir is undefined");
|
||||
for (const dir of assets) {
|
||||
copyRecursively(dir, dst)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// NOTE: This is an inline fork of this npm package:
|
||||
// https://github.com/nguyenbatranvan/vite-multiple-assets
|
||||
//
|
||||
// The published version at time of writing doesn't include a mime type for
|
||||
// WGSL, which is fixed in this copy. This module can be removed and replaced
|
||||
// with the npm dependency if/when this PR is accepted:
|
||||
// https://github.com/nguyenbatranvan/vite-multiple-assets/pull/5
|
||||
|
||||
import {PluginOption, ViteDevServer} from "vite";
|
||||
import {ServerMiddleWare} from "./server";
|
||||
import {buildMiddleWare} from "./build";
|
||||
|
||||
export default function DynamicPublicDirectory(assets: string[]): PluginOption {
|
||||
return {
|
||||
configureServer(server: ViteDevServer) {
|
||||
return ServerMiddleWare(server, assets)
|
||||
},
|
||||
async writeBundle(options) {
|
||||
buildMiddleWare(options, assets)
|
||||
},
|
||||
name: "dynamic assets"
|
||||
};
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ViteDevServer } from "vite";
|
||||
|
||||
interface IPropsFile {
|
||||
name: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function getFiles(dir, files_): string[] {
|
||||
files_ = files_ || [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const i in files) {
|
||||
const name = dir + "/" + files[i];
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
getFiles(name, files_);
|
||||
} else {
|
||||
files_.push(name);
|
||||
}
|
||||
}
|
||||
return files_;
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
".7z": "application/x-7z-compressed",
|
||||
".bin": "application/octet-stream",
|
||||
".bvh": "text/bvh",
|
||||
".css": "text/css",
|
||||
".eot": "font/eot",
|
||||
".gif": "image/gif",
|
||||
".glb": "model/gltf-binary",
|
||||
".gltf": "model/gltf+json",
|
||||
".gz": "application/gzip",
|
||||
".hdr": "image/vnd.radiance",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".ktx": "image/ktx",
|
||||
".ktx2": "image/ktx",
|
||||
".meta": "text/plain",
|
||||
".mjs": "text/javascript",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".otf": "font/otf",
|
||||
".png": "image/png",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".svg": "image/svg+xml",
|
||||
".tiff": "image/tiff",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain",
|
||||
".wasm": "application/wasm",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wgsl": "text/wgsl",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xml": "text/xml",
|
||||
".zip": "application/zip",
|
||||
}
|
||||
|
||||
export function ServerMiddleWare(server: ViteDevServer, assets: string[] = []) {
|
||||
if (!assets || !assets.length)
|
||||
return;
|
||||
|
||||
const fileObject: IPropsFile[] = [];
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
const files = getFiles(path.join(process.cwd(), `/${assets[i]}`), []);
|
||||
fileObject.push({
|
||||
name: assets[i],
|
||||
files
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
for (let i = 0; i < fileObject.length; i++) {
|
||||
const file = path.join(process.cwd(), `${fileObject[i].name}/${req.originalUrl}`);
|
||||
if (fileObject[i].files.some(f => path.relative(f, file) === "")) {
|
||||
const extension = file.substring(file.lastIndexOf("."));
|
||||
res.setHeader("Cache-Control", "max-age=31536000, immutable");
|
||||
res.setHeader("Content-Type", mimeTypes[extension]);
|
||||
res.writeHead(200);
|
||||
res.write(fs.readFileSync(path.join(process.cwd(), "/" + fileObject[i].name + req.originalUrl)));
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { defineConfig } from "vite";
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
|
||||
import mergeAssetsDirs from "./vite-plugins/multiple-assets";
|
||||
import mergeAssetsDirs from "../vite-multiple-assets";
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: "../node_modules/.vite/studio-frontend",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# studio-ui
|
||||
|
||||
This is a UI/component library that can be easily shared between multiple
|
||||
Storyteller Studio frontend applications.
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build studio-ui` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test studio-ui` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@storyteller/studio-ui",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@storyteller/framework": "0.0.1",
|
||||
"@storyteller/studio": "0.0.1",
|
||||
"lit": "^2.8.0"
|
||||
},
|
||||
"main": "./index.js",
|
||||
"module": "./index.mjs",
|
||||
"typings": "./index.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "studio-ui",
|
||||
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "studio-ui/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": ["studio"],
|
||||
"target": "build:web"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/studio-ui"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"passWithNoTests": true,
|
||||
"reportsDirectory": "../coverage/studio-ui"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./lib/button.element";
|
||||
export * from "./lib/icon.element";
|
||||
export * from "./lib/toolbar.element";
|
||||
export * from "./lib/tree.element";
|
||||
+10
-5
@@ -1,8 +1,8 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 10px 20px;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: {
|
||||
@@ -10,20 +10,25 @@
|
||||
size: 15px;
|
||||
weight: 500;
|
||||
}
|
||||
line-height: 1;
|
||||
line-height: 1.5;
|
||||
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
background: #0006;
|
||||
color: #FFF;
|
||||
color: #FFFC;
|
||||
backdrop-filter: blur(16px);
|
||||
transition: background 0.1s linear;
|
||||
transition: {
|
||||
property: background, color;
|
||||
duration: 0.1s;
|
||||
timing-function: linear;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
background: #0008;
|
||||
color: #FFFF;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -0,0 +1,101 @@
|
||||
import { spread } from "@storyteller/framework";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, TemplateResult, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeSVG } from "lit/directives/unsafe-svg.js";
|
||||
|
||||
import farCrosshairs from "@fortawesome/fontawesome-pro/svgs/regular/crosshairs.svg?raw";
|
||||
import farVideo from "@fortawesome/fontawesome-pro/svgs/regular/video.svg?raw";
|
||||
import fasCircleQuestion from "@fortawesome/fontawesome-pro/svgs/solid/circle-question.svg?raw";
|
||||
import fasKey from "@fortawesome/fontawesome-pro/svgs/solid/key.svg?raw";
|
||||
import farCube from "@fortawesome/fontawesome-pro/svgs/regular/cube.svg?raw";
|
||||
import farGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw";
|
||||
import farUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw";
|
||||
import fasPlay from "@fortawesome/fontawesome-pro/svgs/solid/play.svg?raw";
|
||||
import fasFwdStep from "@fortawesome/fontawesome-pro/svgs/solid/forward-step.svg?raw";
|
||||
import fasFwdFast from "@fortawesome/fontawesome-pro/svgs/solid/forward-fast.svg?raw";
|
||||
import fasBwdStep from "@fortawesome/fontawesome-pro/svgs/solid/backward-step.svg?raw";
|
||||
import fasBwdFast from "@fortawesome/fontawesome-pro/svgs/solid/backward-fast.svg?raw";
|
||||
import fasQuestion from "@fortawesome/fontawesome-pro/svgs/solid/question.svg?raw";
|
||||
import farRotate from "@fortawesome/fontawesome-pro/svgs/regular/arrows-rotate.svg?raw";
|
||||
import farScale from "@fortawesome/fontawesome-pro/svgs/regular/arrow-up-right-and-arrow-down-left-from-center.svg?raw";
|
||||
import fasCaretRight from "@fortawesome/fontawesome-pro/svgs/solid/caret-right.svg?raw";
|
||||
import fasFloppyDisk from "@fortawesome/fontawesome-pro/svgs/solid/floppy-disk.svg?raw";
|
||||
import farSkeleton from "@fortawesome/fontawesome-pro/svgs/regular/skeleton.svg?raw";
|
||||
import farBone from "@fortawesome/fontawesome-pro/svgs/regular/bone.svg?raw";
|
||||
import fasBrightness from "@fortawesome/fontawesome-pro/svgs/solid/brightness.svg?raw";
|
||||
import fasCheck from "@fortawesome/fontawesome-pro/svgs/solid/check.svg?raw";
|
||||
import fasXmark from "@fortawesome/fontawesome-pro/svgs/solid/xmark.svg?raw";
|
||||
import fasCircleCheck from "@fortawesome/fontawesome-pro/svgs/solid/circle-check.svg?raw";
|
||||
import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark.svg?raw";
|
||||
|
||||
import styles from "./icon.element.scss?inline";
|
||||
|
||||
@customElement("sts-icon")
|
||||
export class IconElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property() icon = "";
|
||||
|
||||
protected override render = () => html`
|
||||
<span class="a11y-label">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<div class="svg-wrapper" role="presentation">
|
||||
${renderSvg(this.icon)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const RENDER_RESULTS = new Map<string, TemplateResult>();
|
||||
|
||||
function renderSvg(iconId: string) {
|
||||
if (RENDER_RESULTS.has(iconId))
|
||||
return RENDER_RESULTS.get(iconId)!;
|
||||
|
||||
const svgSrc = match (iconId, {
|
||||
"bone": () => farBone,
|
||||
"bwd-fast": () => fasBwdFast,
|
||||
"bwd-step": () => fasBwdStep,
|
||||
"check": () => fasCheck,
|
||||
"circle-check": () => fasCircleCheck,
|
||||
"circle-question": () => fasCircleQuestion,
|
||||
"circle-xmark": () => fasCircleXmark,
|
||||
"caret-right": () => fasCaretRight,
|
||||
"crosshairs": () => farCrosshairs,
|
||||
"cube": () => farCube,
|
||||
"floppy-disk": () => fasFloppyDisk,
|
||||
"fwd-fast": () => fasFwdFast,
|
||||
"fwd-step": () => fasFwdStep,
|
||||
"key": () => fasKey,
|
||||
"light": () => fasBrightness,
|
||||
"globe": () => farGlobe,
|
||||
"play": () => fasPlay,
|
||||
"question": () => fasQuestion,
|
||||
"rotate": () => farRotate,
|
||||
"scale": () => farScale,
|
||||
"skeleton": () => farSkeleton,
|
||||
"up-down-left-right": () => farUpDownLeftRight,
|
||||
"video": () => farVideo,
|
||||
"xmark": () => fasXmark,
|
||||
_: () => null,
|
||||
});
|
||||
|
||||
if (!svgSrc) return nothing;
|
||||
|
||||
const [, attrsString, body] = svgSrc.match(/<svg(.+?)>(.+?)<\/svg>/)!;
|
||||
const attrs = Object.fromEntries(
|
||||
Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g))
|
||||
.map(([, attr, value]) => [attr, value])
|
||||
);
|
||||
|
||||
const result = html`
|
||||
<svg ${spread(attrs)}>
|
||||
${unsafeSVG(body)}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
RENDER_RESULTS.set(iconId, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
$item-height: 24px;
|
||||
|
||||
:host, .children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -31,7 +33,7 @@ header {
|
||||
timing-function: linear;
|
||||
}
|
||||
|
||||
&:hover:not(:has(.toggle-expanded:hover)) {
|
||||
&.selectable:hover:not(:has(.toggle-expanded:hover)) {
|
||||
background: #0000001F;
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ header {
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
height: $item-height;
|
||||
margin-block: -5px;
|
||||
padding-inline-start: 4px;
|
||||
|
||||
@@ -68,16 +70,21 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
.icon.expanded {
|
||||
.caret.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.name {
|
||||
.label {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
padding: {
|
||||
block: 5px;
|
||||
inline: 5px 10px;
|
||||
}
|
||||
height: $item-height;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline-start: 5px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.children {
|
||||
@@ -4,6 +4,7 @@ import { type EntitySelectEvent } from "@storyteller/studio";
|
||||
import { LitElement, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import "./icon.element";
|
||||
|
||||
@@ -12,6 +13,7 @@ import styles from "./tree.element.scss?inline";
|
||||
export interface Tree {
|
||||
id?: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
children?: Tree[];
|
||||
}
|
||||
|
||||
@@ -20,8 +22,9 @@ export class TreeElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property() name = "";
|
||||
@property() treeChildren?: Tree[];
|
||||
@property() expanded = false;
|
||||
@property() icon?: string;
|
||||
@property({ attribute: false }) treeChildren?: Tree[];
|
||||
@property({ type: Boolean }) expanded = true;
|
||||
|
||||
@state() selected = false;
|
||||
|
||||
@@ -35,19 +38,25 @@ export class TreeElement extends LitElement {
|
||||
}
|
||||
|
||||
selectEntity(): void {
|
||||
studio.select(this.id);
|
||||
if (this.id)
|
||||
studio.select(this.id);
|
||||
}
|
||||
|
||||
// TODO: A11y
|
||||
protected override render = () => html`
|
||||
<header class=${classMap({ selected: this.selected })}>
|
||||
<header
|
||||
class=${classMap({
|
||||
selected: this.selected,
|
||||
selectable: !!this.id,
|
||||
})}
|
||||
>
|
||||
${this.treeChildren?.length ? html`
|
||||
<button class="toggle-expanded"
|
||||
@click=${this.toggleExpanded}
|
||||
>
|
||||
<sts-icon
|
||||
class=${classMap({
|
||||
icon: true,
|
||||
caret: true,
|
||||
expanded: this.expanded,
|
||||
})}
|
||||
icon="caret-right"
|
||||
@@ -55,9 +64,12 @@ export class TreeElement extends LitElement {
|
||||
></sts-icon>
|
||||
</button>
|
||||
` : nothing}
|
||||
<span class="name"
|
||||
<span class="label"
|
||||
@click=${this.selectEntity}
|
||||
>
|
||||
${this.icon ? html`
|
||||
<sts-icon class="icon" icon=${this.icon}></sts-icon>
|
||||
` : nothing}
|
||||
${this.name}
|
||||
</span>
|
||||
</header>
|
||||
@@ -66,8 +78,9 @@ export class TreeElement extends LitElement {
|
||||
<div class="children">
|
||||
${this.treeChildren?.map(tree => html`
|
||||
<sts-tree
|
||||
id=${tree.id}
|
||||
id=${ifDefined(tree.id)}
|
||||
.name=${tree.name}
|
||||
.icon=${tree.icon}
|
||||
.treeChildren=${tree.children}
|
||||
></sts-tree>
|
||||
`)}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// <reference types='vitest' />
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import * as path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: "../node_modules/.vite/studio-web",
|
||||
|
||||
plugins: [
|
||||
nxViteTsPaths(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsConfigFilePath: path.join(__dirname, "tsconfig.lib.json"),
|
||||
skipDiagnostics: true,
|
||||
}),
|
||||
],
|
||||
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points.
|
||||
entry: "src/index.ts",
|
||||
name: "studio-ui",
|
||||
fileName: "index",
|
||||
// Change this to the formats you want to support.
|
||||
// Don't forget to update your package.json as well.
|
||||
formats: ["es", "cjs"],
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: [
|
||||
"lit",
|
||||
"@storyteller/framework",
|
||||
"@storyteller/studio",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
globals: true,
|
||||
cache: {
|
||||
dir: "../node_modules/.vitest",
|
||||
},
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./lib/bvh-viewer.element";
|
||||
export * from "./lib/canvas-provider.element";
|
||||
export * from "./lib/viewer.element";
|
||||
export * from "./lib/scene-hierarchy.element";
|
||||
export * from "./lib/studio.element";
|
||||
export * from "./lib/transform-toolbar.element";
|
||||
export * from "./lib/url-params";
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { ViewportSize } from "@storyteller/studio";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { StudioBaseElement } from "./studio-base.element";
|
||||
import { PropertyValues } from "lit";
|
||||
|
||||
@customElement("sts-bvh-viewer")
|
||||
export class BvhViewerElement extends StudioBaseElement {
|
||||
@property() bvhPath?: string;
|
||||
@property() skyboxId?: string;
|
||||
|
||||
onStart(viewportSize: ViewportSize): void {
|
||||
if (!this.bvhPath) return;
|
||||
if (!this.skyboxId) return;
|
||||
|
||||
studio.startBvhViewer(
|
||||
this.bvhPath,
|
||||
this.skyboxId,
|
||||
`#${this._canvasProvider!.canvasId}`,
|
||||
"Storyteller Studio",
|
||||
viewportSize,
|
||||
);
|
||||
}
|
||||
|
||||
onChanges(changes: PropertyValues<this>): void {
|
||||
if (changes.has("bvhPath") && !!this.bvhPath) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
if (changes.has("skyboxId") && !!this.skyboxId)
|
||||
studio.setSkybox(this.skyboxId);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Token, UniqueToken, provide } from "@storyteller/framework";
|
||||
import { Token, UniqueToken, on, provide } from "@storyteller/framework";
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
@@ -71,6 +71,35 @@ export class CanvasProviderElement
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
@on("keydown")
|
||||
@on("keyup")
|
||||
_pipeKeyboardEvents(event: KeyboardEvent): void {
|
||||
// When the user interacts with one of the web UI elements, the Bevy
|
||||
// canvas will lose focus, preventing it from receiving keyboard events.
|
||||
// As a workaround, we can listen for any events that have bubbled up to
|
||||
// the app root and repeat them for the canvas.
|
||||
//
|
||||
// This does unfortunately burden the web front-end with some additional
|
||||
// responsibility: when we handle a keyboard event and _don't_ want the
|
||||
// Bevy app to receive it, we'll need to call `event.stopPropagation()` in
|
||||
// the handler to prevent it from reaching this listener.
|
||||
const {
|
||||
type,
|
||||
code, charCode, key, keyCode, location, repeat,
|
||||
altKey, ctrlKey, metaKey, shiftKey,
|
||||
} = event;
|
||||
|
||||
document
|
||||
.getElementById(this.#canvasId)
|
||||
?.dispatchEvent(new KeyboardEvent(type, {
|
||||
code, charCode, key, keyCode, location, repeat,
|
||||
altKey, ctrlKey, metaKey, shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<slot></slot>
|
||||
<slot name="canvas"></slot>
|
||||
@@ -80,6 +109,7 @@ export class CanvasProviderElement
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = this.#canvasId;
|
||||
canvas.slot = "canvas";
|
||||
canvas.tabIndex = -1;
|
||||
|
||||
this.appendChild(canvas);
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { ViewportSize } from "@storyteller/studio";
|
||||
import { PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { StudioBaseElement } from "./studio-base.element";
|
||||
|
||||
@customElement("sts-mixamo-viewer")
|
||||
export class MixamoViewerElement extends StudioBaseElement {
|
||||
@property() mixamoPath?: string;
|
||||
@property() skyboxId?: string;
|
||||
|
||||
onStart(viewportSize: ViewportSize): void {
|
||||
if (!this.mixamoPath) return;
|
||||
if (!this.skyboxId) return;
|
||||
|
||||
studio.startMixamoViewer(
|
||||
this.mixamoPath,
|
||||
this.skyboxId,
|
||||
`#${this._canvasProvider!.canvasId}`,
|
||||
"Storyteller Studio",
|
||||
viewportSize,
|
||||
);
|
||||
}
|
||||
|
||||
onChanges(changes: PropertyValues<this>): void {
|
||||
if (changes.has("mixamoPath") && !!this.mixamoPath) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
if (changes.has("skyboxId") && !!this.skyboxId)
|
||||
studio.setSkybox(this.skyboxId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 1rem;
|
||||
overflow: auto;
|
||||
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
border: 8px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
&-thumb {
|
||||
border-radius: 2px;
|
||||
background: #FFF2;
|
||||
|
||||
&:hover {
|
||||
background: #FFF3;
|
||||
}
|
||||
}
|
||||
|
||||
&-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { on } from "@storyteller/framework";
|
||||
import * as studio from "@storyteller/studio";
|
||||
import {
|
||||
SceneElement,
|
||||
type EntityDespawnEvent,
|
||||
type EntityMultiSpawnEvent,
|
||||
type EntitySpawnEvent,
|
||||
} from "@storyteller/studio";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import styles from "./scene-hierarchy.element.scss?inline";
|
||||
|
||||
import "@storyteller/studio-ui/tree";
|
||||
|
||||
interface SceneObject extends Omit<studio.SceneObject, "icon"|"children"> {
|
||||
icon?: string;
|
||||
children: SceneObject[];
|
||||
}
|
||||
|
||||
@customElement("sts-scene-hierarchy")
|
||||
export class SceneHierarchyElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
get tree() { return this._tree as readonly SceneObject[]; }
|
||||
get entityMap() { return this.#entityMap as ReadonlyMap<string, SceneObject>; }
|
||||
|
||||
@state() private _tree: SceneObject[] = [];
|
||||
#sceneObjectsFlat: studio.SceneObject[] = [];
|
||||
#entityMap = new Map<string, SceneObject>();
|
||||
|
||||
@on("window:entity-spawn")
|
||||
@on("window:entity-multi-spawn")
|
||||
_onEntitiesSpawned({ detail: entities }: EntitySpawnEvent | EntityMultiSpawnEvent): void {
|
||||
this.#sceneObjectsFlat = this.#sceneObjectsFlat.concat(entities);
|
||||
this.#rebuildTree();
|
||||
}
|
||||
|
||||
@on("window:entity-despawn")
|
||||
_onEntityDespawn({ detail: entity }: EntityDespawnEvent): void {
|
||||
this.#sceneObjectsFlat = this.#sceneObjectsFlat.filter(({ id }) => id !== entity);
|
||||
this.#rebuildTree();
|
||||
}
|
||||
|
||||
#rebuildTree(): void {
|
||||
this.#entityMap.clear();
|
||||
|
||||
for (let obj of this.#sceneObjectsFlat) {
|
||||
this.#entityMap.set(obj.id, {
|
||||
...obj,
|
||||
icon: match (obj.type, {
|
||||
[SceneElement.Generic]: () => "cube",
|
||||
[SceneElement.Mesh]: () => "cube", // TODO ?
|
||||
[SceneElement.Skeleton]: () => "skeleton",
|
||||
[SceneElement.Bone]: () => "bone",
|
||||
[SceneElement.Light]: () => "light",
|
||||
[SceneElement.Camera]: () => "video",
|
||||
}),
|
||||
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((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
this._tree = Array
|
||||
.from(this.#entityMap.values())
|
||||
.filter(obj => obj.parent == null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
${this.tree.map(obj => html`
|
||||
<sts-tree
|
||||
id=${obj.id}
|
||||
.name=${obj.name}
|
||||
.icon=${obj.icon}
|
||||
.treeChildren=${obj.children}
|
||||
></sts-tree>
|
||||
`)}
|
||||
`
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { inject } from "@storyteller/framework";
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { ViewportSize } from "@storyteller/studio";
|
||||
import { LitElement, PropertyValues, html, unsafeCSS } from "lit";
|
||||
|
||||
import { CANVAS_PROVIDER, type CanvasProvider } from "./canvas-provider.element";
|
||||
|
||||
import styles from "./studio-base.element.scss?inline";
|
||||
|
||||
export abstract class StudioBaseElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@inject(CANVAS_PROVIDER)
|
||||
_canvasProvider?: CanvasProvider;
|
||||
|
||||
#canvas?: HTMLCanvasElement;
|
||||
#resizeObserver?: ResizeObserver;
|
||||
|
||||
abstract onStart(viewportSize: ViewportSize): void;
|
||||
abstract onChanges?(changes: PropertyValues<this>): void;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this));
|
||||
this.#resizeObserver.observe(this);
|
||||
|
||||
if (!this._canvasProvider)
|
||||
throw new Error(`No provider found for token: ${CANVAS_PROVIDER}`);
|
||||
|
||||
if ((this.#canvas = this._canvasProvider.takeCanvas()))
|
||||
this.appendChild(this.#canvas);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
if (this.#canvas)
|
||||
this._canvasProvider?.returnCanvas(this.removeChild(this.#canvas));
|
||||
|
||||
this.#resizeObserver?.disconnect();
|
||||
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changes: PropertyValues<this>): void {
|
||||
if (!studio.isInitialized()) {
|
||||
this.#start();
|
||||
} else {
|
||||
this.onChanges?.(changes);
|
||||
}
|
||||
}
|
||||
|
||||
#onResize(): void {
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
studio.resize(new ViewportSize(width, height));
|
||||
}
|
||||
|
||||
#start(): void {
|
||||
if (!this.#canvas) return;
|
||||
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
try {
|
||||
this.onStart(new ViewportSize(width, height));
|
||||
} catch (err) {
|
||||
if (!(err as Error).message?.startsWith("Using exceptions for control flow"))
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<slot name="canvas"></slot>
|
||||
`;
|
||||
}
|
||||
@@ -10,4 +10,5 @@
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none !important;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { inject } from "@storyteller/framework";
|
||||
import * as studio from "@storyteller/studio";
|
||||
import {
|
||||
type InitOptions,
|
||||
StudioMode,
|
||||
ViewportSize,
|
||||
} from "@storyteller/studio";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { CANVAS_PROVIDER, type CanvasProvider } from "./canvas-provider.element";
|
||||
|
||||
import styles from "./studio.element.scss?inline";
|
||||
|
||||
@customElement("sts-studio")
|
||||
export class StudioElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property() objectId?: string;
|
||||
@property() bvhPath?: string;
|
||||
@property() mixamoPath?: string;
|
||||
@property() sceneImport?: string;
|
||||
@property() storytellerScene?: string;
|
||||
@property() skyboxId?: string;
|
||||
@property({ type: Number }) mode?: StudioMode;
|
||||
|
||||
@inject(CANVAS_PROVIDER)
|
||||
_canvasProvider?: CanvasProvider;
|
||||
|
||||
#canvas?: HTMLCanvasElement;
|
||||
#resizeObserver?: ResizeObserver;
|
||||
#frameRequest?: number;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this));
|
||||
this.#resizeObserver.observe(this);
|
||||
|
||||
if (!this._canvasProvider)
|
||||
throw new Error(`No provider found for token: ${CANVAS_PROVIDER}`);
|
||||
|
||||
if ((this.#canvas = this._canvasProvider.takeCanvas()))
|
||||
this.appendChild(this.#canvas);
|
||||
|
||||
this.#tick();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
if (this.#frameRequest != null)
|
||||
cancelAnimationFrame(this.#frameRequest);
|
||||
|
||||
if (this.#canvas)
|
||||
this._canvasProvider?.returnCanvas(this.removeChild(this.#canvas));
|
||||
|
||||
this.#resizeObserver?.disconnect();
|
||||
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changes: PropertyValues<this>): void {
|
||||
if (!studio.isInitialized()) {
|
||||
this.#start();
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
#onResize(): void {
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
|
||||
studio.resize(new ViewportSize(width, height));
|
||||
|
||||
this.dispatchEvent(new CustomEvent("viewport-resized", {
|
||||
detail: { width, height },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
#tick(): void {
|
||||
// If user focus lands on the `document.body`, neither the frontend app
|
||||
// nor the Bevy app will properly handle keyboard events. Thie function
|
||||
// runs every tick to ensure that user focus never becomes "stranded" in
|
||||
// this way.
|
||||
if (document.activeElement === document.body)
|
||||
this.#canvas?.focus();
|
||||
|
||||
this.#frameRequest = requestAnimationFrame(() => this.#tick());
|
||||
}
|
||||
|
||||
#start(): void {
|
||||
if (!this.#canvas) return;
|
||||
if (this.mode == null) return;
|
||||
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
const options: InitOptions = {
|
||||
mode: this.mode,
|
||||
canvasSelector: `#${this._canvasProvider?.canvasId}`,
|
||||
canvasAlt: "Storyteller Studio",
|
||||
viewportSize: new ViewportSize(width, height),
|
||||
};
|
||||
|
||||
try {
|
||||
if (
|
||||
this.skyboxId
|
||||
&& (this.objectId || this.bvhPath || this.mixamoPath || this.sceneImport)
|
||||
) {
|
||||
options.skyboxId = this.skyboxId;
|
||||
|
||||
if (this.objectId) {
|
||||
studio.initFromLibrary(this.objectId, options);
|
||||
} else if (this.bvhPath) {
|
||||
studio.initFromBvh(this.bvhPath, options);
|
||||
} else if (this.mixamoPath) {
|
||||
studio.initFromMixamo(this.mixamoPath, options);
|
||||
} else if (this.sceneImport) {
|
||||
studio.initFromSceneImport(this.sceneImport, options);
|
||||
}
|
||||
}
|
||||
else if (this.storytellerScene) {
|
||||
studio.initFromStorytellerScene(this.storytellerScene, options);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err as Error).message?.startsWith("Using exceptions for control flow"))
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<slot name="canvas" part="canvas"></slot>
|
||||
<slot></slot>
|
||||
`;
|
||||
}
|
||||
+20
-22
@@ -1,7 +1,11 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { TransformSpace, TransformType } from "@storyteller/studio";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import "@storyteller/studio-ui/icon";
|
||||
import "@storyteller/studio-ui/toolbar";
|
||||
|
||||
import styles from "./transform-toolbar.element.scss?inline";
|
||||
|
||||
@@ -9,12 +13,15 @@ import styles from "./transform-toolbar.element.scss?inline";
|
||||
export class TransformToolbarElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property({ type: Number }) space = TransformSpace.Local;
|
||||
@property({ type: Number }) type = TransformType.Translate;
|
||||
get space() { return this._space; }
|
||||
@state() private _space = TransformSpace.Local;
|
||||
|
||||
get type() { return this._type; }
|
||||
@state() private _type = TransformType.Translate;
|
||||
|
||||
#onSpaceChange(event: InputEvent): void {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
const space = match (value, {
|
||||
this._space = match (value, {
|
||||
"local": () => TransformSpace.Local,
|
||||
"world": () => TransformSpace.World,
|
||||
_: () => {
|
||||
@@ -22,11 +29,7 @@ export class TransformToolbarElement extends LitElement {
|
||||
}
|
||||
});
|
||||
|
||||
this.dispatchEvent(new CustomEvent<TransformSpace>("space-change", {
|
||||
detail: space,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
studio.setTransformSpace(this._space);
|
||||
}
|
||||
|
||||
#onTypeChange(event: InputEvent): void {
|
||||
@@ -40,15 +43,10 @@ export class TransformToolbarElement extends LitElement {
|
||||
}
|
||||
});
|
||||
|
||||
const type = checked
|
||||
? this.type | flag
|
||||
: this.type & ~flag;
|
||||
if (checked) this._type |= flag;
|
||||
else this._type &= ~flag;
|
||||
|
||||
this.dispatchEvent(new CustomEvent<TransformType>("type-change", {
|
||||
detail: type,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
studio.setTransformType(this._type);
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
@@ -59,7 +57,7 @@ export class TransformToolbarElement extends LitElement {
|
||||
<input type="radio"
|
||||
name="transform-space"
|
||||
value="local"
|
||||
?checked=${this.space === TransformSpace.Local}
|
||||
?checked=${this._space === TransformSpace.Local}
|
||||
@input=${this.#onSpaceChange}
|
||||
/>
|
||||
</label>
|
||||
@@ -68,7 +66,7 @@ export class TransformToolbarElement extends LitElement {
|
||||
<input type="radio"
|
||||
name="transform-space"
|
||||
value="world"
|
||||
?checked=${this.space === TransformSpace.World}
|
||||
?checked=${this._space === TransformSpace.World}
|
||||
@input=${this.#onSpaceChange}
|
||||
/>
|
||||
</label>
|
||||
@@ -78,7 +76,7 @@ export class TransformToolbarElement extends LitElement {
|
||||
<sts-icon icon="up-down-left-right">Translate</sts-icon>
|
||||
<input type="checkbox"
|
||||
name="translate"
|
||||
?checked=${Boolean(this.type & TransformType.Translate)}
|
||||
?checked=${Boolean(this._type & TransformType.Translate)}
|
||||
@input=${this.#onTypeChange}
|
||||
/>
|
||||
</label>
|
||||
@@ -86,7 +84,7 @@ export class TransformToolbarElement extends LitElement {
|
||||
<sts-icon icon="rotate">Rotate</sts-icon>
|
||||
<input type="checkbox"
|
||||
name="rotate"
|
||||
?checked=${Boolean(this.type & TransformType.Rotate)}
|
||||
?checked=${Boolean(this._type & TransformType.Rotate)}
|
||||
@input=${this.#onTypeChange}
|
||||
/>
|
||||
</label>
|
||||
@@ -95,7 +93,7 @@ export class TransformToolbarElement extends LitElement {
|
||||
<input type="checkbox"
|
||||
name="scale"
|
||||
disabled
|
||||
?checked=${Boolean(this.type & TransformType.Scale)}
|
||||
?checked=${Boolean(this._type & TransformType.Scale)}
|
||||
@input=${this.#onTypeChange}
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StudioMode } from "@storyteller/studio";
|
||||
import { match } from "@storyteller/utility";
|
||||
|
||||
export class StudioURLParams {
|
||||
#_nativeParams?: URLSearchParams;
|
||||
get #nativeParams() {
|
||||
return this.#_nativeParams ??= new URLSearchParams(document.location.search);
|
||||
}
|
||||
|
||||
get objectId() { return this.#nativeParams.get("objectId") ?? undefined; }
|
||||
get bvh() { return this.#nativeParams.get("bvh") ?? undefined; }
|
||||
get mixamo() { return this.#nativeParams.get("mixamo") ?? undefined; }
|
||||
get sceneImport() { return this.#nativeParams.get("sceneImport") ?? undefined; }
|
||||
get scene() { return this.#nativeParams.get("scene") ?? undefined; }
|
||||
get skybox() { return this.#nativeParams.get("skybox") ?? undefined; }
|
||||
get mode(): StudioMode {
|
||||
return match(this.#nativeParams.get("mode"), {
|
||||
"studio": () => StudioMode.Editor,
|
||||
"viewer": () => StudioMode.Viewer,
|
||||
_: () => StudioMode.Viewer,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { StudioMode, ViewportSize } from "@storyteller/studio";
|
||||
import { PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { StudioBaseElement } from "./studio-base.element";
|
||||
|
||||
@customElement("sts-viewer")
|
||||
export class ViewerElement extends StudioBaseElement {
|
||||
@property() objectId?: string;
|
||||
@property() skyboxId?: string;
|
||||
@property({ type: Number }) mode?: StudioMode;
|
||||
|
||||
onStart(viewportSize: ViewportSize): void {
|
||||
if (!this.objectId) return;
|
||||
if (!this.skyboxId) return;
|
||||
if (this.mode == null) return;
|
||||
|
||||
studio.start(
|
||||
this.objectId,
|
||||
this.skyboxId,
|
||||
`#${this._canvasProvider!.canvasId}`,
|
||||
"Storyteller Studio",
|
||||
viewportSize,
|
||||
this.mode,
|
||||
);
|
||||
}
|
||||
|
||||
onChanges(changes: PropertyValues<this>): void {
|
||||
if (changes.has("objectId") && !!this.objectId)
|
||||
studio.loadObject(this.objectId);
|
||||
|
||||
if (changes.has("skyboxId") && !!this.skyboxId)
|
||||
studio.setSkybox(this.skyboxId);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementTagNameMap {
|
||||
"sts-viewer": ViewerElement;
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -24,11 +24,12 @@ bevy_mod_outline = "0.6.0"
|
||||
bevy_web_asset = "0.7.0"
|
||||
bitflags = "2.4.0"
|
||||
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||
itertools = "0.12.1"
|
||||
js-sys = { version = "0.3.65", optional = true }
|
||||
parking_lot = "0.12.1"
|
||||
serde = { version = "1.0.192", optional = true }
|
||||
serde = "1.0.192"
|
||||
serde-wasm-bindgen = { version = "0.6.1", optional = true }
|
||||
serde_json = { version = "1.0.108", optional = true }
|
||||
serde_json = "1.0.108"
|
||||
studio-bvh = { path = "../studio-bvh" }
|
||||
wasm-bindgen = "0.2.84"
|
||||
web-sys = { version = "0.3.65", optional = true, features = [
|
||||
@@ -47,13 +48,20 @@ image = "0.24.8"
|
||||
bytemuck = "1.14.1"
|
||||
space_editor = {git = "https://github.com/rewin123/space_editor", rev = "ce83f86dc902787ab16213f3209276a784654df6"}
|
||||
anyhow.workspace = true
|
||||
serde_repr = "0.1.18"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.11.24"
|
||||
features = [
|
||||
"default",
|
||||
"json",
|
||||
"stream",
|
||||
]
|
||||
|
||||
[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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-3
@@ -27,7 +27,7 @@
|
||||
"executor": "nx:run-commands",
|
||||
"inputs": ["assets"],
|
||||
"options": {
|
||||
"command": "node scripts/build-web.mjs",
|
||||
"command": "node scripts/build-web.mjs --features=wasm",
|
||||
"cwd": "studio"
|
||||
}
|
||||
},
|
||||
@@ -41,8 +41,8 @@
|
||||
"headless": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "cargo run -p studio --bin studio-headless -- {args}",
|
||||
}
|
||||
"command": "cargo run -p studio --bin studio-headless -- {args}"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nxrs/cargo:test",
|
||||
|
||||
@@ -13,6 +13,12 @@ async function main() {
|
||||
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
|
||||
const workspaceRoot = path.resolve(__dirname, "../..");
|
||||
|
||||
const extraCargoArgs = process.argv.slice(2);
|
||||
console.log(`Extra cargo args:`);
|
||||
for (let arg of extraCargoArgs) {
|
||||
console.log(` ${arg}`);
|
||||
}
|
||||
|
||||
/** @type import("node:child_process").StdioOptions */
|
||||
const stdio = ["ignore", "inherit", "inherit"];
|
||||
|
||||
@@ -26,9 +32,8 @@ async function main() {
|
||||
"-p", "studio",
|
||||
"--lib",
|
||||
"--release",
|
||||
"--features", "wasm",
|
||||
"--target", "wasm32-unknown-unknown",
|
||||
], {
|
||||
].concat(extraCargoArgs), {
|
||||
shell: true,
|
||||
cwd: workspaceRoot,
|
||||
stdio,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use bevy::prelude::*;
|
||||
use itertools::Itertools;
|
||||
use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
|
||||
use crate::math::{InvLerp, Lerp};
|
||||
|
||||
use super::GlobalSeekTime;
|
||||
|
||||
pub struct CameraAnimPlugin;
|
||||
impl Plugin for CameraAnimPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<CameraKeyframe>()
|
||||
.editor_registry::<CameraKeyframe>()
|
||||
.register_type::<CameraTargetKeyframe>()
|
||||
.editor_registry::<CameraTargetKeyframe>();
|
||||
|
||||
app.add_systems(Update, draw_camera_keyframes);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
wasm::spawn_camera_keyframes,
|
||||
wasm::notify_keyframe_changes.map(bevy::utils::error),
|
||||
wasm::process_update_keyframe,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraKeyframe(pub f32);
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraTargetKeyframe(pub f32);
|
||||
|
||||
fn draw_camera_keyframes(
|
||||
mut gizmos: Gizmos,
|
||||
r_seek_time: Res<GlobalSeekTime>,
|
||||
q_cam_keyframe_xforms: Query<&GlobalTransform, With<CameraKeyframe>>,
|
||||
q_cam_target_keyframe_xforms: Query<&GlobalTransform, With<CameraTargetKeyframe>>,
|
||||
q_cam_keyframes: Query<(&CameraKeyframe, &GlobalTransform)>,
|
||||
) {
|
||||
if q_cam_keyframes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for world_xform in q_cam_keyframe_xforms
|
||||
.iter()
|
||||
.chain(q_cam_target_keyframe_xforms.iter())
|
||||
{
|
||||
let origin = world_xform.translation();
|
||||
let x_axis = world_xform.right() * 0.25;
|
||||
let y_axis = world_xform.up() * 0.25;
|
||||
let z_axis = world_xform.back() * 0.25;
|
||||
|
||||
gizmos.line(origin, origin + x_axis, Color::RED);
|
||||
gizmos.line(origin, origin + y_axis, Color::GREEN);
|
||||
gizmos.line(origin, origin + z_axis, Color::BLUE);
|
||||
}
|
||||
|
||||
let seek_time = **r_seek_time;
|
||||
let keyframes_sorted = q_cam_keyframes
|
||||
.iter()
|
||||
.sorted_by(|(a, _), (b, _)| a.partial_cmp(&b.0).unwrap())
|
||||
.collect_vec();
|
||||
|
||||
let (timestamp_first, xform_first) = keyframes_sorted.first().unwrap();
|
||||
let (timestamp_last, xform_last) = keyframes_sorted.last().unwrap();
|
||||
|
||||
if seek_time <= timestamp_first.0 {
|
||||
let (_, rot, pos) = xform_first.to_scale_rotation_translation();
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
} else if seek_time >= timestamp_last.0 {
|
||||
let (_, rot, pos) = xform_last.to_scale_rotation_translation();
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
} else if let Some(((timestamp_a, xform_a), (timestamp_b, xform_b))) = keyframes_sorted
|
||||
.iter()
|
||||
.tuple_windows()
|
||||
.find(|((a, _), (b, _))| a.0 <= seek_time && b.0 >= seek_time)
|
||||
{
|
||||
let t = seek_time.inv_lerp(timestamp_a.0, timestamp_b.0);
|
||||
let (_, rot_a, pos_a) = xform_a.to_scale_rotation_translation();
|
||||
let (_, rot_b, pos_b) = xform_b.to_scale_rotation_translation();
|
||||
let pos = t.lerp(pos_a, pos_b);
|
||||
let rot = Quat::slerp(rot_a, rot_b, t);
|
||||
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use space_editor::prelude::space_undo::{self, AddedEntity};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::{
|
||||
camera_controller::CameraController,
|
||||
wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer},
|
||||
};
|
||||
|
||||
use super::{CameraKeyframe, CameraTargetKeyframe};
|
||||
|
||||
static ADD_KEYFRAME: WasmMessageBuffer<f32> = WasmMessageBuffer::new();
|
||||
static UPDATE_KEYFRAME: WasmMessageBuffer<(Entity, f32)> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = addCameraKeyframe)]
|
||||
pub fn add_camera_keyframe(timestamp: f32) {
|
||||
ADD_KEYFRAME.write_message(timestamp);
|
||||
}
|
||||
|
||||
pub(super) fn spawn_camera_keyframes(
|
||||
mut cmd: Commands,
|
||||
mut ew_changes: EventWriter<space_undo::NewChange>,
|
||||
q_camera: Query<(&CameraController, &GlobalTransform)>,
|
||||
) {
|
||||
if let Some(timestamp) = ADD_KEYFRAME.take_message() {
|
||||
let Ok((cam_controller, world_xform)) = q_camera.get_single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = cmd
|
||||
.spawn((
|
||||
Name::new("Camera Target Keyframe"),
|
||||
TransformBundle::from_transform(Transform::from_translation(
|
||||
cam_controller.focus,
|
||||
)),
|
||||
CameraTargetKeyframe(timestamp),
|
||||
))
|
||||
.id();
|
||||
ew_changes.send(space_undo::NewChange {
|
||||
change: Arc::new(AddedEntity { entity }),
|
||||
});
|
||||
|
||||
let entity = cmd
|
||||
.spawn((
|
||||
Name::new("Camera Keyframe"),
|
||||
TransformBundle::from_transform(Transform::from(*world_xform)),
|
||||
CameraKeyframe(timestamp),
|
||||
))
|
||||
.id();
|
||||
ew_changes.send(space_undo::NewChange {
|
||||
change: Arc::new(AddedEntity { entity }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = updateKeyframe)]
|
||||
pub fn update_keyframe(entity: &str, timestamp: f32) {
|
||||
let entity = Entity::from_str(entity).unwrap();
|
||||
UPDATE_KEYFRAME.write_message((entity, timestamp));
|
||||
}
|
||||
|
||||
pub(super) fn process_update_keyframe(
|
||||
mut q_camera_keyframes: Query<&mut CameraKeyframe>,
|
||||
mut q_camera_target_keyframes: Query<&mut CameraTargetKeyframe>,
|
||||
) {
|
||||
if let Some((ent, timestamp)) = UPDATE_KEYFRAME.take_message() {
|
||||
if let Ok(mut keyframe) = q_camera_keyframes.get_mut(ent) {
|
||||
**keyframe = timestamp;
|
||||
} else if let Ok(mut keyframe) = q_camera_target_keyframes.get_mut(ent) {
|
||||
**keyframe = timestamp;
|
||||
} else {
|
||||
warn!("No matching keyframe found for entity: {ent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const CAMERA_KEYFRAMES_EVENT: &str = r#"
|
||||
export interface CameraKeyframesEvent extends CustomEvent {
|
||||
type: "camera-keyframes";
|
||||
detail: Record<string, number>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"camera-keyframes": CameraKeyframesEvent;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub(super) fn notify_keyframe_changes(
|
||||
mut l_notified: Local<serde_json::Map<String, serde_json::Value>>,
|
||||
q_keyframes: Query<(Entity, &CameraKeyframe)>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
if l_notified.len() == q_keyframes.iter().count() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
l_notified.clear();
|
||||
for (ent, timestamp) in q_keyframes.iter() {
|
||||
l_notified.insert(format!("{ent:?}"), timestamp.0.into());
|
||||
}
|
||||
|
||||
wasm::dispatch_to_js(window, "camera-keyframes", &*l_notified)
|
||||
}
|
||||
}
|
||||
+36
-3
@@ -6,9 +6,10 @@ use bevy::{
|
||||
};
|
||||
use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
|
||||
use crate::hierarchy;
|
||||
use crate::{hierarchy, scene::SceneElement};
|
||||
pub use retargeting::*;
|
||||
|
||||
mod camera;
|
||||
mod retargeting;
|
||||
|
||||
pub struct AnimPlugin;
|
||||
@@ -28,11 +29,19 @@ impl Plugin for AnimPlugin {
|
||||
.editor_registry::<InvBindPose>();
|
||||
|
||||
app.add_systems(Update, process_skinned_meshes);
|
||||
app.insert_resource(GlobalSeekTime(0.));
|
||||
|
||||
app.add_plugins(retargeting::RetargetingPlugin);
|
||||
app.add_plugins((retargeting::RetargetingPlugin, camera::CameraAnimPlugin));
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(Update, wasm::update_global_seek_time);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Resource)]
|
||||
pub struct GlobalSeekTime(pub f32);
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct Skeleton;
|
||||
@@ -122,7 +131,7 @@ fn process_skinned_meshes(
|
||||
// skeleton
|
||||
let joints = joints.iter().copied().collect::<Vec<_>>();
|
||||
if let Some(root) = hierarchy::nearest_common_ancestor(&joints, &q_parents) {
|
||||
cmd.entity(root).insert(Skeleton);
|
||||
cmd.entity(root).insert((Skeleton, SceneElement::Skeleton));
|
||||
}
|
||||
|
||||
// Tag each joint and find its bind poses
|
||||
@@ -132,6 +141,7 @@ fn process_skinned_meshes(
|
||||
|
||||
cmd.entity(joint).insert((
|
||||
SkeletalJoint(Color::BLACK),
|
||||
SceneElement::Bone,
|
||||
BindPoseLocal(bind_pose_local),
|
||||
BindPoseGlobal(bind_pose_global.affine()),
|
||||
InvBindPose(bind_pose_global.affine().inverse()),
|
||||
@@ -139,3 +149,26 @@ fn process_skinned_meshes(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::wasm::{StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
use super::GlobalSeekTime;
|
||||
|
||||
static SEEK_TIME: WasmMessageBuffer<f32> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = seekTime)]
|
||||
pub fn seek_time(timestamp: f32) {
|
||||
SEEK_TIME.write_message(timestamp);
|
||||
}
|
||||
|
||||
pub(super) fn update_global_seek_time(mut r_seek_time: ResMut<GlobalSeekTime>) {
|
||||
if let Some(timestamp) = SEEK_TIME.take_message() {
|
||||
**r_seek_time = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
interaction::Selected,
|
||||
math::{
|
||||
Easing, InterpPosition, InterpVelocity, Interpolation, InterpolationSystem, NearlyEq,
|
||||
Velocity,
|
||||
NearlyZero, Velocity,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,9 +78,6 @@ fn camera_control_kinematic(
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut controller, mut xform, proj) = q_camera.single_mut();
|
||||
let mut window = q_window.single_mut();
|
||||
|
||||
let mut any_ongoing = false;
|
||||
let mut any_started = false;
|
||||
let mut any_stopped = false;
|
||||
@@ -92,7 +89,8 @@ fn camera_control_kinematic(
|
||||
)
|
||||
}) {
|
||||
if let InputAction(lifecycle, Action::Camera(motion)) = action {
|
||||
use CameraMotion::*;
|
||||
let (mut controller, mut xform, proj) = q_camera.single_mut();
|
||||
let window = q_window.single();
|
||||
|
||||
match lifecycle {
|
||||
Lifecycle::Start => any_started = true,
|
||||
@@ -100,20 +98,21 @@ fn camera_control_kinematic(
|
||||
Lifecycle::Stop => any_stopped = true,
|
||||
}
|
||||
|
||||
use CameraMotion::*;
|
||||
match motion {
|
||||
Pan(delta) => do_pan(proj, &window, *delta, &mut xform, &mut controller),
|
||||
Zoom(delta) => do_zoom(&window, *delta, &mut xform, &mut controller),
|
||||
Orbit(delta) => do_orbit(&window, *delta, *lifecycle, &mut xform, &mut controller),
|
||||
Rotate(delta) => do_rotate(&window, *delta, &mut xform, &mut controller),
|
||||
Pan(delta) => do_pan(proj, window, *delta, &mut xform, &mut controller),
|
||||
Zoom(delta) => do_zoom(window, *delta, &mut xform, &mut controller),
|
||||
Orbit(delta) => do_orbit(window, *delta, *lifecycle, &mut xform, &mut controller),
|
||||
Rotate(delta) => do_rotate(window, *delta, &mut xform, &mut controller),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if any_started {
|
||||
capture_pointer(&mut window);
|
||||
capture_pointer(&mut q_window.single_mut());
|
||||
} else if any_stopped && !any_ongoing {
|
||||
release_pointer(&mut window);
|
||||
release_pointer(&mut q_window.single_mut());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,12 +214,16 @@ fn process_focus_inputs(
|
||||
}
|
||||
|
||||
fn camera_control_physical(
|
||||
mut q_camera: Query<(&mut CameraController, &mut Transform, &Velocity)>,
|
||||
mut q_camera: Query<(Entity, &mut CameraController, &Velocity)>,
|
||||
mut q_xforms: Query<&mut Transform>,
|
||||
) {
|
||||
for (mut controller, mut xform, velocity) in q_camera.iter_mut() {
|
||||
let delta_pos = xform.rotation * (**velocity);
|
||||
xform.translation += delta_pos;
|
||||
controller.focus += delta_pos;
|
||||
for (ent, mut controller, velocity) in q_camera.iter_mut() {
|
||||
if !velocity.nearly_zero() {
|
||||
let mut xform = q_xforms.get_mut(ent).unwrap();
|
||||
let delta_pos = xform.rotation * (**velocity);
|
||||
xform.translation += delta_pos;
|
||||
controller.focus += delta_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::scene::{SceneElement, SceneSpawnerSystem, SceneState};
|
||||
|
||||
pub struct ChangeTrackingPlugin;
|
||||
|
||||
impl Plugin for ChangeTrackingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_event::<EntityChangedEvent>();
|
||||
|
||||
// Add more of these systems for each component that we want to track
|
||||
app.add_systems(Update, send_change_events::<Transform>);
|
||||
app.add_systems(
|
||||
OnEnter(SceneState::Active),
|
||||
track_scene_elements.after(SceneSpawnerSystem),
|
||||
);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(
|
||||
Update,
|
||||
wasm::send_change_events::<Transform>.map(bevy::utils::error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ChangeTracker;
|
||||
|
||||
fn send_change_events<T: Component>(
|
||||
query: Query<Entity, (Changed<T>, With<ChangeTracker>)>,
|
||||
mut entity_change_writer: EventWriter<EntityChangedEvent>,
|
||||
) {
|
||||
for entity in query.iter() {
|
||||
entity_change_writer.send(EntityChangedEvent(entity));
|
||||
}
|
||||
}
|
||||
|
||||
fn track_scene_elements(
|
||||
mut cmd: Commands,
|
||||
q_scene_elements: Query<Entity, (With<Transform>, With<SceneElement>, Without<ChangeTracker>)>,
|
||||
) {
|
||||
for element in q_scene_elements.iter() {
|
||||
cmd.entity(element).insert(ChangeTracker);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Event, Deref)]
|
||||
pub struct EntityChangedEvent(pub Entity);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
use crate::wasm;
|
||||
|
||||
use super::ChangeTracker;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const ENTITY_CHANGES_EVENT: &str = r#"
|
||||
export interface EntityChangesEvent extends CustomEvent {
|
||||
type: "entity-changes";
|
||||
detail: string[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"entity-changes": EntityChangesEvent;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub(super) fn send_change_events<T: Component>(
|
||||
q_changed_ents: Query<Entity, (Changed<T>, With<ChangeTracker>)>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
if q_changed_ents.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let entities = q_changed_ents
|
||||
.iter()
|
||||
.map(|ent| format!("{ent:?}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
wasm::dispatch_to_js(window, "entity-changes", &entities)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::{
|
||||
app::{AppExit, ScheduleRunnerPlugin},
|
||||
prelude::*,
|
||||
};
|
||||
use space_editor::prelude::{EditorEvent, EditorPrefabPath};
|
||||
use wgpu::Extent3d;
|
||||
|
||||
use crate::{
|
||||
bvh::{BvhAnimCompleteEvent, TestBvh},
|
||||
screenshot::{ScreenCapture, ScreenShotPlugin},
|
||||
BvhPlugin, CommonPlugins, LoadLibraryObjectEvent, LoadSceneEvent, MainCamera,
|
||||
MixamoImportEvent, MixamoPlugin, SkyboxId, StudioMode, WorldGridPlugin,
|
||||
};
|
||||
|
||||
#[derive(Debug, Resource, Clone)]
|
||||
pub enum HeadlessMode {
|
||||
Bvh(String, String),
|
||||
Scene(String, String),
|
||||
Mixamo(String, String),
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn start_headless(
|
||||
mode: HeadlessMode,
|
||||
fps: u32,
|
||||
outout_dir: &str,
|
||||
frames_to_capture: u32,
|
||||
image_res: Extent3d,
|
||||
) -> App {
|
||||
let mut app = App::new();
|
||||
|
||||
app.insert_resource(mode.clone());
|
||||
app.insert_resource(StudioMode::Headless);
|
||||
app.insert_resource(HeadlessSettings {
|
||||
max_frames: frames_to_capture,
|
||||
output_dims: image_res,
|
||||
output_dir: outout_dir.to_string(),
|
||||
});
|
||||
|
||||
app.add_plugins((
|
||||
ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(1.0 / fps as f64)),
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: None,
|
||||
exit_condition: bevy::window::ExitCondition::DontExit,
|
||||
close_when_requested: true,
|
||||
}),
|
||||
ScreenShotPlugin,
|
||||
CommonPlugins.build().disable::<WorldGridPlugin>(),
|
||||
));
|
||||
|
||||
match mode {
|
||||
HeadlessMode::Bvh(bvh_dir, skybox) => {
|
||||
app.add_plugins(BvhPlugin);
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut ew: EventWriter<LoadLibraryObjectEvent>| {
|
||||
cmd.insert_resource(SkyboxId(skybox.clone()));
|
||||
cmd.insert_resource(TestBvh(asset_server.load(bvh_dir.clone())));
|
||||
ew.send(LoadLibraryObjectEvent("Mannequin.gltf".to_string()));
|
||||
},
|
||||
);
|
||||
|
||||
app.add_systems(PostUpdate, check_for_bvh_capture_complete);
|
||||
}
|
||||
HeadlessMode::Scene(scene, skybox) => {
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands, mut ew: EventWriter<EditorEvent>| {
|
||||
cmd.insert_resource(SkyboxId(skybox.clone()));
|
||||
ew.send(EditorEvent::Load(EditorPrefabPath::File(scene.clone())))
|
||||
},
|
||||
);
|
||||
}
|
||||
HeadlessMode::Mixamo(mixamo_path, skybox) => {
|
||||
app.add_plugins(MixamoPlugin);
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut commands: Commands,
|
||||
mut ew: EventWriter<MixamoImportEvent>,
|
||||
mut load_scene_event: EventWriter<LoadSceneEvent>| {
|
||||
ew.send(MixamoImportEvent(mixamo_path.clone()));
|
||||
load_scene_event.send(LoadSceneEvent("Mannequin.gltf".to_string()));
|
||||
commands.insert_resource(SkyboxId(skybox.clone()));
|
||||
},
|
||||
);
|
||||
app.add_systems(PostUpdate, check_for_mixamo_capture_complete);
|
||||
|
||||
app.run();
|
||||
}
|
||||
HeadlessMode::None => {}
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
pub fn is_headless(studio_state: Res<StudioMode>) -> bool {
|
||||
*studio_state == StudioMode::Headless
|
||||
}
|
||||
|
||||
pub fn is_not_headless(studio_state: Res<StudioMode>) -> bool {
|
||||
*studio_state != StudioMode::Headless
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct HeadlessSettings {
|
||||
pub output_dims: Extent3d,
|
||||
pub output_dir: String,
|
||||
pub max_frames: u32,
|
||||
}
|
||||
|
||||
impl Default for HeadlessSettings {
|
||||
fn default() -> Self {
|
||||
HeadlessSettings {
|
||||
output_dims: Extent3d {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
output_dir: "./screen_shots/".to_string(),
|
||||
max_frames: u32::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_for_bvh_capture_complete(
|
||||
mut close_app: EventWriter<AppExit>,
|
||||
mut main_camera: Query<&mut ScreenCapture, With<MainCamera>>,
|
||||
bvh_event_reader: EventReader<BvhAnimCompleteEvent>,
|
||||
) {
|
||||
let Ok(mut screen_capture) = main_camera.get_single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
println!("Frames captured {}", screen_capture.frames_captured());
|
||||
if !screen_capture.should_capture() || !bvh_event_reader.is_empty() {
|
||||
screen_capture.stop_capturing();
|
||||
close_app.send(AppExit);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_for_mixamo_capture_complete(
|
||||
mut close_app: EventWriter<AppExit>,
|
||||
mut main_camera: Query<&mut ScreenCapture, With<MainCamera>>,
|
||||
mixamo_anims: Query<&AnimationPlayer>,
|
||||
) {
|
||||
let Ok(mut capture) = main_camera.get_single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for anim in mixamo_anims.iter() {
|
||||
println!(
|
||||
"Time elapsed {} | Frames Captured : {}",
|
||||
anim.elapsed(),
|
||||
capture.frames_captured()
|
||||
);
|
||||
if anim.completions() != 0 {
|
||||
capture.stop_capturing();
|
||||
close_app.send(AppExit);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -79,6 +79,8 @@ pub enum Selection {
|
||||
Clear,
|
||||
/// Focus the camera on the currently selected entity
|
||||
Focus,
|
||||
/// Delete the currently selected entity and its descendants
|
||||
Delete,
|
||||
}
|
||||
impl From<Selection> for Action {
|
||||
fn from(value: Selection) -> Self {
|
||||
@@ -100,6 +102,12 @@ fn translate_selection_events(keyboard: Res<Input<KeyCode>>, mut input: EventWri
|
||||
} else if keyboard.just_released(KeyCode::F) {
|
||||
input.send(InputAction(Lifecycle::Stop, Selection::Focus.into()));
|
||||
}
|
||||
|
||||
if keyboard.just_pressed(KeyCode::Delete) {
|
||||
input.send(InputAction(Lifecycle::Start, Selection::Delete.into()));
|
||||
} else if keyboard.just_released(KeyCode::Delete) {
|
||||
input.send(InputAction(Lifecycle::Stop, Selection::Delete.into()));
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_debug_toggle(keyboard: Res<Input<KeyCode>>, mut input: EventWriter<InputAction>) {
|
||||
@@ -169,11 +177,17 @@ fn translate_dcc_camera_events(
|
||||
else if (keyboard.just_released(KeyCode::AltLeft) && !keyboard.pressed(KeyCode::AltRight))
|
||||
|| (keyboard.just_released(KeyCode::AltRight) && !keyboard.pressed(KeyCode::AltLeft))
|
||||
{
|
||||
input.send(InputAction(
|
||||
Lifecycle::Stop,
|
||||
CameraMotion::Orbit(Vec2::ZERO).into(),
|
||||
));
|
||||
input.send(InputAction(Lifecycle::Stop, CameraMotion::Zoom(0.).into()));
|
||||
// Stop Camera Orbit
|
||||
if mouse_buttons.pressed(MouseButton::Left) {
|
||||
input.send(InputAction(
|
||||
Lifecycle::Stop,
|
||||
CameraMotion::Orbit(Vec2::ZERO).into(),
|
||||
));
|
||||
}
|
||||
// Stop Camera Zoom
|
||||
else if mouse_buttons.pressed(MouseButton::Right) {
|
||||
input.send(InputAction(Lifecycle::Stop, CameraMotion::Zoom(0.).into()));
|
||||
}
|
||||
}
|
||||
// Camera Rotation
|
||||
else if mouse_buttons.pressed(MouseButton::Right) {
|
||||
|
||||
@@ -4,10 +4,7 @@ use bevy::{prelude::*, utils::HashSet, window::PrimaryWindow};
|
||||
use serde::Serialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::{
|
||||
scene::SceneElement,
|
||||
wasm::{self, AsJson},
|
||||
};
|
||||
use crate::{scene::SceneElement, wasm};
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const TYPE_DECLARATIONS: &str = r#"
|
||||
@@ -16,6 +13,7 @@ export interface SceneObject {
|
||||
id: string;
|
||||
/** A user-friendly name for displaying in the UI */
|
||||
name: string;
|
||||
type: SceneElement;
|
||||
/** 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 */
|
||||
@@ -52,7 +50,7 @@ export interface EntityMultiSpawnEvent extends CustomEvent {
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"entity-spawn": EntitySpawnEvent;
|
||||
"entity-despawn": EntityDespawnEvent;
|
||||
"entity-multi-spawn": EntityMultiSpawnEvent;
|
||||
@@ -64,50 +62,50 @@ declare global {
|
||||
pub(super) struct SceneObject {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: SceneElement,
|
||||
children: Vec<String>,
|
||||
parent: Option<String>,
|
||||
transform: serde_json::Value,
|
||||
#[serde(rename(serialize = "globalTransform"))]
|
||||
global_transform: serde_json::Value,
|
||||
}
|
||||
|
||||
struct SceneObjectBuilder<'a> {
|
||||
struct SceneObjectBuilder {
|
||||
id: Entity,
|
||||
name: Option<String>,
|
||||
type_: Option<SceneElement>,
|
||||
children: Option<Vec<Entity>>,
|
||||
parent: Option<Entity>,
|
||||
transform: Option<&'a Transform>,
|
||||
global_transform: Option<&'a GlobalTransform>,
|
||||
}
|
||||
|
||||
impl<'a> SceneObjectBuilder<'a> {
|
||||
impl SceneObjectBuilder {
|
||||
fn new(id: Entity) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: None,
|
||||
type_: None,
|
||||
children: None,
|
||||
parent: None,
|
||||
transform: None,
|
||||
global_transform: None,
|
||||
}
|
||||
}
|
||||
fn finish(self) -> SceneObject {
|
||||
SceneObject {
|
||||
id: format!("{:?}", self.id),
|
||||
name: self.name.unwrap(),
|
||||
type_: self.type_.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_type(mut self, type_: SceneElement) -> Self {
|
||||
self.type_ = Some(type_);
|
||||
self
|
||||
}
|
||||
fn with_children(mut self, children: Vec<Entity>) -> Self {
|
||||
self.children = Some(children);
|
||||
self
|
||||
@@ -116,18 +114,10 @@ impl<'a> SceneObjectBuilder<'a> {
|
||||
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> {
|
||||
impl SceneObject {
|
||||
fn build(id: Entity) -> SceneObjectBuilder {
|
||||
SceneObjectBuilder::new(id)
|
||||
}
|
||||
}
|
||||
@@ -140,8 +130,8 @@ pub(super) fn notify_entity_spawn_changes(
|
||||
mut l_despawned_entities: Local<Vec<Entity>>,
|
||||
mut l_missing_parents: Local<VecDeque<Entity>>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
q_inspectables: Query<(Entity, &Transform, &GlobalTransform), With<SceneElement>>,
|
||||
q_others: Query<(Entity, &Transform, &GlobalTransform), Without<SceneElement>>,
|
||||
q_inspectables: Query<(Entity, &SceneElement)>,
|
||||
q_others: Query<Entity, Without<SceneElement>>,
|
||||
q_names: Query<&Name>,
|
||||
q_parents: Query<&Children>,
|
||||
q_children: Query<&Parent>,
|
||||
@@ -149,26 +139,21 @@ pub(super) fn notify_entity_spawn_changes(
|
||||
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,
|
||||
)
|
||||
}),
|
||||
);
|
||||
l_new_objects.extend(q_inspectables.iter().filter_map(|(ent, &type_)| {
|
||||
build_scene_object(
|
||||
&mut l_notified,
|
||||
&mut l_missing_parents,
|
||||
ent,
|
||||
type_,
|
||||
&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 {
|
||||
let Ok(ent) = q_others.get(parent) else {
|
||||
error!("Failed to find missing parent: {parent:?}");
|
||||
l_notified.insert(parent);
|
||||
|
||||
@@ -179,8 +164,7 @@ pub(super) fn notify_entity_spawn_changes(
|
||||
&mut l_notified,
|
||||
&mut l_missing_parents,
|
||||
ent,
|
||||
xform,
|
||||
world_xform,
|
||||
SceneElement::Generic,
|
||||
&q_inspectables,
|
||||
&q_names,
|
||||
&q_parents,
|
||||
@@ -218,9 +202,8 @@ fn build_scene_object(
|
||||
notified: &mut HashSet<Entity>,
|
||||
missing_parents: &mut VecDeque<Entity>,
|
||||
ent: Entity,
|
||||
xform: &Transform,
|
||||
world_xform: &GlobalTransform,
|
||||
q_inspectables: &Query<(Entity, &Transform, &GlobalTransform), With<SceneElement>>,
|
||||
type_: SceneElement,
|
||||
q_inspectables: &Query<(Entity, &SceneElement)>,
|
||||
q_names: &Query<&Name>,
|
||||
q_parents: &Query<&Children>,
|
||||
q_children: &Query<&Parent>,
|
||||
@@ -231,9 +214,7 @@ fn build_scene_object(
|
||||
|
||||
notified.insert(ent);
|
||||
|
||||
let mut result = SceneObject::build(ent)
|
||||
.with_transform(xform)
|
||||
.with_global_transform(world_xform);
|
||||
let mut result = SceneObject::build(ent).with_type(type_);
|
||||
|
||||
if let Ok(children) = q_parents.get(ent) {
|
||||
result = result.with_children(children.iter().cloned().collect());
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::interaction::TransformSpace;
|
||||
use super::material::GizmoMaterial;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
pub(super) struct TransformGizmo {
|
||||
pub struct TransformGizmo {
|
||||
pub(super) target: Entity,
|
||||
pub(super) transform_space: TransformSpace,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use self::{
|
||||
rotation::{spawn_rotation_gizmo, update_ss_ring_rotation},
|
||||
translation::spawn_translation_gizmo,
|
||||
};
|
||||
pub use common::TransformGizmo;
|
||||
|
||||
use super::{Selection, TransformMode, TransformType};
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::{prelude::*, render::mesh::skinning::SkinnedMesh};
|
||||
use bevy_mod_outline::{
|
||||
AutoGenerateOutlineNormalsPlugin, OutlineBundle, OutlinePlugin, OutlineVolume,
|
||||
};
|
||||
use bevy_mod_picking::{prelude::Pointer, selection::Select, PickableBundle};
|
||||
use space_editor::prelude::space_undo::{self, NewChange, RemovedEntity};
|
||||
|
||||
use crate::{
|
||||
input::{self, Action, InputAction, Lifecycle},
|
||||
@@ -13,7 +16,13 @@ pub struct SelectionPlugin;
|
||||
impl Plugin for SelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins((OutlinePlugin, AutoGenerateOutlineNormalsPlugin))
|
||||
.add_systems(Update, update_selection)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_selection,
|
||||
process_delete_inputs.run_if(resource_exists::<Selection>()),
|
||||
),
|
||||
)
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
draw_selection_outline.run_if(resource_changed_or_removed::<Selection>()),
|
||||
@@ -149,6 +158,28 @@ fn update_selection(
|
||||
}
|
||||
}
|
||||
|
||||
fn process_delete_inputs(
|
||||
mut cmd: Commands,
|
||||
mut er_input: EventReader<InputAction>,
|
||||
mut ew_changes: EventWriter<space_undo::NewChange>,
|
||||
r_selection: Res<Selection>,
|
||||
) {
|
||||
if er_input.read().any(|action| {
|
||||
matches!(
|
||||
action,
|
||||
InputAction(Lifecycle::Stop, Action::Selection(input::Selection::Delete))
|
||||
)
|
||||
}) {
|
||||
let entity = **r_selection;
|
||||
|
||||
cmd.entity(entity).despawn_recursive();
|
||||
cmd.remove_resource::<Selection>();
|
||||
ew_changes.send(NewChange {
|
||||
change: Arc::new(RemovedEntity { entity }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_selection_outline(
|
||||
mut cmd: Commands,
|
||||
q_selected: Query<&Children, With<Selected>>,
|
||||
@@ -193,7 +224,7 @@ mod wasm {
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::{Selected, Selection};
|
||||
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
|
||||
use crate::wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const EVENT_DECLARATION: &str = r#"
|
||||
@@ -207,7 +238,7 @@ export interface EntitySelectEvent extends CustomEvent {
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"entity-select": EntitySelectEvent;
|
||||
}
|
||||
}
|
||||
@@ -225,12 +256,7 @@ declare global {
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn select(entity: &str) {
|
||||
let mut split = entity.split('v');
|
||||
let index = split.next().unwrap().parse::<u32>().unwrap();
|
||||
let generation = split.next().unwrap().parse::<u32>().unwrap();
|
||||
let bits = (generation as u64) << 32 | index as u64;
|
||||
|
||||
let entity = Entity::from_bits(bits);
|
||||
let entity = Entity::from_str(entity).unwrap();
|
||||
SELECTION_REQUEST.write_message(entity);
|
||||
}
|
||||
|
||||
|
||||
+103
-257
@@ -1,33 +1,31 @@
|
||||
#![allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::{
|
||||
app::{AppExit, PluginGroupBuilder, ScheduleRunnerPlugin},
|
||||
prelude::*,
|
||||
};
|
||||
use bevy::{app::PluginGroupBuilder, prelude::*};
|
||||
use bevy_mod_picking::DefaultPickingPlugins;
|
||||
use bevy_web_asset::WebAssetPlugin;
|
||||
use bvh::{BvhAnimCompleteEvent, TestBvh};
|
||||
use screenshot::{ScreenCapture, ScreenShotPlugin};
|
||||
use space_editor::prelude::{EditorEvent, EditorPrefabPath};
|
||||
use scene::ImportSceneEvent;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wgpu::Extent3d;
|
||||
|
||||
mod anim;
|
||||
mod bvh;
|
||||
mod camera_controller;
|
||||
mod change_tracking;
|
||||
mod debug;
|
||||
mod design_system;
|
||||
pub mod headless;
|
||||
mod hierarchy;
|
||||
mod input;
|
||||
mod inspector;
|
||||
mod interaction;
|
||||
pub mod math;
|
||||
mod mixamo;
|
||||
mod path;
|
||||
mod persistance;
|
||||
mod remote;
|
||||
mod scene;
|
||||
pub mod screenshot;
|
||||
mod skybox;
|
||||
@@ -46,7 +44,8 @@ pub use crate::{
|
||||
math::InterpolationPlugin,
|
||||
mixamo::{MixamoImportEvent, MixamoPlugin},
|
||||
persistance::PersistancePlugin,
|
||||
scene::{LoadSceneEvent, ScenePlugin},
|
||||
remote::RemoteAssetPlugin,
|
||||
scene::{LoadLibraryObjectEvent, LoadSceneEvent, ScenePlugin},
|
||||
skybox::{SkyboxId, SkyboxPlugin},
|
||||
ui::UiLayerPlugin,
|
||||
viewport::ViewportSize,
|
||||
@@ -59,7 +58,12 @@ mod wasm;
|
||||
#[derive(Component)]
|
||||
pub(crate) struct MainCamera;
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
#[cfg_attr(
|
||||
feature = "wasm",
|
||||
wasm_bindgen,
|
||||
repr(u8),
|
||||
derive(Deserialize_repr, Serialize_repr)
|
||||
)]
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum StudioMode {
|
||||
Editor,
|
||||
@@ -100,169 +104,65 @@ impl PluginGroup for EditorPlugins {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = startViewer))]
|
||||
/// Start the application as a read-only viewer for a single library object.
|
||||
///
|
||||
/// @param object_id ID of the library object to load.
|
||||
/// @param skybox_id ID of the Skybox to use
|
||||
/// @param canvas_selector A CSS selector for the canvas that will host the Bevy
|
||||
/// application. This should be unique (i.e. `querySelectorAll` should not
|
||||
/// return more than one element), so using an ID selector is a good idea.
|
||||
/// @param canvas_alt `alt` text for the HTML canvas element.
|
||||
/// @param viewport_size The size of the canvas in CSS `px` units.
|
||||
pub fn start_viewer(
|
||||
object_id: &str,
|
||||
skybox_id: &str,
|
||||
canvas_selector: &str,
|
||||
canvas_alt: &str,
|
||||
viewport_size: ViewportSize,
|
||||
) {
|
||||
start_internal(
|
||||
object_id,
|
||||
skybox_id,
|
||||
canvas_selector,
|
||||
canvas_alt,
|
||||
viewport_size,
|
||||
StudioMode::Viewer,
|
||||
)
|
||||
.run();
|
||||
#[derive(Debug)]
|
||||
pub enum InitParams {
|
||||
/// Initialize the application by importing an asset and processing it to
|
||||
/// work with Storyteller Studio's feature-set.
|
||||
FromAsset {
|
||||
asset: InitAsset,
|
||||
/// A skybox name that corresponds to a matching set of assets under
|
||||
/// `studio/assets/skyboxes`, _not_ including file extensions or
|
||||
/// "diffuse"/"specular" qualifiers (e.g., "gum_trees_4k")
|
||||
skybox_id: String,
|
||||
},
|
||||
/// Initialize the application by loading a previously saved Storyteller
|
||||
/// Studio scene.
|
||||
FromScene(String),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = startEditor))]
|
||||
pub fn start_editor(
|
||||
object_id: &str,
|
||||
skybox_id: &str,
|
||||
canvas_selector: &str,
|
||||
canvas_alt: &str,
|
||||
viewport_size: ViewportSize,
|
||||
) {
|
||||
start_internal(
|
||||
object_id,
|
||||
skybox_id,
|
||||
canvas_selector,
|
||||
canvas_alt,
|
||||
viewport_size,
|
||||
StudioMode::Editor,
|
||||
)
|
||||
.run();
|
||||
#[derive(Debug)]
|
||||
pub enum InitAsset {
|
||||
/// Initialize the application from an asset in the "bundled" asset library.
|
||||
/// Takes a filename, including the extension, that can be found under
|
||||
/// `studio/assets/gltf` (e.g., "base-human-female.gltf").
|
||||
LibraryObject(String),
|
||||
/// Initialize the application by loading an arbitrary glTF scene from any
|
||||
/// source (e.g., the Storyteller.ai backend)
|
||||
ImportedScene(String),
|
||||
/// Initialize the application by retargeting a BVH animation (currently
|
||||
/// only supports the MocapNET skeleton) onto the bundled `Mannequin.gltf`
|
||||
/// model.
|
||||
BvhAnim(String),
|
||||
/// Initialize the application by retargeting a Mixamo animation onto the
|
||||
/// bundled `Mannequin.gltf` model.
|
||||
MixamoAnim(String),
|
||||
/// Initialize the application by loading an arbitary model given an identifier
|
||||
MediaAsset(String),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = startBvhViewer))]
|
||||
pub fn start_bvh_viewer(
|
||||
bvh_path: &str,
|
||||
skybox_id: &str,
|
||||
canvas_selector: &str,
|
||||
canvas_alt: &str,
|
||||
viewport_size: ViewportSize,
|
||||
) {
|
||||
let mut app = start_internal(
|
||||
"Mannequin.gltf",
|
||||
skybox_id,
|
||||
canvas_selector,
|
||||
canvas_alt,
|
||||
viewport_size,
|
||||
StudioMode::Viewer,
|
||||
);
|
||||
|
||||
let bvh_path = bvh_path.to_string();
|
||||
|
||||
app.add_plugins(BvhPlugin);
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands, r_assets: Res<AssetServer>| {
|
||||
cmd.insert_resource(bvh::TestBvh(r_assets.load(bvh_path.clone())));
|
||||
},
|
||||
);
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = startMixamoViewer))]
|
||||
pub fn start_mixamo_viewer(
|
||||
mixamo_path: &str,
|
||||
skybox_id: &str,
|
||||
canvas_selector: &str,
|
||||
canvas_alt: &str,
|
||||
viewport_size: ViewportSize,
|
||||
) {
|
||||
let mut app = start_internal(
|
||||
"Mannequin.gltf",
|
||||
skybox_id,
|
||||
canvas_selector,
|
||||
canvas_alt,
|
||||
viewport_size,
|
||||
StudioMode::Viewer,
|
||||
);
|
||||
|
||||
let mixamo_path = mixamo_path.to_string();
|
||||
|
||||
app.add_plugins(MixamoPlugin);
|
||||
app.add_systems(Startup, move |mut ew: EventWriter<MixamoImportEvent>| {
|
||||
ew.send(MixamoImportEvent(mixamo_path.clone()));
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
pub fn start(
|
||||
object_id: &str,
|
||||
skybox_id: &str,
|
||||
canvas_selector: &str,
|
||||
canvas_alt: &str,
|
||||
viewport_size: ViewportSize,
|
||||
studio_mode: StudioMode,
|
||||
) {
|
||||
start_internal(
|
||||
object_id,
|
||||
skybox_id,
|
||||
canvas_selector,
|
||||
canvas_alt,
|
||||
viewport_size,
|
||||
studio_mode,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
// TODO: These `start_` functiions should be refactored to better support the
|
||||
// needs of the "reaodnly viewer" vs "full studio editor" use cases. See
|
||||
// notes in `studio-web/src/lib/viewer.element.ts` for more info.
|
||||
|
||||
pub fn start_internal(
|
||||
object_id: &str,
|
||||
skybox_id: &str,
|
||||
canvas_selector: &str,
|
||||
canvas_alt: &str,
|
||||
viewport_size: ViewportSize,
|
||||
studio_mode: StudioMode,
|
||||
) -> App {
|
||||
pub fn start(init: InitParams, mode: StudioMode, window_config: Option<Window>) -> App {
|
||||
#[cfg(feature = "wasm")]
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let object_id = object_id.to_string();
|
||||
let skybox_id = skybox_id.to_string();
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
app.insert_resource(studio_mode);
|
||||
app.insert_resource(mode);
|
||||
|
||||
app.add_plugins((
|
||||
#[cfg(feature = "wasm")]
|
||||
viewport::wasm::ViewportPlugin,
|
||||
WebAssetPlugin,
|
||||
RemoteAssetPlugin,
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
resolution: viewport_size.into(),
|
||||
title: canvas_alt.into(),
|
||||
canvas: Some(canvas_selector.into()),
|
||||
..default()
|
||||
}),
|
||||
primary_window: Some(window_config.unwrap_or_default()),
|
||||
..default()
|
||||
}),
|
||||
CommonPlugins,
|
||||
#[cfg(feature = "wasm")]
|
||||
wasm::ScreenshotPlugin,
|
||||
));
|
||||
|
||||
if matches!(studio_mode, StudioMode::Editor) {
|
||||
if matches!(mode, StudioMode::Editor) {
|
||||
app.add_plugins((DefaultPickingPlugins, EditorPlugins));
|
||||
|
||||
// Only for registering components for undo
|
||||
@@ -270,13 +170,16 @@ pub fn start_internal(
|
||||
app.auto_reflected_undo::<Transform>();
|
||||
}
|
||||
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands, mut ew: EventWriter<LoadSceneEvent>| {
|
||||
cmd.insert_resource(SkyboxId(skybox_id.clone()));
|
||||
ew.send(LoadSceneEvent(object_id.clone()));
|
||||
},
|
||||
);
|
||||
match init {
|
||||
InitParams::FromAsset { asset, skybox_id } => {
|
||||
init_from_asset(&mut app, asset, skybox_id);
|
||||
}
|
||||
InitParams::FromScene(scene) => {
|
||||
app.add_systems(Startup, move |mut ew: EventWriter<LoadSceneEvent>| {
|
||||
ew.send(LoadSceneEvent(scene.clone()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
wasm::initialize();
|
||||
@@ -284,109 +187,52 @@ pub fn start_internal(
|
||||
app
|
||||
}
|
||||
|
||||
pub enum HeadlessMode {
|
||||
Bvh(String, String),
|
||||
Scene(String, String),
|
||||
}
|
||||
|
||||
pub fn start_headless(
|
||||
mode: HeadlessMode,
|
||||
fps: u32,
|
||||
outout_dir: &str,
|
||||
frames_to_capture: u32,
|
||||
image_res: Extent3d,
|
||||
) -> App {
|
||||
let mut app = App::new();
|
||||
|
||||
app.insert_resource(StudioMode::Headless);
|
||||
app.insert_resource(HeadlessSettings {
|
||||
frames_to_capture,
|
||||
output_dims: image_res,
|
||||
output_dir: outout_dir.to_string(),
|
||||
fn init_from_asset(app: &mut App, asset: InitAsset, skybox_id: String) {
|
||||
app.add_systems(Startup, move |mut cmd: Commands| {
|
||||
cmd.insert_resource(SkyboxId(skybox_id.clone()));
|
||||
});
|
||||
|
||||
app.add_plugins((
|
||||
ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(1.0 / fps as f64)),
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: None,
|
||||
exit_condition: bevy::window::ExitCondition::DontExit,
|
||||
close_when_requested: true,
|
||||
}),
|
||||
ScreenShotPlugin,
|
||||
CommonPlugins.build().disable::<WorldGridPlugin>(),
|
||||
));
|
||||
|
||||
match mode {
|
||||
HeadlessMode::Bvh(bvh_dir, skybox) => {
|
||||
app.add_plugins(BvhPlugin);
|
||||
match asset {
|
||||
InitAsset::LibraryObject(id) => {
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut ew: EventWriter<LoadSceneEvent>| {
|
||||
cmd.insert_resource(SkyboxId(skybox.clone()));
|
||||
cmd.insert_resource(TestBvh(asset_server.load(bvh_dir.clone())));
|
||||
ew.send(LoadSceneEvent("Mannequin.gltf".to_string()));
|
||||
},
|
||||
);
|
||||
|
||||
app.add_systems(PostUpdate, check_for_capture_complete);
|
||||
}
|
||||
HeadlessMode::Scene(scene, skybox) => {
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands, mut ew: EventWriter<EditorEvent>| {
|
||||
cmd.insert_resource(SkyboxId(skybox.clone()));
|
||||
ew.send(EditorEvent::Load(EditorPrefabPath::File(scene.clone())))
|
||||
move |mut ew: EventWriter<LoadLibraryObjectEvent>| {
|
||||
ew.send(LoadLibraryObjectEvent(id.clone()));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
anim @ (InitAsset::BvhAnim(_) | InitAsset::MixamoAnim(_)) => {
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut ew: EventWriter<LoadLibraryObjectEvent>| {
|
||||
ew.send(LoadLibraryObjectEvent("Mannequin.gltf".into()));
|
||||
},
|
||||
);
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
pub fn is_headless(studio_state: Res<StudioMode>) -> bool {
|
||||
*studio_state == StudioMode::Headless
|
||||
}
|
||||
|
||||
pub fn is_not_headless(studio_state: Res<StudioMode>) -> bool {
|
||||
*studio_state != StudioMode::Headless
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct HeadlessSettings {
|
||||
pub output_dims: Extent3d,
|
||||
pub output_dir: String,
|
||||
pub frames_to_capture: u32,
|
||||
}
|
||||
|
||||
impl Default for HeadlessSettings {
|
||||
fn default() -> Self {
|
||||
HeadlessSettings {
|
||||
output_dims: Extent3d {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
output_dir: "./screen_shots/".to_string(),
|
||||
frames_to_capture: 1,
|
||||
match anim {
|
||||
InitAsset::BvhAnim(anim) => {
|
||||
app.add_plugins(BvhPlugin);
|
||||
app.add_systems(
|
||||
Startup,
|
||||
move |mut cmd: Commands, r_assets: Res<AssetServer>| {
|
||||
cmd.insert_resource(bvh::TestBvh(r_assets.load(anim.clone())));
|
||||
},
|
||||
);
|
||||
}
|
||||
InitAsset::MixamoAnim(anim) => {
|
||||
app.add_plugins(MixamoPlugin);
|
||||
app.add_systems(Startup, move |mut ew: EventWriter<MixamoImportEvent>| {
|
||||
ew.send(MixamoImportEvent(anim.clone()));
|
||||
});
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_for_capture_complete(
|
||||
mut commands: Commands,
|
||||
mut close_app: EventWriter<AppExit>,
|
||||
main_camera: Query<(Entity, &ScreenCapture), With<MainCamera>>,
|
||||
bvh_event_reader: EventReader<BvhAnimCompleteEvent>,
|
||||
) {
|
||||
let Ok((entity, screen_capture)) = main_camera.get_single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if screen_capture.is_complete() || !bvh_event_reader.is_empty() {
|
||||
commands.entity(entity).remove::<ScreenCapture>();
|
||||
close_app.send(AppExit);
|
||||
InitAsset::ImportedScene(scene) => {
|
||||
app.add_systems(Startup, move |mut ew: EventWriter<ImportSceneEvent>| {
|
||||
ew.send(ImportSceneEvent(scene.clone()));
|
||||
});
|
||||
}
|
||||
InitAsset::MediaAsset(_) => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use bevy::render::render_resource::Extent3d;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use studio::{start_headless, HeadlessMode};
|
||||
use studio::headless::{start_headless, HeadlessMode};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "StorytellerStudioHeadless")]
|
||||
@@ -43,6 +43,19 @@ enum Subcommands {
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
frames: Option<u32>,
|
||||
},
|
||||
Mixamo {
|
||||
mixamo_dir: String,
|
||||
#[arg(long, short = 'o', short)]
|
||||
out: Option<String>,
|
||||
#[arg(long)]
|
||||
skybox: Option<String>,
|
||||
#[arg(long = "res", value_parser = extent3d_parser)]
|
||||
output_resolution: Option<Extent3d>,
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
fps: Option<u32>,
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
frames: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
@@ -102,6 +115,31 @@ fn main() {
|
||||
output_res,
|
||||
)
|
||||
}
|
||||
Subcommands::Mixamo {
|
||||
mixamo_dir,
|
||||
out,
|
||||
skybox,
|
||||
output_resolution,
|
||||
fps,
|
||||
frames,
|
||||
} => {
|
||||
let output_dir = out.unwrap_or("./screen_shots/".to_string());
|
||||
let output_res = output_resolution.unwrap_or(Extent3d {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
depth_or_array_layers: 1,
|
||||
});
|
||||
let fps = fps.unwrap_or(24);
|
||||
let frames = frames.unwrap_or(u32::MAX);
|
||||
let skybox = skybox.unwrap_or("gum_trees_4k".to_string());
|
||||
start_headless(
|
||||
HeadlessMode::Mixamo(mixamo_dir, skybox),
|
||||
fps,
|
||||
&output_dir,
|
||||
frames,
|
||||
output_res,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
app.run();
|
||||
|
||||
+16
-10
@@ -1,6 +1,9 @@
|
||||
use bevy::{app::Update, window::close_on_esc};
|
||||
use bevy::{
|
||||
app::Update,
|
||||
window::{close_on_esc, Window},
|
||||
};
|
||||
use clap::Parser;
|
||||
use studio::{screenshot::ScreenShotPlugin, StudioMode, ViewportSize};
|
||||
use studio::{screenshot::ScreenShotPlugin, InitAsset, InitParams, StudioMode};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "StorytellerStudio")]
|
||||
@@ -16,17 +19,20 @@ struct StudioArgs {
|
||||
fn main() {
|
||||
let args = StudioArgs::parse();
|
||||
|
||||
let model_path = args.model.unwrap_or("Mannequin.gltf".to_string());
|
||||
let skybox_path = args.skybox.unwrap_or("gum_trees_4k".to_string());
|
||||
let object_id = args.model.unwrap_or("Mannequin.gltf".to_string());
|
||||
let skybox_id = args.skybox.unwrap_or("gum_trees_4k".to_string());
|
||||
|
||||
// Edit the arguments here to change the default startup scene or skybox
|
||||
let mut app = studio::start_internal(
|
||||
model_path.as_str(),
|
||||
skybox_path.as_str(),
|
||||
"", // ignored for non-WASM
|
||||
"Storyteller Studio",
|
||||
ViewportSize::default(),
|
||||
let mut app = studio::start(
|
||||
InitParams::FromAsset {
|
||||
asset: InitAsset::LibraryObject(object_id),
|
||||
skybox_id,
|
||||
},
|
||||
StudioMode::Editor,
|
||||
Some(Window {
|
||||
title: "Storyteller Studio".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
// Add any additional systems, resources, etc. for local debugging
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::{ffi::OsStr, path::Path};
|
||||
|
||||
pub trait PathExtras {
|
||||
/// See [std::path::Path::file_prefix]
|
||||
#[must_use]
|
||||
fn ext_file_prefix(&self) -> Option<&OsStr>;
|
||||
}
|
||||
|
||||
impl PathExtras for Path {
|
||||
fn ext_file_prefix(&self) -> Option<&OsStr> {
|
||||
self.file_name()
|
||||
.map(split_file_at_dot)
|
||||
.map(|(before, _after)| before)
|
||||
}
|
||||
}
|
||||
|
||||
/// Note: Copied from private fn `std::path::split_file_at_dot`
|
||||
fn split_file_at_dot(file: &OsStr) -> (&OsStr, Option<&OsStr>) {
|
||||
let slice = file.as_encoded_bytes();
|
||||
if slice == b".." {
|
||||
return (file, None);
|
||||
}
|
||||
|
||||
// The unsafety here stems from converting between &OsStr and &[u8]
|
||||
// and back. This is safe to do because (1) we only look at ASCII
|
||||
// contents of the encoding and (2) new &OsStr values are produced
|
||||
// only from ASCII-bounded slices of existing &OsStr values.
|
||||
let i = match slice[1..].iter().position(|b| *b == b'.') {
|
||||
Some(i) => i + 1,
|
||||
None => return (file, None),
|
||||
};
|
||||
let before = &slice[..i];
|
||||
let after = &slice[i + 1..];
|
||||
unsafe {
|
||||
(
|
||||
OsStr::from_encoded_bytes_unchecked(before),
|
||||
Some(OsStr::from_encoded_bytes_unchecked(after)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ impl Plugin for PersistancePlugin {
|
||||
EditorTabName::Other("Persistance".to_string()),
|
||||
DebugPersistanceTab::default(),
|
||||
);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(Update, wasm::forward_save_scene_events)
|
||||
.add_systems(
|
||||
OnEnter(SaveState::Idle),
|
||||
wasm::on_scene_saved.map(bevy::utils::warn),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,3 +50,64 @@ impl EditorTab for DebugPersistanceTab {
|
||||
"Persistance".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use space_editor::prelude::{EditorEvent, EditorPrefabPath, PrefabMemoryCache};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
static SAVE_SCENE: WasmMessageBuffer<()> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = saveScene)]
|
||||
pub fn save_scene() {
|
||||
SAVE_SCENE.write_message(());
|
||||
}
|
||||
|
||||
pub(super) fn forward_save_scene_events(mut ew_editor_events: EventWriter<EditorEvent>) {
|
||||
if SAVE_SCENE.take_message().is_some() {
|
||||
ew_editor_events.send(EditorEvent::Save(EditorPrefabPath::MemoryCahce));
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const SCENE_SAVED_EVENT: &str = r#"
|
||||
export interface SceneSavedEvent extends CustomEvent {
|
||||
type: "scene-saved";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"scene-saved": SceneSavedEvent;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub(super) fn on_scene_saved(
|
||||
r_scene_cache: Res<PrefabMemoryCache>,
|
||||
r_type_registry: Res<AppTypeRegistry>,
|
||||
ra_scenes: Res<Assets<DynamicScene>>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
let handle = r_scene_cache
|
||||
.scene
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Scene cache is empty!".to_string())?;
|
||||
|
||||
let serialized = match ra_scenes.get(handle) {
|
||||
Some(scene) => scene
|
||||
.serialize_ron(&r_type_registry)
|
||||
.map_err(|err| format!("{err}")),
|
||||
None => Err("No scene found for cached handle!".into()),
|
||||
}?;
|
||||
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
wasm::dispatch_to_js(window, "scene-saved", &serialized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use bevy::{
|
||||
asset::io::{AssetSource, AssetSourceId},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use self::reader::RemoteAssetReader;
|
||||
|
||||
mod reader;
|
||||
mod response;
|
||||
|
||||
pub struct RemoteAssetPlugin;
|
||||
|
||||
impl Plugin for RemoteAssetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_asset_source(
|
||||
AssetSourceId::Name("remote".into()),
|
||||
AssetSource::build()
|
||||
.with_reader(|| Box::new(RemoteAssetReader))
|
||||
.with_writer(|| None),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
|
||||
use bevy::{
|
||||
asset::io::{AssetReader, AssetReaderError, PathStream, Reader, VecReader},
|
||||
prelude::*,
|
||||
utils::BoxedFuture,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::response::GetMediaFileSuccessResponse;
|
||||
use crate::path::PathExtras;
|
||||
|
||||
pub struct RemoteAssetReader;
|
||||
|
||||
impl RemoteAssetReader {
|
||||
const API_BASE_URL: &'static str = "https://api.fakeyou.com/v1/media_files/file";
|
||||
const BUCKET_BASE_URL: &'static str = "https://storage.googleapis.com/vocodes-public";
|
||||
}
|
||||
|
||||
impl AssetReader for RemoteAssetReader {
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
) -> BoxedFuture<'a, Result<Box<Reader<'a>>, AssetReaderError>> {
|
||||
Box::pin(async move {
|
||||
let token = path
|
||||
.ext_file_prefix()
|
||||
.ok_or_else(|| {
|
||||
AssetReaderError::Io(std::io::Error::new(
|
||||
ErrorKind::Other,
|
||||
format!("Failed to parse token from '{path:?}'"),
|
||||
))
|
||||
})?
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
info!("Loading asset from media token: '{token}'");
|
||||
|
||||
let response = reqwest::get(format!("{}/{token}", &Self::API_BASE_URL))
|
||||
.await
|
||||
.map_err(IntoError::<AssetReaderError>::into)?;
|
||||
|
||||
let response = match response.status() {
|
||||
StatusCode::OK => response
|
||||
.json::<GetMediaFileSuccessResponse>()
|
||||
.await
|
||||
.map_err(IntoError::<AssetReaderError>::into),
|
||||
other => Err(AssetReaderError::Io(std::io::Error::new(
|
||||
ErrorKind::Other,
|
||||
format!("Status Code from endpoint: {other}"),
|
||||
))),
|
||||
}?;
|
||||
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
&Self::BUCKET_BASE_URL,
|
||||
&response.media_file.public_bucket_path
|
||||
);
|
||||
|
||||
let response = reqwest::get(url)
|
||||
.await
|
||||
.map_err(IntoError::<AssetReaderError>::into)?;
|
||||
|
||||
let bytes = match response.status() {
|
||||
// TODO: This is not thread-safe, but some variation of this
|
||||
// would be better than the current solution once Bevy
|
||||
// WASM becomes multi-threaded:
|
||||
// StatusCode::OK => Ok(response.bytes_stream().map(|result| {
|
||||
// result.map_err(|err| std::io::Error::new(ErrorKind::Other, format!("{err}")))
|
||||
// })),
|
||||
StatusCode::OK => Ok(response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(IntoError::<AssetReaderError>::into)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()),
|
||||
other => Err(AssetReaderError::Io(std::io::Error::new(
|
||||
ErrorKind::Other,
|
||||
format!("Status Code from bucket URL: {other}"),
|
||||
))),
|
||||
}?;
|
||||
|
||||
let reader: Box<Reader> = Box::new(VecReader::new(bytes));
|
||||
Ok(reader)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_meta<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
) -> BoxedFuture<'a, Result<Box<Reader<'a>>, AssetReaderError>> {
|
||||
// TODO: Do we want to try to read .meta files?
|
||||
Box::pin(async { Err(AssetReaderError::NotFound(path.into())) })
|
||||
}
|
||||
|
||||
fn read_directory<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
) -> BoxedFuture<'a, Result<Box<PathStream>, AssetReaderError>> {
|
||||
// We won't have to deal with directories
|
||||
Box::pin(async { Err(AssetReaderError::NotFound(path.into())) })
|
||||
}
|
||||
|
||||
fn is_directory<'a>(&'a self, _: &'a Path) -> BoxedFuture<'a, Result<bool, AssetReaderError>> {
|
||||
// We won't have to deal with directories
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
}
|
||||
|
||||
trait IntoError<Output> {
|
||||
fn into(self) -> Output;
|
||||
}
|
||||
|
||||
impl IntoError<AssetReaderError> for reqwest::Error {
|
||||
fn into(self) -> AssetReaderError {
|
||||
AssetReaderError::Io(std::io::Error::new(ErrorKind::Other, format!("{self}")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Response types copy/pasted straight from the backend source code.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file: MediaFileInfo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MediaFileInfo {
|
||||
pub token: String,
|
||||
|
||||
/// Type of media will dictate which fields are populated and what
|
||||
/// the frontend should display (eg. video player vs audio player).
|
||||
pub media_type: String,
|
||||
|
||||
/// If the file was generated as part of a batch, this is the token for the batch.
|
||||
pub maybe_batch_token: Option<String>,
|
||||
|
||||
/// URL to the media file
|
||||
pub public_bucket_path: String,
|
||||
|
||||
//// Provenance Data
|
||||
//pub origin_product: MediaFileOriginProductCategory,
|
||||
//pub origin_category: MediaFileOriginCategory,
|
||||
//pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
//pub maybe_origin_model_token: Option<String>,
|
||||
//pub maybe_origin_filename: Option<String>,
|
||||
/// Possible model info (if generated by inference)
|
||||
pub maybe_model_weight_info: Option<GetMediaFileModelInfo>,
|
||||
|
||||
/// User info
|
||||
pub maybe_creator_user: Option<UserDetailsLight>,
|
||||
|
||||
pub creator_set_visibility: String,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// We can simulate media files for "tts_results" records.
|
||||
/// If this is set as true, this informs the frontend and API callers not to treat
|
||||
/// the token as a media file and improperly assume it can be used with the rest of
|
||||
/// the media file APIs.
|
||||
pub is_emulated_media_file: bool,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
//pub maybe_moderator_fields: Option<ModeratorFields>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetMediaFileModelInfo {
|
||||
pub weight_token: String,
|
||||
pub weight_type: String,
|
||||
pub weight_category: String,
|
||||
pub title: String,
|
||||
|
||||
/// Cover images are small descriptive images that can be set for any model.
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
pub maybe_cover_image_public_bucket_path: Option<String>,
|
||||
|
||||
/// NB: Technically this shouldn't be a nullable field, but since there are so many
|
||||
/// joins we'll relax that this must exist (for now).
|
||||
pub maybe_weight_creator: Option<UserDetailsLight>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UserDetailsLight {
|
||||
/// The token for the user
|
||||
pub user_token: String,
|
||||
|
||||
/// The unique username someone logs in with
|
||||
/// As of 2023-08-23, this is always lowercase
|
||||
pub username: String,
|
||||
|
||||
/// As of 2023-08-23, this is the username with capitalization
|
||||
/// (In the future, a display name can be customized by the user.)
|
||||
pub display_name: String,
|
||||
|
||||
/// Email hash for Gravatar
|
||||
/// Always set for now since login is email/username+password.
|
||||
/// In the future this will need to become an optional *OR* be filled with a bogus hash.
|
||||
pub gravatar_hash: String,
|
||||
|
||||
/// For users without a gravatar, we show one of our own avatars.
|
||||
pub default_avatar: DefaultAvatarInfo,
|
||||
// In the future, we'll also support user-uploaded avatars that we store on our servers.
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct DefaultAvatarInfo {
|
||||
pub image_index: u8,
|
||||
pub color_index: u8,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SimpleEntityStats {
|
||||
/// Number of positive ratings (or "likes") for this item
|
||||
pub positive_rating_count: u32,
|
||||
/// Number of bookmarks for this item
|
||||
pub bookmark_count: u32,
|
||||
}
|
||||
+130
-38
@@ -12,19 +12,24 @@ use bevy::{
|
||||
render::{camera::RenderTarget, primitives::Aabb},
|
||||
scene::InstanceId,
|
||||
};
|
||||
|
||||
use space_editor::{prelude::PrefabMarker, space_prefab::component::SceneAutoChild};
|
||||
#[cfg(feature = "wasm")]
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
use space_editor::{
|
||||
prelude::{EditorEvent, EditorPrefabPath, PrefabMarker},
|
||||
space_prefab::component::SceneAutoChild,
|
||||
};
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use wgpu::{TextureDescriptor, TextureUsages};
|
||||
|
||||
use crate::{
|
||||
camera_controller::{CameraController, CameraControllerBundle},
|
||||
change_tracking::{ChangeTracker, ChangeTrackingPlugin},
|
||||
debug::DebugPoint,
|
||||
is_headless, is_not_headless,
|
||||
headless::{is_headless, is_not_headless, HeadlessSettings},
|
||||
math::{Combinable, Extrema},
|
||||
screenshot::ScreenCapture,
|
||||
HeadlessSettings, MainCamera,
|
||||
MainCamera,
|
||||
};
|
||||
|
||||
pub struct ScenePlugin;
|
||||
@@ -34,10 +39,14 @@ pub struct SceneSpawnerSystem;
|
||||
|
||||
impl Plugin for ScenePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(ChangeTrackingPlugin);
|
||||
|
||||
app.insert_resource(ClearColor(Color::hsl(0.0, 0.0, 0.25)))
|
||||
.insert_resource(Msaa::Off)
|
||||
.add_state::<SceneState>()
|
||||
.add_event::<LoadLibraryObjectEvent>()
|
||||
.add_event::<LoadSceneEvent>()
|
||||
.add_event::<ImportSceneEvent>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
(
|
||||
@@ -45,10 +54,20 @@ impl Plugin for ScenePlugin {
|
||||
initialiaze_headless.run_if(is_headless),
|
||||
),
|
||||
)
|
||||
.add_systems(Update, init_scene_loading.in_set(SceneSpawnerSystem))
|
||||
.add_systems(
|
||||
Update,
|
||||
(init_object_loading, init_scene_import, init_scene_loading)
|
||||
.in_set(SceneSpawnerSystem),
|
||||
)
|
||||
.add_systems(
|
||||
OnEnter(SceneState::Active),
|
||||
(tag_scene_elements, frame_scene_in_view).in_set(SceneSpawnerSystem),
|
||||
(
|
||||
tag_scene_elements,
|
||||
frame_scene_in_view,
|
||||
start_capture.run_if(is_headless),
|
||||
)
|
||||
.chain()
|
||||
.in_set(SceneSpawnerSystem),
|
||||
);
|
||||
|
||||
app.add_systems(Update, tag_scene_elements);
|
||||
@@ -73,7 +92,7 @@ impl Plugin for ScenePlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
wasm::dispatch_scene_request,
|
||||
wasm::dispatch_object_load_request,
|
||||
wasm::notify_state_changes
|
||||
.map(bevy::utils::error)
|
||||
.run_if(resource_changed::<State<SceneState>>()),
|
||||
@@ -83,16 +102,20 @@ impl Plugin for ScenePlugin {
|
||||
}
|
||||
|
||||
fn minimal_scene_setup(mut cmd: Commands) {
|
||||
cmd.spawn(DirectionalLightBundle {
|
||||
directional_light: DirectionalLight {
|
||||
shadows_enabled: false,
|
||||
illuminance: 20000.0,
|
||||
cmd.spawn((
|
||||
Name::new("Directional Light"),
|
||||
DirectionalLightBundle {
|
||||
directional_light: DirectionalLight {
|
||||
shadows_enabled: false,
|
||||
illuminance: 20000.0,
|
||||
..default()
|
||||
},
|
||||
transform: Transform::default().looking_at(Vec3::NEG_ONE, Vec3::Y),
|
||||
..default()
|
||||
},
|
||||
transform: Transform::default().looking_at(Vec3::NEG_ONE, Vec3::Y),
|
||||
..default()
|
||||
})
|
||||
.insert(PrefabMarker);
|
||||
PrefabMarker,
|
||||
SceneElement::Light,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", wasm_bindgen)]
|
||||
@@ -167,17 +190,58 @@ fn auto_add_handle<T: Asset>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the application to load a `gltf`/`glb` scene from the "bundled"
|
||||
/// asset library in `assets/gltf/`. Expects a filename including the extension.
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() {
|
||||
/// App::new()
|
||||
/// .add_plugins((
|
||||
/// DefaultPlugins,
|
||||
/// CommonPlugins,
|
||||
/// ))
|
||||
/// .add_systems(Startup, load_mannequin)
|
||||
/// }
|
||||
///
|
||||
/// fn load_mannequin(mut ew_load_object: EventWriter<LoadLibraryObjectEvent>) {
|
||||
/// ew_load_object.send(LoadLibraryObjectEvent("Mannequin.gltf".into()));
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Event, Deref)]
|
||||
pub struct LoadLibraryObjectEvent(pub String);
|
||||
|
||||
/// Request the application to load a previously saved Storyteller Studio scene.
|
||||
///
|
||||
/// Expects a full, resolvable path to the file (e.g. `scenes/my-scene.scn.ron`
|
||||
/// for a local file saved under `studio/assets/scenes`, or
|
||||
/// `remote://<media_token>.scn.ron` for a scene saved to the Storyteller.ai
|
||||
/// backend).
|
||||
#[derive(Event, Deref)]
|
||||
pub struct LoadSceneEvent(pub String);
|
||||
|
||||
// TODOL This could be converted into an enum that indicates what "kind" of
|
||||
// element this is (e.g. StaticMesh, SkinnedMesh, Bone, Light, Camera).
|
||||
// We don't have an immediate need for that just yet, but likely will
|
||||
// soon.
|
||||
/// Request the application to import and process a glTF scene from an arbitrary
|
||||
/// asset source (e.g., an http path or a Storyteller.ai media token).
|
||||
#[derive(Event, Deref)]
|
||||
pub struct ImportSceneEvent(pub String);
|
||||
|
||||
/// A tag that identifies its entity as a user-facing scene element like a prop,
|
||||
/// a light, or a camera.
|
||||
#[derive(Component)]
|
||||
pub struct SceneElement;
|
||||
#[cfg_attr(
|
||||
feature = "wasm",
|
||||
repr(u8),
|
||||
wasm_bindgen,
|
||||
derive(Serialize_repr, Deserialize_repr)
|
||||
)]
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SceneElement {
|
||||
Generic,
|
||||
Mesh,
|
||||
Skeleton,
|
||||
Bone,
|
||||
Light,
|
||||
#[allow(dead_code)] // TODO: Will be used for camera animation
|
||||
Camera,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct SceneData {
|
||||
@@ -195,6 +259,7 @@ fn initialize(mut cmd: Commands) {
|
||||
cmd.spawn((
|
||||
Name::new("Main Camera"),
|
||||
MainCamera,
|
||||
ChangeTracker,
|
||||
Camera3dBundle {
|
||||
transform: camera_xform,
|
||||
tonemapping: Tonemapping::BlenderFilmic,
|
||||
@@ -261,17 +326,15 @@ fn initialiaze_headless(
|
||||
..default()
|
||||
},
|
||||
CameraControllerBundle::new(focus, camera_xform.translation),
|
||||
ScreenCapture::number_of_frames(
|
||||
headless_settings.frames_to_capture,
|
||||
&headless_settings.output_dir,
|
||||
),
|
||||
ScreenCapture::from_out(&headless_settings.output_dir),
|
||||
));
|
||||
}
|
||||
|
||||
fn init_scene_loading(mut cmd: Commands, mut er: EventReader<LoadSceneEvent>) {
|
||||
fn init_object_loading(mut cmd: Commands, mut er: EventReader<LoadLibraryObjectEvent>) {
|
||||
if let Some(id) = er.read().last() {
|
||||
info!("Loading scene: {}", id.0);
|
||||
info!("Loading library asset: {}", id.0);
|
||||
cmd.spawn((
|
||||
Name::new(id.0.clone()),
|
||||
SpatialBundle::default(),
|
||||
space_editor::prelude::GltfPrefab {
|
||||
path: format!("gltf/{}", id.0),
|
||||
@@ -283,11 +346,33 @@ fn init_scene_loading(mut cmd: Commands, mut er: EventReader<LoadSceneEvent>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct NoMeshElement;
|
||||
fn init_scene_import(mut cmd: Commands, mut er: EventReader<ImportSceneEvent>) {
|
||||
if let Some(path) = er.read().last() {
|
||||
info!("Importing asset: {}", path.0);
|
||||
cmd.spawn((
|
||||
Name::new("Scene"), // TODO: Maybe use the "basename" part of the asset path?
|
||||
SpatialBundle::default(),
|
||||
space_editor::prelude::GltfPrefab {
|
||||
path: path.0.clone(),
|
||||
scene: "Scene0".to_string(),
|
||||
},
|
||||
PrefabMarker,
|
||||
SceneRoot,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct InitedKointSkinnedMesh;
|
||||
fn init_scene_loading(
|
||||
mut er_load_scene: EventReader<LoadSceneEvent>,
|
||||
mut ew_editor: EventWriter<EditorEvent>,
|
||||
) {
|
||||
if let Some(scene_path) = er_load_scene.read().last() {
|
||||
info!("Loading scene: {}", scene_path.0);
|
||||
ew_editor.send(EditorEvent::Load(EditorPrefabPath::File(
|
||||
scene_path.0.clone(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_scene_elements(
|
||||
mut cmd: Commands,
|
||||
@@ -305,7 +390,6 @@ fn tag_scene_elements(
|
||||
With<Name>,
|
||||
Without<Handle<Mesh>>,
|
||||
Without<SceneElement>,
|
||||
Without<NoMeshElement>,
|
||||
),
|
||||
>,
|
||||
mesh_entities: Query<Entity, With<Handle<Mesh>>>,
|
||||
@@ -319,9 +403,9 @@ fn tag_scene_elements(
|
||||
}
|
||||
}
|
||||
if has_mesh {
|
||||
cmd.entity(e).insert(SceneElement);
|
||||
cmd.entity(e).insert(SceneElement::Mesh);
|
||||
} else {
|
||||
cmd.entity(e).insert(NoMeshElement);
|
||||
cmd.entity(e).insert(SceneElement::Generic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,12 +513,18 @@ fn frame_scene_in_view(
|
||||
cam_xform.translation = cam.focus + cam_xform.back() * radius;
|
||||
}
|
||||
|
||||
fn start_capture(mut captures: Query<&mut ScreenCapture>) {
|
||||
for mut capture in captures.iter_mut() {
|
||||
capture.start_capturing();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::{LoadSceneEvent, SceneState};
|
||||
use super::{LoadLibraryObjectEvent, SceneState};
|
||||
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
static OBJECT_LOAD_REQUEST: WasmMessageBuffer<String> = WasmMessageBuffer::new();
|
||||
@@ -446,9 +536,11 @@ mod wasm {
|
||||
|
||||
// FIXME: Is it an object or a scene? Inconsistent terminology is
|
||||
// unnecessarily confusing
|
||||
pub(super) fn dispatch_scene_request(mut ew_load_scene: EventWriter<LoadSceneEvent>) {
|
||||
pub(super) fn dispatch_object_load_request(
|
||||
mut ew_load_scene: EventWriter<LoadLibraryObjectEvent>,
|
||||
) {
|
||||
if let Some(id) = OBJECT_LOAD_REQUEST.take_message() {
|
||||
ew_load_scene.send(LoadSceneEvent(id));
|
||||
ew_load_scene.send(LoadLibraryObjectEvent(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +552,7 @@ export interface SceneStateEvent extends CustomEvent {
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"scene-state": SceneStateEvent;
|
||||
}
|
||||
}
|
||||
|
||||
+46
-37
@@ -30,8 +30,7 @@ impl Plugin for ScreenShotPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(test_system, update_screen_capture_on_extract_step)
|
||||
.run_if(in_state(SceneState::Active)),
|
||||
(update_screen_capture_on_extract_step).run_if(in_state(SceneState::Active)),
|
||||
);
|
||||
|
||||
let Ok(render_world) = app.get_sub_app_mut(bevy::render::RenderApp) else {
|
||||
@@ -65,60 +64,71 @@ impl Plugin for ScreenShotPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_system(
|
||||
mut commands: Commands,
|
||||
camera: Query<Entity, With<Camera>>,
|
||||
input: Res<Input<KeyCode>>,
|
||||
) {
|
||||
if input.just_pressed(KeyCode::F10) {
|
||||
for entity in camera.iter() {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(ScreenCapture::number_of_frames(16, "./scrnshot/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Sceen Shot Component
|
||||
//==============================================================================
|
||||
|
||||
#[derive(Component, Clone)]
|
||||
pub struct ScreenCapture {
|
||||
number_of_frames: u32,
|
||||
frames: u32,
|
||||
max_frames: u32,
|
||||
to_directory: String,
|
||||
capture_depth: bool,
|
||||
currently_captureing: bool,
|
||||
}
|
||||
|
||||
impl ScreenCapture {
|
||||
pub fn number_of_frames(frames: u32, output_dir: &str) -> Self {
|
||||
pub fn from_out(output_dir: &str) -> Self {
|
||||
ScreenCapture {
|
||||
number_of_frames: frames,
|
||||
frames: 0,
|
||||
to_directory: output_dir.to_string(),
|
||||
capture_depth: false,
|
||||
currently_captureing: false,
|
||||
max_frames: u32::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_frames(mut self, max_frames: u32) -> Self {
|
||||
self.max_frames = max_frames;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn capture_depth(mut self) -> Self {
|
||||
self.capture_depth = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_complete(&self) -> bool {
|
||||
println!("Number of Frames Left: {}", self.number_of_frames);
|
||||
self.number_of_frames == 1
|
||||
pub fn is_capturing(&self) -> bool {
|
||||
self.currently_captureing
|
||||
}
|
||||
|
||||
pub fn stop_capturing(&mut self) {
|
||||
self.currently_captureing = false;
|
||||
}
|
||||
|
||||
pub fn start_capturing(&mut self) {
|
||||
self.currently_captureing = true;
|
||||
}
|
||||
|
||||
pub fn should_capture(&self) -> bool {
|
||||
self.frames < self.max_frames && self.is_capturing()
|
||||
}
|
||||
|
||||
pub fn frames_captured(&self) -> u32 {
|
||||
self.frames
|
||||
}
|
||||
|
||||
pub fn max_frames(&self) -> u32 {
|
||||
self.max_frames
|
||||
}
|
||||
}
|
||||
|
||||
fn update_screen_capture_on_extract_step(
|
||||
mut commands: Commands,
|
||||
mut screen_captures: Query<(Entity, &mut ScreenCapture)>,
|
||||
) {
|
||||
for (entity, mut screen_capture) in screen_captures.iter_mut() {
|
||||
screen_capture.number_of_frames -= 1;
|
||||
if screen_capture.number_of_frames == 0 {
|
||||
commands.entity(entity).remove::<ScreenCapture>();
|
||||
}
|
||||
fn update_screen_capture_on_extract_step(mut screen_captures: Query<&mut ScreenCapture>) {
|
||||
for mut screen_capture in screen_captures.iter_mut() {
|
||||
screen_capture.frames += 1;
|
||||
// if screen_capture.number_of_frames == 0 {
|
||||
// commands.entity(entity).remove::<ScreenCapture>();
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +138,9 @@ fn extract_screen_capture_components(
|
||||
screen_capure_comps: Extract<Query<(Entity, &ScreenCapture), With<Camera>>>,
|
||||
) {
|
||||
for (entity, screen_capture) in screen_capure_comps.iter() {
|
||||
commands.get_or_spawn(entity).insert(screen_capture.clone());
|
||||
if screen_capture.should_capture() {
|
||||
commands.get_or_spawn(entity).insert(screen_capture.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +164,7 @@ fn prepare_screen_capture_buffers(
|
||||
device: Res<RenderDevice>,
|
||||
) {
|
||||
for (entity, view_target, screen_capture) in views.iter() {
|
||||
if screen_capture.number_of_frames > 0 {
|
||||
if screen_capture.frames > 0 {
|
||||
let texture = view_target.main_texture();
|
||||
let size = texture.size();
|
||||
let format = texture.format();
|
||||
@@ -174,8 +186,6 @@ fn prepare_screen_capture_buffers(
|
||||
main_bytes_per_row: bytes_per_row,
|
||||
main_padded_bytes_per_row: padded_bytes_per_row,
|
||||
});
|
||||
|
||||
println!("Buffers Created!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +229,6 @@ impl ViewNode for ScreenCaptureNode {
|
||||
},
|
||||
view_query.1.main_texture().size(),
|
||||
);
|
||||
println!("Saved to buffer!");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -255,7 +264,7 @@ fn save_buffer_to_file(
|
||||
|
||||
let path = format!(
|
||||
"{}img_{:0.5}.png",
|
||||
screen_capture.to_directory, screen_capture.number_of_frames
|
||||
screen_capture.to_directory, screen_capture.frames
|
||||
);
|
||||
|
||||
save_buffer::<Rgba<u8>>(&main_image_values, &buffers.main_buffer_extent, &path);
|
||||
|
||||
+15
-6
@@ -39,12 +39,21 @@ struct SkyboxAssets {
|
||||
}
|
||||
|
||||
fn load_skybox(mut cmd: Commands, assets: Res<AssetServer>, id: Res<SkyboxId>) {
|
||||
let paths = SkyboxPaths::new(&**id);
|
||||
cmd.insert_resource(SkyboxAssets {
|
||||
cubemap: assets.load(paths.cubemap),
|
||||
env_diffuse: assets.load(paths.env_diffuse),
|
||||
env_specular: assets.load(paths.env_specular),
|
||||
});
|
||||
let id = &**id;
|
||||
if let Ok(color) = Color::hex(id) {
|
||||
cmd.insert_resource(ClearColor(color));
|
||||
cmd.insert_resource(AmbientLight {
|
||||
color,
|
||||
brightness: 1.0,
|
||||
})
|
||||
} else {
|
||||
let paths = SkyboxPaths::new(id);
|
||||
cmd.insert_resource(SkyboxAssets {
|
||||
cubemap: assets.load(paths.cubemap),
|
||||
env_diffuse: assets.load(paths.env_diffuse),
|
||||
env_specular: assets.load(paths.env_specular),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn skybox_assets_loaded(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use core::fmt;
|
||||
|
||||
use bevy::window::WindowResolution;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct ViewportSize {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user