Merge branch 'main' into add-inspector-v2

This commit is contained in:
rewin
2024-02-29 22:02:00 +03:00
116 changed files with 5010 additions and 1297 deletions
+6 -2
View File
@@ -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
+1 -3
View File
@@ -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
View File
@@ -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]]
+19
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+21
View File
@@ -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
View File
@@ -1 +1,2 @@
export * from "./debounce";
export * from "./on";
+1 -1
View File
@@ -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>;
+106
View File
@@ -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);
+8
View File
@@ -0,0 +1,8 @@
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"target": "ES2020"
}
}
+19
View File
@@ -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>
+71
View File
@@ -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;
}
}
+307
View File
@@ -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);
}
+172
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
import "./app/app.element";
+10
View File
@@ -0,0 +1,10 @@
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
+15
View File
@@ -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"
]
}
+34
View File
@@ -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
View File
@@ -0,0 +1,4 @@
declare module "*.scss?inline" {
const styles: import("lit").CSSResult;
export default styles;
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.svg?raw" {
const svg: string;
export default svg;
}
+29
View File
@@ -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",
]),
],
});
+9 -2
View File
@@ -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
View File
@@ -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();
});
};
}
+1 -2
View File
@@ -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
View File
@@ -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>
`;
}
}
+10 -25
View File
@@ -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 {
+59 -188
View File
@@ -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);
}
+6 -1
View File
@@ -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", [
-67
View File
@@ -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();
});
};
}
+1 -2
View File
@@ -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",
+12
View File
@@ -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).
+12
View File
@@ -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"
}
+30
View File
@@ -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": []
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./lib/button.element";
export * from "./lib/icon.element";
export * from "./lib/toolbar.element";
export * from "./lib/tree.element";
@@ -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 {
+101
View File
@@ -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>
`)}
+22
View File
@@ -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"
}
]
}
+10
View File
@@ -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"]
}
+25
View File
@@ -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"
]
}
+58
View File
@@ -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}"],
},
});
+4 -2
View File
@@ -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";
-34
View File
@@ -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);
}
}
+31 -1
View File
@@ -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>
`)}
`
}
-72
View File
@@ -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;
}
+133
View File
@@ -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>
`;
}
@@ -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>
+23
View File
@@ -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,
});
}
}
-42
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+7 -2
View File
@@ -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,
+208
View File
@@ -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
View File
@@ -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;
}
}
}
+19 -16
View File
@@ -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;
}
}
}
+92
View File
@@ -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)
}
}
+165
View File
@@ -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
View File
@@ -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) {
+35 -54
View File
@@ -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());
+1 -1
View File
@@ -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,
}
+1
View File
@@ -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};
+35 -9
View File
@@ -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
View File
@@ -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!(),
}
}
+39 -1
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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)),
)
}
}
+68
View File
@@ -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)
}
}
+22
View File
@@ -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),
);
}
}
+118
View File
@@ -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}")))
}
}
+107
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+2 -1
View File
@@ -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