diff --git a/.gitignore b/.gitignore index aaee5b4..537644e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ Thumbs.db /target /screen_shots + +/frames \ No newline at end of file diff --git a/framework/src/index.ts b/framework/src/index.ts index 3842106..a65b2ec 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -1,4 +1,5 @@ export * from "./lib/decorators"; +export * from "./lib/deref"; export * from "./lib/di"; export { default as bind, type Props as BindProps } from "./lib/directives/bind"; export { default as drag, type Props as DragProps } from "./lib/directives/drag"; diff --git a/framework/src/lib/deref.spec.ts b/framework/src/lib/deref.spec.ts new file mode 100644 index 0000000..90b7655 --- /dev/null +++ b/framework/src/lib/deref.spec.ts @@ -0,0 +1,38 @@ +import { html, render } from "lit"; +import { createRef, ref } from "lit/directives/ref.js"; +import { deref } from "./deref"; + +describe("deref", () => { + let elRef = createRef(); + + beforeEach(() => { + elRef = createRef(); + render(html`
`, document.body); + }); + + it("should return an HTML element when given an HTML element", () => { + const el = elRef.value; + expect(el).toBeDefined(); + + const derefed = deref(el); + expect(derefed).toBe(el); + }); + + it("should return an HTML element when given a `Ref`", () => { + const el = elRef.value; + const derefed = deref(elRef); + + expect(derefed).toBe(el); + }); + + it("should safely return `undefined` when given a nullish value", () => { + const v1 = deref(null); + expect(v1).toBe(undefined); + + const v2 = deref(undefined); + expect(v2).toBe(undefined); + + const v3 = deref((() => {})()); + expect(v3).toBe(undefined); + }); +}); diff --git a/framework/src/lib/deref.ts b/framework/src/lib/deref.ts new file mode 100644 index 0000000..ba10bb7 --- /dev/null +++ b/framework/src/lib/deref.ts @@ -0,0 +1,16 @@ +import { Ref } from "lit/directives/ref.js"; + +export function deref(refOrValue: Ref | T | null | undefined): T | undefined { + if (isRef(refOrValue)) + return refOrValue.value; + + return (refOrValue ?? undefined); +} + +function isRef(refOrValue: Ref | T): refOrValue is Ref { + if (!refOrValue || typeof refOrValue !== "object") + return false; + + const keys = Object.keys(refOrValue); + return keys.length === 1 && keys[0] === "value" +} diff --git a/image-generator-demo/src/app/app.element.scss b/image-generator-demo/src/app/app.element.scss index 4473358..b056665 100644 --- a/image-generator-demo/src/app/app.element.scss +++ b/image-generator-demo/src/app/app.element.scss @@ -5,6 +5,10 @@ background: #242433; } +sts-scene-data-provider { + display: contents; +} + // Layout utilities .v-start, .v-end, .h-start, .h-end, .h-center { position: absolute; diff --git a/image-generator-demo/src/app/app.element.ts b/image-generator-demo/src/app/app.element.ts index 3ea93a9..0283a7d 100644 --- a/image-generator-demo/src/app/app.element.ts +++ b/image-generator-demo/src/app/app.element.ts @@ -18,6 +18,7 @@ import { ImageInfo, LcmClient } from "./lcm-client"; import "@storyteller/studio-web/canvas-provider"; import "@storyteller/studio-web/loading"; +import "@storyteller/studio-web/scene-data-provider"; import "@storyteller/studio-web/scene-hierarchy"; import "@storyteller/studio-web/studio"; import "@storyteller/studio-web/transform-toolbar"; @@ -173,6 +174,7 @@ export class AppElement extends LitElement { protected override render = () => html` +
` : nothing} + ` } diff --git a/package-lock.json b/package-lock.json index 9d13de8..27871e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,12 @@ "license": "MIT", "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", + "axios": "^1.6.7", "lit": "^2.8.0", "lodash": "^4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "^2.3.0", - "uuid": "^9.0.1" + "tslib": "^2.3.0" }, "devDependencies": { "@nx/js": "16.9.1", @@ -48,6 +48,7 @@ "vite-plugin-dts": "~2.3.0", "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2", + "vite-plugin-web-components-hmr": "^0.1.3", "vitest": "~0.32.0", "which": "^4.0.0" } @@ -4976,8 +4977,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -4992,12 +4992,11 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", - "dev": true, + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -5606,7 +5605,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5950,7 +5948,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6531,10 +6528,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", - "dev": true, + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -6591,7 +6587,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8041,7 +8036,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -8050,7 +8044,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -8834,8 +8827,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", @@ -10202,6 +10194,7 @@ "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" @@ -10417,6 +10410,22 @@ "vite": "^2 || ^3 || ^4" } }, + "node_modules/vite-plugin-web-components-hmr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/vite-plugin-web-components-hmr/-/vite-plugin-web-components-hmr-0.1.3.tgz", + "integrity": "sha512-UF+YYOFyaie6cT7XPatRz2Xo1Fh3TdTwOdCq1Z0EjzMfdAlUiUdF3crkN0DdEhctWBvq18CHxJZUTKw7x1zSRQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-import-assertions": "^7.12.1", + "@babel/plugin-syntax-top-level-await": "^7.12.1", + "picomatch": "^2.2.2" + }, + "peerDependencies": { + "vite": ">=2" + } + }, "node_modules/vitest": { "version": "0.32.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.32.4.tgz", diff --git a/package.json b/package.json index 65ddb8a..55de6be 100644 --- a/package.json +++ b/package.json @@ -37,16 +37,17 @@ "vite-plugin-dts": "~2.3.0", "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2", + "vite-plugin-web-components-hmr": "^0.1.3", "vitest": "~0.32.0", "which": "^4.0.0" }, "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", + "axios": "^1.6.7", "lit": "^2.8.0", "lodash": "^4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "^2.3.0", - "uuid": "^9.0.1" + "tslib": "^2.3.0" } } diff --git a/studio-bvh/src/macros.rs b/studio-bvh/src/macros.rs index 026470c..6e17800 100644 --- a/studio-bvh/src/macros.rs +++ b/studio-bvh/src/macros.rs @@ -501,6 +501,7 @@ mod tests { use std::time::Duration; // #[test] // FIXME + #[allow(dead_code)] fn macro_create() { let bvh = bvh! { HIERARCHY diff --git a/studio-frontend/index.html b/studio-frontend/index.html index 49ce5c6..d419a5f 100644 --- a/studio-frontend/index.html +++ b/studio-frontend/index.html @@ -15,5 +15,14 @@ + + diff --git a/studio-frontend/src/app/anim-editor.element.scss b/studio-frontend/src/app/anim-editor.element.scss index a041364..07a10c2 100644 --- a/studio-frontend/src/app/anim-editor.element.scss +++ b/studio-frontend/src/app/anim-editor.element.scss @@ -1,21 +1,30 @@ :host { - display: flex; + display: grid; border-radius: 8px; background: #0006; backdrop-filter: blur(16px); + grid-template-columns: + [sidebar-start] min-content [sidebar-end + divider-start] 8px [divider-end + timeline-start] 1fr [timeline-end]; + padding: 8px; + row-gap: 8px; } -.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; + display: grid; overflow: hidden; + grid-column: sidebar; + grid-row: 1 / span var(--row-count); + grid-template-rows: subgrid; +} + +.timeline { + grid-column: timeline; + grid-row: 1 / span var(--row-count); + grid-template-rows: subgrid; } .btn-keyframe { @@ -28,9 +37,34 @@ background: #7A2C34E3; color: #FFF; + &.disabled { + opacity: 0.25; + } + + &:not(.disabled):hover { + background: #90363F; + } +} + +.btn-autokey { + width: 40px; + height: 36px; + border-radius: 4px; + padding: 0; + justify-content: center; + + background: #0006; + color: #FFF; + opacity: 0.5; + &:hover { background: #90363F; } + + &.active { + background: #7A2C34E3; + opacity: 1; + } } .timeline-nav { @@ -58,6 +92,8 @@ } .divider { + grid-column: divider; + grid-row: 1 / span var(--row-count); position: relative; flex: 0 0 8px; margin: { @@ -83,6 +119,25 @@ } } -.timeline { - flex: 1 0 0; -} +.tracklist { + display: grid; + grid-template-columns: subgrid; + grid-row: 2 / span var(--track-count); + + + .track { + &:hover { + background: #0000001F; + } + + &.selected { + background: #7A2C34E3; + color: #FFF; + font-weight: 500; + + &:hover { + background: #90363F !important; + } + } + } +} \ No newline at end of file diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts index 4e3cbbd..22d18f0 100644 --- a/studio-frontend/src/app/anim-editor.element.ts +++ b/studio-frontend/src/app/anim-editor.element.ts @@ -1,7 +1,7 @@ import * as studio from "@storyteller/studio"; -import { type CameraKeyframesEvent } from "@storyteller/studio"; -import { bind, drag, on } from "@storyteller/framework"; -import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit"; +import type { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe } from "@storyteller/studio"; +import { bind, drag, inject, on, provide } from "@storyteller/framework"; +import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult, PropertyValueMap } from "lit"; import { customElement, state } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; @@ -11,32 +11,179 @@ import "@storyteller/studio-ui/tree"; import "./anim-timeline.element"; import styles from "./anim-editor.element.scss?inline"; +import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "@storyteller/studio-web/scene-data-provider"; +import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree"; + +const TRACK_ICONS: Record = { + camera: "video", + transform: "up-down-left-right", + light: "light" +}; + +function isOutsideEntity( + value?: { track: string } | { outsideEntity: string } +): value is { outsideEntity: string } { + if (value == null) return false; + return "outsideEntity" in value; +} @customElement("sts-anim-editor") -export class AnimEditorElement extends LitElement { +@provide(TREE_SELECTION_PROVIDER) +export class AnimEditorElement extends LitElement + implements TreeSelectionProvider { + @inject(SCENE_DATA_PROVIDER) + sceneData?: SceneDataProvider; + static override styles = unsafeCSS(styles); @state() sidebarWidth = 256; @state() timelineStart = 0; - @state() timelineEnd = 5_000; + @state() timelineEnd = 5; @state() clipStart = 0; - @state() clipEnd = 15_000; + @state() clipEnd = 0; @state() playHead = 0; - @state() keyframes: Record = {}; + @state() playing = false; + + @state() tracks: Record = {}; + + @state() track_ordered: [string, studio.AnimationTarget][] = []; + + @state() keyframes: Record = {}; + + @state() selectedTrack?: { track: string } | { outsideEntity: string }; + + @state() keyingMode: "manual" | "automatic" = "manual"; + + trackCount(): number { + return Object.keys(this.tracks).length; + } + + get selectedId() { + return !isOutsideEntity(this.selectedTrack) && this.selectedTrack?.track || null; + } protected override update(changes: PropertyValues): void { - if (changes.has("playHead")) - studio.seekTime(this.playHead); + if (changes.has("selectedTrack")) { + const treeSelectionChanges: PropertyValues = new Map(); + const prevSelectedTrack = changes.get("selectedTrack"); + const prevTreeSelection = !isOutsideEntity(prevSelectedTrack) && prevSelectedTrack?.track || null; + treeSelectionChanges.set("selectedId", prevTreeSelection); + + this.dispatchEvent(new CustomEvent("property-changes", { + detail: treeSelectionChanges, + })); + } super.update(changes); } - @on("window:camera-keyframes") - onCameraKeyframeChanges({ detail: keyframes }: CameraKeyframesEvent): void { + setPlayTime(time: number): void { + studio.seekTime(time); + } + + @on("window:seek-time-changed") + onSeekTimeChanged({ detail: { time, playing } }: SeekTimeChanged): void { + console.log("Seek Time Changed", time, playing); + this.playHead = time; + this.playing = playing; + + if (this.playHead < this.timelineStart) { + this.timelineStart = this.playHead - (this.timelineEnd - this.timelineStart) * 0.05; + } + if (this.playHead > this.timelineEnd) { + this.timelineEnd = this.playHead + (this.timelineEnd - this.timelineStart) * 0.05; + } + } + + @on("window:update-animation-targets") + onUpdateAnimationTargets({ detail: { added, removed } }: UpdateAnimationTargets): void { + let addedKeys = Object.keys(added); + if (addedKeys.length === 0 && removed.length === 0) { + return; + } + console.log("Animation Targets Changed"); + let tracks = { ...this.tracks }; + for (let track_entity of addedKeys) { + let track = added[track_entity]; + tracks[track_entity] = track; + } + + for (let track_entity of removed) { + delete tracks[track_entity]; + } + + this.track_ordered = Object.keys(tracks).map(entity => [entity, tracks[entity].animation_target]); + + if (typeof this.selectedTrack === "object") { + if (!isOutsideEntity(this.selectedTrack) && !(this.selectedTrack.track in tracks)) { + this.selectedTrack = { outsideEntity: this.selectedTrack.track }; + } else if (isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity in tracks) { + this.selectedTrack = { track: this.selectedTrack.outsideEntity }; + } + } + + this.tracks = tracks; + } + + @on("window:update-keyframes") + onUpdateKeyframes({ detail: { added, removed } }: studio.UpdateKeyframes): void { + let addedKeyframes = Object.keys(added); + if (addedKeyframes.length === 0 && removed.length === 0) { + return; + } + console.log("Keyframes Changed"); + let keyframes = { ...this.keyframes }; + for (let keyframe of addedKeyframes) { + keyframes[keyframe] = added[keyframe]; + } + + for (let keyframe of removed) { + delete keyframes[keyframe]; + } + this.keyframes = keyframes; + + let min = Infinity + let max = -Infinity; + for (const keyframe of Object.keys(this.keyframes)) { + let time = this.keyframes[keyframe].time; + if (typeof time != "number") { + continue; + } + if (time > max) { + max = time; + } + if (time < min) { + min = time; + } + } + this.clipStart = Math.max(min, 0.); + this.clipEnd = Math.max(max, min); + } + + @on("window:keying-mode-changed") + onKeyingModeChanged({ detail: { automatic } }: studio.UpdateKeyingMode): void { + console.log("Keying Mode Changed", automatic); + this.keyingMode = automatic ? "automatic" : "manual"; + } + + + @on("window:entity-select") + onEntitySelected({ detail: id }: EntitySelectEvent): void { + if (id) { + if (this.tracks[id]) { + this.selectedTrack = { track: id }; + } else if (!this.keyframes[id]) { + this.selectedTrack = { outsideEntity: id }; + } else { + this.selectedTrack = undefined; + } + } else { + this.selectedTrack = undefined; + } } onSidebarResize(event: PointerEvent): void { @@ -44,57 +191,168 @@ export class AnimEditorElement extends LitElement { } addKeyframe(): void { - studio.addCameraKeyframe(this.playHead); + if (typeof this.selectedTrack === "object" && "track" in this.selectedTrack) { + studio.recordKeyframe([this.selectedTrack.track]); + } } jumpToStart(): void { - this.playHead = this.clipStart; + if (Math.abs(this.playHead - this.clipStart) < 0.0001) { + studio.seekTime(0); + } else { + studio.seekTime(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; + let closest_keyframe = -1 * Infinity; + + for (const keyframe of Object.keys(this.keyframes)) { + const time = this.keyframes[keyframe]?.time; + if (!time || typeof time != "number") { + continue; + } + if (time < pos && time > closest_keyframe) { + closest_keyframe = time; + } } - this.playHead = pos; + if (closest_keyframe < 0) { + closest_keyframe = 0; + } + + studio.seekTime(closest_keyframe); } 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; + let closest_keyframe = Infinity; + + for (const keyframe of Object.keys(this.keyframes)) { + const time = this.keyframes[keyframe]?.time; + if (!time || typeof time != "number") { + continue; + } + if (time > pos && time < closest_keyframe) { + closest_keyframe = time; + } } - this.playHead = pos; + if (closest_keyframe > this.clipEnd) { + return; + } + + studio.seekTime(closest_keyframe); } jumpToEnd(): void { - this.playHead = this.clipEnd; + studio.seekTime(this.clipEnd); } - protected override render = (): TemplateResult => html` + addTarget(): void { + if (isOutsideEntity(this.selectedTrack)) { + studio.addEntityTrack(this.selectedTrack.outsideEntity); + } + } + + togglePlaying(): void { + console.log("toggling play"); + if (this.playing) { + studio.pause(); + } else { + studio.play(); + } + } + + protected override render(): TemplateResult { + + const track_order_hashed: Record = {}; + const track_animation_target_order_hashed: Record = {}; + + for (const [index, [track, target]] of this.track_ordered.entries()) { + track_order_hashed[track] = index; + track_animation_target_order_hashed[target] = index; + } + + const selected = !isOutsideEntity(this.selectedTrack) && this.selectedTrack?.track || false; + const canAddSelected = isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity || false; + + let length = this.timelineEnd - this.timelineStart; + let clip_start = (this.clipStart - this.timelineStart) / length; + let clip_end = (this.clipEnd - this.timelineStart) / length; + + + let timeline_css_variables = { + '--track-count': `${this.trackCount()}`, + '--row-count': `calc(var(--track-count) + ${canAddSelected ? 3 : 2})`, + '--clip-end': `${clip_end * 100}%`, + '--clip-start': `${clip_start * 100}%` + }; + + const tracklist = this.track_ordered.map(([entity,]) => { + let track = this.tracks[entity]; + let name = track.name ?? track.target_type; + let icon = this.sceneData?.entityMap.get(entity)?.icon ?? "minus"; + return html` + { + studio.select(entity); + }} + .id=${entity} + > + `; + }); + + if (canAddSelected) { + tracklist.push(html` + + `); + } + + const none_selected = !selected; + + return html` -
- `; -} + ` + }; +}; + diff --git a/studio-frontend/src/app/anim-timeline.element.scss b/studio-frontend/src/app/anim-timeline.element.scss index fa1b681..b1831c4 100644 --- a/studio-frontend/src/app/anim-timeline.element.scss +++ b/studio-frontend/src/app/anim-timeline.element.scss @@ -1,6 +1,9 @@ :host { position: relative; overflow: hidden; + display: grid; + grid-template-rows: subgrid; + grid-row: 1 / span var(--row-count); } *:focus-visible { @@ -8,19 +11,34 @@ } .wrapper { - position: absolute; + position: relative; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: subgrid; inset: 0; + grid-row: 1 / span var(--row-count); + + background: linear-gradient(90deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.2) calc(var(--clip-start) - 1px), rgba(255,255,255,0.1) var(--clip-start), rgba(255,255,255,0.1) var(--clip-end), rgba(0,0,0,0.2) calc(var(--clip-end) + 1px)); &.panning { cursor: grabbing; } -} -.clip-region { - position: absolute; - top: 0; - bottom: 0; - background: #0004; + .zoom-controls { + grid-row: var(--row-count); + background: rgba(0,0,0,1); + height: 10px; + width: 100%; + z-index: 1; + } + + .timeline-nav-dragger { + grid-row: 1 / span 1; + grid-column: 1 / span 1; + background: #000; + opacity: 0.1; + height: 1.5em; + } } .hash { @@ -37,7 +55,7 @@ .timestamp { position: absolute; - top: 8px; + grid-row: 1; color: #FFF6; font: { family: "Fira Sans", sans-serif; @@ -100,8 +118,7 @@ .keyframe { position: absolute; - top: 50%; - transform: translateX(-4.5px) translateY(-4.5px); + transform: translateX(-4.5px); &::before { content: ""; diff --git a/studio-frontend/src/app/anim-timeline.element.ts b/studio-frontend/src/app/anim-timeline.element.ts index 537c56d..1b0683f 100644 --- a/studio-frontend/src/app/anim-timeline.element.ts +++ b/studio-frontend/src/app/anim-timeline.element.ts @@ -22,7 +22,7 @@ import styles from "./anim-timeline.element.scss?inline"; // which would look something like: // ``` // @memo(["rangeStart", "rangeEnd", "_renderWidth"]) -// msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); +// secondsPerPx = 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 @@ -54,51 +54,53 @@ class Computed extends Number { export class AnimTimelineElement extends LitElement { static override styles = unsafeCSS(styles); - /** The time (in ms) at the left edge of the timeline view */ + /** The time (in seconds) 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 */ + /** The time (in seconds) at the right edge of the timeline view */ + @property({ type: Number }) rangeEnd = 5; + + + /** The time (in seconds) at the start of the clip */ @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 seconds) at the end of the clip */ + @property({ type: Number }) clipEnd = 5; + /** The number of frames per second for this animation clip */ + @property({ type: Number }) clipFps = 24; - /** The time (in ms) of the current play-head position */ + /** The time (in seconds) of the current play-head position */ @property({ type: Number }) playHead = 0; - /** Keyframe positions (in ms) */ - @property({ attribute: false }) keyframes: Record = {}; + + /** Keyframe positions (in seconds) */ + @property({ attribute: false }) keyframes: Record = {}; + @property({ attribute: false }) track_ordered: Record = {}; + @property({ attribute: false }) track_animation_target_ordered: Record = {}; @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; + @state() _scrubbing = false; - /** How many ms are represented by each px of horizontal space. */ - msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); + /** How many seconds are represented by each px of horizontal space. */ + secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth); #resizeObserver: ResizeObserver; constructor () { super(); - this.#resizeObserver = new ResizeObserver(() => this.#updateRenderWidth()); + this.#resizeObserver = new ResizeObserver(() => this.updateRenderWidth()); this.#resizeObserver.observe(this); } override connectedCallback(): void { super.connectedCallback(); - this.#updateRenderWidth(); + this.updateRenderWidth(); } override disconnectedCallback(): void { @@ -107,12 +109,13 @@ export class AnimTimelineElement extends LitElement { } protected override update(changes: PropertyValues): void { + console.log("Changes: ", changes); if ( changes.has("rangeStart") || changes.has("rangeEnd") || changes.has("_renderWidth") ) { - this.msPerPx.invalidate(); + this.secondsPerPx.invalidate(); } super.update(changes); @@ -123,12 +126,10 @@ export class AnimTimelineElement extends LitElement { event.preventDefault(); event.stopImmediatePropagation(); - if (event.ctrlKey) { - this.#zoomTimeline(event.offsetX, event.deltaY); + if (event.ctrlKey || Math.abs(event.deltaY ) > 0) { + this.zoomTimeline(event.clientX, event.deltaY); } else if (event.deltaX) { - this.#pan(-event.deltaX); - } else if (event.shiftKey && event.deltaY) { - this.#pan(-event.deltaY); + this.pan(-event.deltaX); } } @@ -140,8 +141,9 @@ export class AnimTimelineElement extends LitElement { } if (Array.from(Object.keys(this.keyframes)).includes(id)) { + let keyframe = this.keyframes[id]; this._selectedKeyframe = id; - this.dispatchEvent(new CustomEvent("playHead-change", { detail: this.keyframes[id]! })); + studio.seekTime(keyframe.time); } else { this._selectedKeyframe = null; } @@ -151,183 +153,236 @@ export class AnimTimelineElement extends LitElement { studio.select(id); } - #zoomTimeline(origin: number, delta: number): void { + zoomTimeline(origin: number, delta: number): void { const currentRange = this.rangeEnd - this.rangeStart; - const deltaRange = delta * 4; + const elementRect = this.getBoundingClientRect(); + const focusPoint = (origin - elementRect.x) / elementRect.width; - // 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); + const deltaTimeScale = delta * 0.001 * currentRange; + + const rangeStart = Math.max(this.rangeStart - deltaTimeScale * focusPoint, 0.); + const rangeEnd = Math.max(this.rangeEnd + deltaTimeScale * (1 - focusPoint), rangeStart + 0.01); this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart })); this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd })); } - #onDragStart(): void { + onMidDragStart(): void { this._panning = true; } - #onDragEnd(): void { + onMidDragEnd(): void { this._panning = false; } - #onDrag(event: PointerEvent): void { - this.#pan(event.movementX); + onMidDrag(event: PointerEvent): void { + this.pan(event.movementX); } - #pan(deltaPx: number): void { - this._offsetX += deltaPx; + onDragStart(): void { + this._scrubbing = true; + } - const msPerPx = this.msPerPx.get(); - const rangeStart = this.rangeStart - deltaPx * msPerPx; - const rangeEnd = this.rangeEnd - deltaPx * msPerPx; + onDragEnd(): void { + this._scrubbing = false; + } + + onDrag(event: PointerEvent): void { + let elementBounds = this.getBoundingClientRect(); + let relativeX = event.clientX - elementBounds.left; + let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1)); + const seconds = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart; + const updated = Math.round(seconds * this.clipFps) / this.clipFps; + + studio.seekTime(updated); + } + + pan(deltaPx: number): void { + const deltaChange = deltaPx * 0.001; + const scaledChange = (this.rangeEnd - this.rangeStart) * deltaChange; + + let rangeStart = this.rangeStart - scaledChange; + let rangeEnd = Math.max(this.rangeEnd - scaledChange, rangeStart + 0.01); + + if (rangeStart < 0) { + rangeEnd = this.rangeEnd - this.rangeStart; + rangeStart = 0; + } this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart })); this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd })); } - #onPlayheadDrag(event: PointerEvent): void { - const deltaMs = event.movementX * this.msPerPx.get(); - const updated = this.playHead + deltaMs; + onPlayheadDrag(event: PointerEvent): void { + let elementBounds = this.getBoundingClientRect(); + let relativeX = event.clientX - elementBounds.left; + let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1)); + const seconds = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart; + const updated = Math.round(seconds * this.clipFps) / this.clipFps; - this.dispatchEvent(new CustomEvent("playHead-change", { detail: updated })); + studio.seekTime(updated); } - #onKeyframeDrag(id: string, event: PointerEvent): void { - const deltaMs = event.movementX * this.msPerPx.get(); - const updated = this.keyframes[id] + deltaMs; + onSetPlayhead(event: PointerEvent): void { + let elementBounds = this.getBoundingClientRect(); + let relativeX = event.clientX - elementBounds.left; + let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1)); + const seconds = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart; + const updated = Math.round(seconds * this.clipFps) / this.clipFps; - studio.updateKeyframe(id, updated); - - const updatedKeyframes = Object.fromEntries( - Object - .entries(this.keyframes) - .map(entry => entry[0] === id - ? [id, updated] - : entry - ) - ); - this.dispatchEvent(new CustomEvent("keyframes-change", { detail: updatedKeyframes })); + studio.seekTime(updated); } - #updateRenderWidth(): void { + onKeyframeDrag(id: string, event: PointerEvent): void { + let elementBounds = this.getBoundingClientRect(); + let relativeX = event.clientX - elementBounds.left; + let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1)); + const time = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart; + const final_time = Math.round(time * this.clipFps) / this.clipFps; + + studio.moveKeyframeTime(id, final_time); + } + + updateRenderWidth(): void { this._renderWidth = this.getBoundingClientRect().width; } /** - * Format a time value (in ms) for displaying in the timeline view + * Format a time value (in seconds) for displaying in the timeline view */ - #timestamp(ms: number) { - const sign = ms < 0 ? "-" : ""; - ms = Math.abs(ms); + timestamp(seconds: number, major: boolean) { + const sign = seconds < 0 ? "-" : ""; + seconds = Math.abs(seconds); - 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; - } + let minutes = Math.floor(seconds / 60); + let secondsInMinute = seconds % 60; + let flooredSeconds = Math.floor(secondsInMinute); + let fraction = Math.round(this.clipFps * (secondsInMinute - flooredSeconds)); return html`${ sign - }${ - m.toString(10).padStart(2, "0") + }${major || fraction == 0 ? html`${ + minutes.toString(10).padStart(2, "0") }:${ - s.toString(10).padStart(2, "0") - }`; + flooredSeconds.toString(10).padStart(2, "0") + }` : ``} ${fraction != 0 ? html`f${ + fraction.toString(10) + }` : `` }`; } - /** - * 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(); + protected override render() { const range = this.rangeEnd - this.rangeStart; - let subdivsPerSec = 32; - let subdivsCount = (range / 1000) * subdivsPerSec; - let pxPerSubdiv = this._renderWidth / subdivsCount; + let fps = this.clipFps; + + let frameRange = Math.ceil(range * this.clipFps); - while (pxPerSubdiv < 64) { - subdivsPerSec /= 2; - subdivsCount = (range / 1000) * subdivsPerSec; - pxPerSubdiv = this._renderWidth / subdivsCount; + let spf = 1 / fps; + + let majorSubdivisionSize = this.clipFps; + let minorSubdivisionSize = this.clipFps / 2; + let unlablelledSubdivisionSize = this.clipFps / 4; + + if (range < 0.5) { + majorSubdivisionSize = this.clipFps / 2; + minorSubdivisionSize = this.clipFps / 12; + unlablelledSubdivisionSize = 1; + } else if (range < 2) { + majorSubdivisionSize = this.clipFps / 2; + minorSubdivisionSize = this.clipFps / 4; + unlablelledSubdivisionSize = 2; + } else if (range < 5) { + majorSubdivisionSize = this.clipFps; + minorSubdivisionSize = this.clipFps / 2; + unlablelledSubdivisionSize = this.clipFps / 4; + } else if (range < 10) { + majorSubdivisionSize = this.clipFps * 2; + minorSubdivisionSize = this.clipFps; + unlablelledSubdivisionSize = this.clipFps / 2; + } else if (range < 60) { + majorSubdivisionSize = this.clipFps * 10; + minorSubdivisionSize = this.clipFps * 5; + unlablelledSubdivisionSize = this.clipFps; + } else if (range < 120) { + majorSubdivisionSize = this.clipFps * 20; + minorSubdivisionSize = this.clipFps * 10; + unlablelledSubdivisionSize = this.clipFps * 2; + } else { + majorSubdivisionSize = Math.ceil(frameRange / 10); + minorSubdivisionSize = Math.ceil(frameRange / 20); + unlablelledSubdivisionSize = Math.ceil(frameRange / 40); } + + let subdivisions : Array<{seconds: number, fraction: number, isMajor: boolean, isLabelled: boolean}> = []; - 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; + let initialFrame = Math.ceil(this.rangeStart * this.clipFps); + for (let i = 0; i < frameRange; i++) { + let frame = i + initialFrame; + let seconds = frame * spf; + let fraction = (seconds - this.rangeStart) / range; + if (frame % majorSubdivisionSize === 0) { + subdivisions.push({ + seconds, + fraction, + isMajor: true, + isLabelled: true + }); + } else if (frame % minorSubdivisionSize === 0) { + subdivisions.push({ + seconds, + fraction, + isMajor: false, + isLabelled: true + }); + } else if (frame % unlablelledSubdivisionSize === 0) { + subdivisions.push({seconds, fraction, isMajor: false, isLabelled: false }); + } + } + return html`
-
+
- ${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; + ${subdivisions.map(({ seconds, fraction, isMajor, isLabelled}, idx) => { + const secondsPercentage = fraction * 100; return html`
- ${strong ? html` + ${isMajor || isLabelled ? html` - ${this.#timestamp(ms)} + ${this.timestamp(seconds, isMajor)} ` : nothing} `; @@ -335,10 +390,10 @@ export class AnimTimelineElement extends LitElement {
@@ -349,22 +404,26 @@ export class AnimTimelineElement extends LitElement { ${repeat( Object.entries(this.keyframes), ([id]) => id, - ([id, keyframe]) => html` + ([id, keyframe]) => { + let row = this.track_animation_target_ordered[keyframe.target]; + return html`
this.#onKeyframeDrag(id, event), + move: event => this.onKeyframeDrag(id, event), })} @click=${() => this.selectKeyframe(id)} >
- ` + `; + } )}
`; diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 5d05551..5ad99a2 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -165,3 +165,9 @@ sts-button.active { } } } + +// TODO: Move this +.menu-button-icon { + font-size: 12px; + opacity: 0.75; +} diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 9212f25..b7e55a8 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,4 +1,4 @@ -import { drag, on } from "@storyteller/framework"; +import { drag, on, provide } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { type SceneSavedEvent, @@ -6,8 +6,11 @@ import { SceneState, StudioMode, } from "@storyteller/studio"; +import { ButtonElement } from "@storyteller/studio-ui/button"; +import { DialogElement } from "@storyteller/studio-ui/dialog"; import { SceneHierarchyElement, + StorytellerApi, StudioURLParams, } from "@storyteller/studio-web"; import { match } from "@storyteller/utility"; @@ -16,27 +19,33 @@ 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 { v4 as uuid } from "uuid"; import "@storyteller/studio-ui/button"; +import "@storyteller/studio-ui/dialog"; import "@storyteller/studio-ui/icon"; +import "@storyteller/studio-ui/overlay-provider"; import "@storyteller/studio-ui/toolbar"; import "@storyteller/studio-ui/tree"; +import "@storyteller/studio-web/add-to-scene-menu"; import "@storyteller/studio-web/canvas-provider"; import "@storyteller/studio-web/entity-inspector"; import "@storyteller/studio-web/iframe-messaging-bridge"; import "@storyteller/studio-web/loading"; +import "@storyteller/studio-web/media-library-dialog"; 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 "./blocking.element"; - import "./app.element.scss"; +import { mock_save_load } from "./util"; @customElement("app-root") +@provide({ + token: StorytellerApi, + provide() { return this.api; } +}) export class AppElement extends LitElement { @state() showHelp = false; @state() showInspector = false; @@ -46,8 +55,11 @@ export class AppElement extends LitElement { @state() inspectorWidth = 320; @state() hierarchyHeight?: number; - #enableAnimTimeline = false; // TODO + #enableAnimTimeline = true; // TODO + private api = new StorytellerApi(); + #addToSceneButtonRef = createRef(); + #mediaLibraryRef = createRef(); #inspectorRef = createRef(); #hierarchyRef = createRef(); @@ -59,33 +71,44 @@ export class AppElement extends LitElement { @on("scene-saved") async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise { - 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, - }); + const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); + if (mock_save_load()) { + /* + // NB(brandon, 2024-03-22): Hey Lee-Orr, I'm commenting this out since the file + picker isn't defined and the Netlify builds are breaking. Feel free to uncomment + this for your testing. We probably just need a local definition file so the + builds are happy. - if (response.ok) { - const resultJson: { - success: boolean; - media_file_token: string; - } = await response.json(); + let handle = await window.showSaveFilePicker({ + suggestedName: "scene", + types: [{ + description: "Scene Files", + accept: { + "text/ron": [".scn.ron"] + } + }] + }); + let writable = await handle.createWritable(); + await writable.write(file); + await writable.close(); + let token = handle.name; - if (resultJson.success) { - this.dispatchEvent(new CustomEvent("scene-uploaded", { - detail: resultJson.media_file_token, - bubbles: true, - composed: true, - })); - } + this.dispatchEvent(new CustomEvent("scene-uploaded", { + detail: token, + bubbles: true, + composed: true, + })); + */ + + } else { + const token = await this.api.createScene(file); + + this.dispatchEvent(new CustomEvent("scene-uploaded", { + detail: token, + bubbles: true, + composed: true, + })); } } catch (err) { @@ -144,7 +167,9 @@ export class AppElement extends LitElement { this.showHelp = !this.showHelp; } - protected override render = () => html` + protected override render() { + return html` + @@ -174,6 +199,16 @@ export class AppElement extends LitElement { > Save + + Add to Scene + + ` : nothing} ${this.#params.mode === StudioMode.Editor ? html` - + { + this.#mediaLibraryRef.value?.open(); + }} + > + @@ -276,5 +320,7 @@ export class AppElement extends LitElement { ` : nothing} + `; + } } diff --git a/studio-frontend/src/app/blocking.element.scss b/studio-frontend/src/app/blocking.element.scss deleted file mode 100644 index 19a9a3d..0000000 --- a/studio-frontend/src/app/blocking.element.scss +++ /dev/null @@ -1,8 +0,0 @@ -:host { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - position: absolute; - top: 60px; - left: 16px; -} \ No newline at end of file diff --git a/studio-frontend/src/app/blocking.element.ts b/studio-frontend/src/app/blocking.element.ts deleted file mode 100644 index 9b88573..0000000 --- a/studio-frontend/src/app/blocking.element.ts +++ /dev/null @@ -1,41 +0,0 @@ - -import { LitElement, html, nothing, unsafeCSS } from "lit"; - -import styles from "./blocking.element.scss?inline"; -import { customElement } from "lit/decorators.js"; -import * as studio from "@storyteller/studio"; - -@customElement("sts-blocking") -export class BlockingElement extends LitElement { - static override styles = unsafeCSS(styles); - - override render = () => html` - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Cube); - }}> - Cube - - - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Sphere); - }}> - Sphere - - - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Cylinder); - }}> - Cylinder - - - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Torus); - }}> - Torus - - `; -} \ No newline at end of file diff --git a/studio-frontend/src/app/util.ts b/studio-frontend/src/app/util.ts index 302c206..20bcc69 100644 --- a/studio-frontend/src/app/util.ts +++ b/studio-frontend/src/app/util.ts @@ -3,3 +3,7 @@ export function isDevEnvironment(): boolean { || window.location.hostname === "127.0.0.1" || window.location.hostname.includes("deploy-preview"); } + +export function mock_save_load(): boolean { + return window.location.search.includes("mock-saves"); +} \ No newline at end of file diff --git a/studio-frontend/tsconfig.json b/studio-frontend/tsconfig.json index 724ccfc..b5d79d1 100644 --- a/studio-frontend/tsconfig.json +++ b/studio-frontend/tsconfig.json @@ -6,7 +6,7 @@ "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "Bundler", @@ -22,6 +22,7 @@ "noImplicitAny": true, "noImplicitOverride": true, "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, "types": ["vite/client"] }, diff --git a/studio-frontend/vite.config.ts b/studio-frontend/vite.config.ts index 2d5913f..0a03965 100644 --- a/studio-frontend/vite.config.ts +++ b/studio-frontend/vite.config.ts @@ -6,12 +6,14 @@ 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"; +import { hmrPlugin, presets} from "vite-plugin-web-components-hmr"; export default defineConfig({ cacheDir: "../node_modules/.vite/studio-frontend", server:{ port: 4200, host: "localhost", + hmr: true, }, preview:{ port: 4300, @@ -25,5 +27,9 @@ export default defineConfig({ "studio-frontend/public", "dist/studio/public", ]), + hmrPlugin({ + include: ['./src/**/*.ts'], + presets: [presets.lit] + }) ], }); diff --git a/studio-ui/src/assets/icons/cylinder.svg b/studio-ui/src/assets/icons/cylinder.svg new file mode 100644 index 0000000..c411bd6 --- /dev/null +++ b/studio-ui/src/assets/icons/cylinder.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/assets/icons/sphere.svg b/studio-ui/src/assets/icons/sphere.svg new file mode 100644 index 0000000..7c948a1 --- /dev/null +++ b/studio-ui/src/assets/icons/sphere.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio-ui/src/assets/icons/torus.svg b/studio-ui/src/assets/icons/torus.svg new file mode 100644 index 0000000..7cd4b8b --- /dev/null +++ b/studio-ui/src/assets/icons/torus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio-ui/src/index.ts b/studio-ui/src/index.ts index fe3b7a5..b75f273 100644 --- a/studio-ui/src/index.ts +++ b/studio-ui/src/index.ts @@ -1,5 +1,8 @@ export * from "./lib/button.element"; +export * from "./lib/dialog"; export * from "./lib/icon.element"; +export * from "./lib/menu.element"; +export * from "./lib/overlay-provider.element"; export * from "./lib/progress.element"; export * from "./lib/toolbar.element"; export * from "./lib/tree.element"; diff --git a/studio-ui/src/lib/button.element.scss b/studio-ui/src/lib/button.element.scss index a171b45..42c2d83 100644 --- a/studio-ui/src/lib/button.element.scss +++ b/studio-ui/src/lib/button.element.scss @@ -35,6 +35,7 @@ :host(.disabled:hover) { background: #0003; color: #FFF6; + cursor: default; } .icon { diff --git a/studio-ui/src/lib/button.element.ts b/studio-ui/src/lib/button.element.ts index 73d6f5b..2a532e0 100644 --- a/studio-ui/src/lib/button.element.ts +++ b/studio-ui/src/lib/button.element.ts @@ -10,10 +10,12 @@ import "./icon.element"; export class ButtonElement extends LitElement { static override styles = unsafeCSS(styles); + @property({ reflect: true }) + override role = "button"; + @property() icon?: string; @property({ type: Boolean }) disabled = false; - override role = "button"; override tabIndex = 0; protected override willUpdate(changes: PropertyValues): void { @@ -35,9 +37,13 @@ export class ButtonElement extends LitElement { } protected override render = () => html` - ${this.icon - ? html`` - : nothing} + ${this.icon ? html` + + ` : nothing} ` } diff --git a/studio-ui/src/lib/dialog/dialog-base.element.scss b/studio-ui/src/lib/dialog/dialog-base.element.scss new file mode 100644 index 0000000..05a4edd --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog-base.element.scss @@ -0,0 +1,41 @@ +*, *::before, *::after { + font-family: "Fira Sans", sans-serif; + box-sizing: border-box; +} + +:host { + display: flex; + flex-direction: column; + + background: #0006; + color: #FFFC; + backdrop-filter: blur(32px); + border-radius: 0.5rem; + overflow: hidden; +} + +.main { + display: flex; + flex: 1 0 0; +} + +.content { + display: flex; + flex-direction: column; + flex: 0 0 auto; + gap: 1rem; + border: { + inline: 0.5rem solid #0004; + block-end: 0.5rem solid #0004; + } + padding: 1rem; +} + +[part="sidebar"]:not(:empty) { + padding: 1rem; + background: #0004; + + + .content { + border-inline-start: none; + } +} diff --git a/studio-ui/src/lib/dialog/dialog-base.element.ts b/studio-ui/src/lib/dialog/dialog-base.element.ts new file mode 100644 index 0000000..7d9e7a5 --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog-base.element.ts @@ -0,0 +1,118 @@ +import { on } from "@storyteller/framework"; +import { LitElement, TemplateResult, html, unsafeCSS } from "lit"; +import { customElement, property, queryAssignedElements } from "lit/decorators.js"; +import { v4 as uuid } from "uuid"; + +import { type DialogHeaderElement } from "./dialog-header.element"; + +import styles from "./dialog-base.element.scss?inline"; + +/** + * A base template for building custom dialogs. Can be extended via inheritance + * or customized with [named slots](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) + * and [CSS parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part). + * + * @slot `dialog-header` + * + * Insert a custom header. Can alternatively be configured via the + * {@linkcode heading} and {@linkcode icon} properties. See also + * {@linkcode DialogHeaderElement}. + * + * @slot `sidebar` + * + * Insert a custom sidebar (empty by default). + * + * @slot `footer` + * + * Insert a custom footer (empty by default and not currently styled). + * + * @slot (default) + * + * Projected content without a named `slot` attribute will appear in the main + * content area. + * + * @part `header` + * + * A CSS part for styling the default dialog header. Has no effect if a custom + * `sts-dialog-header` or `[part="dialog-header"]` is inserted. + * + * @part `main` + * + * A CSS part for styling the wrapper around the `sidebar` and `content` areas. + * + * @part `content` + * + * A CSS part to style the wrapper around the main content area. + */ +@customElement("sts-dialog-base") +export class DialogBaseElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "dialog"; + + @property({ + attribute: "aria-label", + reflect: true, + }) + heading?: string; + + @property() icon?: string; + + override ariaModal = "true"; + override tabIndex = -1; + + @queryAssignedElements({ slot: "dialog-header" }) + dialogHeaders!: Element[]; + + override connectedCallback(): void { + this.focus(); + super.connectedCallback(); + } + + @on("keydown") + onKeydown(event: KeyboardEvent): void { + if (/^(Escape|Shift)$/.test(event.key)) + event.stopPropagation(); + + if (event.key === "Escape") + this.dispatchEvent(new CustomEvent("close")); + } + + onDialogHeaderChanged(): void { + if (this.dialogHeaders.length && !this.ariaLabel) { + const header = this.dialogHeaders[0]; + header.id = uuid(); + this.setAttribute("aria-labelledby", header.id); + } + else if (!this.dialogHeaders.length && this.hasAttribute("aria-labelledby")) { + this.removeAttribute("aria-labelledby"); + } + } + + protected override render(): TemplateResult { + return html` + + + ${this.heading} + + + +
+ + +
+ +
+
+ + + `; + } +} diff --git a/studio-ui/src/lib/dialog/dialog-header.element.scss b/studio-ui/src/lib/dialog/dialog-header.element.scss new file mode 100644 index 0000000..dcb827a --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog-header.element.scss @@ -0,0 +1,44 @@ +@use "sass:math"; + +:host { + display: flex; + align-items: center; + justify-content: space-between; + height: 3rem; + padding: 0 { + inline-start: 1rem; + } + background: #0006; +} + +.title { + display: flex; + align-items: center; + gap: math.div(12, 16) * 1rem; + + &__icon { + color: #FFFC; + opacity: 0.75; + } + + &__heading { + color: #FFFC; + font: { + weight: 500; + size: math.div(15, 16) * 1rem; + } + } +} + +.close { + width: 3rem; + height: 3rem; + font-size: 1rem; + color: #FFFC; + opacity: 0.75; + + &:hover { + color: #FFFF; + opacity: 1; + } +} diff --git a/studio-ui/src/lib/dialog/dialog-header.element.ts b/studio-ui/src/lib/dialog/dialog-header.element.ts new file mode 100644 index 0000000..68160b1 --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog-header.element.ts @@ -0,0 +1,100 @@ +import { LitElement, html, nothing, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { v4 as uuid } from "uuid"; + +import styles from "./dialog-header.element.scss?inline"; + +/** + * A configurable header for custom dialogs. This component should be preferred + * over inserting a wholly custom `[part="dialog-header"]`. + * + * @slot (default) + * + * Projected content without a named `slot` attribute will appear as the + * "heading", next to the icon. This should usually be simple text content + * without any markup elements. + * + * @slot `extras` + * + * Use this slot to insert arbitrary content in between the title and close + * button. + * + * @slot `close` + * + * Insert a custom close button, or an empty `[slot="close"]` to remove the + * default close button. + * + * @part `title` + * + * A CSS part for styling the wrapper around the icon and heading. + * + * @part `icon` + * + * A CSS part for styling the icon. + * + * @part `heading` + * + * A CSS part for styling the wrapper around the slotted heading (default slot). + * + * @part `close` + * + * A CSS part for styling the default "close" button. Has no effect if a custom + * `[part="close"]` is inserted. + */ +@customElement("sts-dialog-header") +export class DialogHeaderElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override id = uuid(); + + @property({ reflect: true }) + override slot = "dialog-header"; + + @property() icon?: string; + + onClose(): void { + this.dispatchEvent(new CustomEvent("close", { + bubbles: true, + composed: true, + })); + } + + protected override render = () => html` +
+ ${this.icon ? html` + + ` : nothing} + + + Dialog + +
+ + + + + ${/* + Note: "Close" button is intentionally hidden from the a11y tree + because because the Esc key is the ARIA-standard interaction for + closing a dialog. + */ nothing} + + + `; +} diff --git a/studio-ui/src/lib/dialog/dialog.element.ts b/studio-ui/src/lib/dialog/dialog.element.ts new file mode 100644 index 0000000..07d0d74 --- /dev/null +++ b/studio-ui/src/lib/dialog/dialog.element.ts @@ -0,0 +1,399 @@ +import { deref, inject } from "@storyteller/framework"; +import { type ArrayOrSingle, asArray, exists } from "@storyteller/utility"; +import { LitElement, TemplateResult, type PropertyValues } from "lit"; +import { html, unsafeStatic } from "lit/static-html.js"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { Ref } from "lit/directives/ref.js"; + +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "../overlay-provider.element"; +import { type DialogBaseElement } from "./dialog-base.element"; + +/** + * A simple, WIP [dialog widget](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) + * implementation. + * + * @warning + * This component should be considered experimental! Its API is still a work-in- + * progress, potential use cases have not been thoroughly explored, and it may + * not behave as expected in all circumstances. + * + * @example + * ``` + * \@customElement("my-dialog") + * class MyDialogElement extends DialogBaseElement { + * render = () => html` + * + * My Dialog + * + *
+ *
+ *

Hello, world!

+ *
+ *
+ * ` + * } + * ``` + * ### Trigger-based implementation + * ``` + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogTriggerRef = createRef(); + * + * render = () => html` + * + * + * Open Dialog + * + * + * + * + * ` + * } + * ``` + * ### Manual-open implementation + * ``` + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogRef = createRef(); + * + * render = () => html` + * + * { + * this.dialogRef.value?.open(); + * }} + * > + * Open Dialog + * + * + * + * + * ` + * } + * ``` + */ +@customElement("sts-dialog") +export class DialogElement extends LitElement { + /** + * Tag-name of a custom element inheriting from {@linkcode DialogBaseElement}` + */ + @property() + tag?: string; + + /** + * A custom Lit template to render. Use this when you want to render some + * quick-and-dirty custom dialog content without extending the {@linkcode DialogBaseElement} + * class, or when you need to dynamically set the properties or slot content + * of your custom dialog element. + * + * @example + * + * ### Quick custom content + * ``` + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogTriggerRef = createRef(); + * + * render = () => html` + * + * + * Open Dialog + * + * + * Hey folks here's my dialog, hope you like it!

+ * `] as const} + * >
+ *
+ * ` + * } + * ``` + * + * @example + * + * ### More complex custom content + * ``` + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogTriggerRef = createRef(); + * + * get dialogTemplate() { + * return html` + * + * My Dialog + * + * + *
+ *

Custom sidebar

+ *
    + *
  • Lorem ipsum
  • + *
  • Dolor
  • + *
  • Sit amet
  • + *
+ *
+ * + *

These paragraphs will appear in the main content area.

+ *

Lorem ipsum dolor sit amet.

+ *

Consectetur adipiscing elit.

+ * + *
+ * + * Confirm + * + * + * Cancel + * + *
+ * ` + * } + * + * render = () => html` + * + * + * Open Dialog + * + * + * + * + * ` + * } + * ``` + * + * @example + * ### Dynamic custom dialog class + * ``` + * import styles from "./my-counter-dialog.element.scss?inline"; + * + * \@customElement("my-counter-dialog") + * class CounterDialogElement extends DialogBaseElement { + * static override styles = css` + * ${DialogBaseElement.styles} + * ${unsafeCSS(styles)} + * `; + * + * \@property({ type: Number }) + * count = 0; + * + * render = () => html` + * + * Counter Dialog + * + * + *
+ * + *

The count is ${this.count}.

+ * + *
+ *
+ * + *
+ * this.dispatchEvent(new CustomEvent("increment"))} + * > + * ++ + * + *
+ * ` + * } + * + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogTriggerRef = createRef(); + * dialogRef = createRef(); + * + * \@state() count = 0; + * + * onIncrement(): void { + * this.count++; + * } + * + * onClose(): void { + * this.dialogRef.value?.close(); + * } + * + * get dialogTemplate() { + * return html` + * + * ${this.count === 69 ? html` + *

(Nice.)

+ * ` : nothing} + *
+ * ` + * } + * + * render = () => html` + * + * + * Open Dialog + * + * + * + * + * ` + * } + * ``` + * + * @see {@linkcode skipWrapper} + * @see {@linkcode DialogBaseElement} + */ + @property({ attribute: false }) + template?: [LitElement, TemplateResult]; + + /** + * Use this in conjunction with the {@linkcode template} property to render + * a custom dialog element inheriting from {@linkcode DialogBaseElement} with + * dynamic property binding and/or slotted content. + * + * This avoids wrapping your custom dialog template in a redundant + * `` tag, which is important for UX and + * accessibility when rendering a custom component that already implements + * the [dialog widget](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) + * spec. + */ + @property({ type: Boolean }) + skipWrapper = false; + + /** + * If this property is `true`, the dialog will not be immediately closed when + * its backdrop is clicked. + */ + @property({ type: Boolean }) + ignoreBackdropClick = false; + + /** + * ARIA role to pass to the custom element defined by `tag`. + * + * Has no effect if a custom {@linkcode template} is provided with + * {@linkcode skipWrapper} enabled. + * */ + @property() + dialogRole?: "dialog" | "alert"; + + /** + * @optional + * Ref(s) of the HTML element(s) which should open this dialog when clicked. + */ + @property({ attribute: false }) + trigger: ArrayOrSingle | HTMLElement> = []; + + @inject(OVERLAY_PROVIDER) + _overlay!: OverlayProvider; + + #overlayHandle?: WeakRef; + + open(): void { + if (!this.tag && !this.template) + return; + + const containerStyles = { + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#1A1A2766", + }; + const onBackdropClick = this.onBackdropClick; + + if (this.template) { + const [host, template] = this.template; + + this.#overlayHandle = this._overlay.attach( + host, + this.skipWrapper ? template : html` + this.close()} + > + ${template} + + `, + { + containerStyles, + onBackdropClick, + }, + ); + } else { + this.#overlayHandle = this._overlay.attach(this, html` + <${unsafeStatic(this.tag!)} + role=${ifDefined(this.dialogRole)} + @close=${() => this.close()} + > + `, { + containerStyles, + onBackdropClick, + }); + } + } + + close(): void { + if (this.#overlayHandle) + this._overlay.detach(this.#overlayHandle); + } + + protected override updated(changes: PropertyValues): void { + if (changes.has("tag") && changes.get("tag") != null) + throw new Error(`DialogElement's "tag" attribute should not be changed after rendering!`); + + if (changes.has("trigger")) { + const prevTrigger = changes.get("trigger"); + + if (prevTrigger) + for (let trigger of asArray(prevTrigger).map(deref).filter(exists)) + trigger.removeEventListener("click", this.onTriggerClick); + + for (let trigger of asArray(this.trigger).map(deref).filter(exists)) + trigger.addEventListener("click", this.onTriggerClick); + } + + super.updated(changes); + } + + override disconnectedCallback(): void { + for (let trigger of asArray(this.trigger).map(deref).filter(exists)) + trigger.removeEventListener("click", this.onTriggerClick); + + super.disconnectedCallback(); + } + + onTriggerClick = (event: Event): void => { + event.stopPropagation(); + this.open(); + } + + onBackdropClick = (): void => { + if (!this.ignoreBackdropClick) + this.close(); + } +} diff --git a/studio-ui/src/lib/dialog/index.ts b/studio-ui/src/lib/dialog/index.ts new file mode 100644 index 0000000..8cb5302 --- /dev/null +++ b/studio-ui/src/lib/dialog/index.ts @@ -0,0 +1,7 @@ +import "./dialog-base.element"; +import "./dialog-header.element"; +import "./dialog.element"; + +export * from "./dialog.element"; +export * from "./dialog-base.element"; +export * from "./dialog-header.element"; diff --git a/studio-ui/src/lib/forms/index.ts b/studio-ui/src/lib/forms/index.ts index 852be6d..ae82dc4 100644 --- a/studio-ui/src/lib/forms/index.ts +++ b/studio-ui/src/lib/forms/index.ts @@ -1 +1,2 @@ export * from "./text-field.element"; +export * from "./scalar-field.element"; diff --git a/studio-ui/src/lib/forms/scalar-field.element.scss b/studio-ui/src/lib/forms/scalar-field.element.scss new file mode 100644 index 0000000..6d20b1e --- /dev/null +++ b/studio-ui/src/lib/forms/scalar-field.element.scss @@ -0,0 +1,48 @@ +@use "sass:math"; + +:host { + box-sizing: border-box; + display: block; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + + background: #0004; + border: 1px solid #FFF2; + color: #FFFC; + outline: none !important; + transition: { + property: background-color, border-color, color; + duration: 33.333ms; + timing-function: linear; + } + + font: { + family: "Fira Sans", sans-serif; + size: math.div(15, 16) * 1rem; + } +} + +:host(:focus-within) { + border-color: #FFF4; + background: #0006; + color: #FFFF; +} + +:host(:not(.editing)) { + cursor: ew-resize; +} + +:host(:disabled) { + pointer-events: none !important; +} + +.input { + width: 100%; + height: 100%; + border: none; + outline: none !important; + + &:first-line { + display: inline-block; + } +} diff --git a/studio-ui/src/lib/forms/scalar-field.element.ts b/studio-ui/src/lib/forms/scalar-field.element.ts new file mode 100644 index 0000000..8a9e881 --- /dev/null +++ b/studio-ui/src/lib/forms/scalar-field.element.ts @@ -0,0 +1,353 @@ +import { on } from "@storyteller/framework"; +import { PropertyValues, html, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; + +import { FormFieldBaseElement } from "./form-field-base.element"; + +import styles from "./scalar-field.element.scss?inline"; + +const VALID_INPUT_PATTERN = /[-+*\/.0-9 ]/; + +// NOTE: This code was adapted from here: +// https://stackblitz.com/edit/angular-wfikuy?file=src%2Ffloat-input%2Ffloat-input.component.ts +@customElement("sts-scalar-field") +export class ScalarFieldElement extends FormFieldBaseElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "slider"; + + @property({ + type: Number, + attribute: "aria-valuenow", + reflect: true, + }) + value = 0; + + @property({ + type: Number, + attribute: "aria-valuemax", + reflect: true, + }) + max = Infinity; + + @property({ + type: Number, + attribute: "aria-valuemin", + reflect: true, + }) + min = -Infinity; + + @property({ type: Number }) + step = 0.01; + + @property({ type: Number }) + microStep = 0.001; + + /** + * A format string, supporting similar syntax to Rust's numeric + * {@linkcode https://doc.rust-lang.org/std/fmt/ std::fmt} specifiers: + * + * ``` + * [minIntegerDigits]['.' minFractionalDigits ['-' maxFractionalDigits]][' ' unit] + * ``` + * where: + * + * - `minIntegerDigits` (default: `1`) : + * + * The minimum number of integer digits to use. A value with a smaller + * number of integer digits than this number will be left-padded with zeros + * (to the specified length) when formatted. Possible values are from `1` + * to `21`. + * + * - `minFractioinalDigits` (default: `1`) : + * + * The minimum number of fraction digits to use. Possible values are from + * `0` to `20`. + * + * - `maxFractionalDigits` (default: `3`) : + * + * The maximum number of fraction digits to use. Possible values are from + * `0` to `20`. If not specified, will default to the larger of + * `minFractionalDigits` and `3`. + * + * - `unit` (optional) : + * + * Arbitrary text to display after the formatted value, representing the + * unit of measurement. + * + * @example + * ```html + * + * + * + * + * + * ``` + */ + @property() + format = "1.1-3"; + + /** + * Specify which DOM event ("input" or "change") should trigger this + * component to emit a "value-change" event. + * + * - "input" events fire every time the user types into the text field + * - "change" events fire only when the updated value is "committed" (by + * pressing "Enter" or moving focus out of the text field) + * + * @default "input" + */ + @property() + emitOn: "change" | "input" = "input"; + + @property({ + type: Number, + attribute: "tabindex", + reflect: true, + }) + override tabIndex = 0; + + @state() _editing = false; + + #inputRef = createRef(); + #abortControllers = { + "pointermove": null as AbortController | null, + "pointerup": null as AbortController | null, + }; + + #fmtDecimal = new Intl.NumberFormat("en-US", { + style: "decimal", + minimumIntegerDigits: 1, + minimumFractionDigits: 1, + maximumFractionDigits: 3, + }); + #fmtUnitSuffix = ""; + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has("format")) { + // Optional fractional digits: https://regexr.com/7ttu5 + const FORMAT_PATTERN_OPT_FRAC = /^([0-9]+)(?:\.([0-9]+)(?:-([0-9]+))?)?( .+)?$/; + // Optional integral digits: https://regexr.com/7ttu8 + const FORMAT_PATTERN_OPT_INT = /^([0-9]+)?(?:\.([0-9]+)(?:-([0-9]+))?)( .+)?$/; + + const matches = this.format.match(FORMAT_PATTERN_OPT_FRAC) + ?? this.format.match(FORMAT_PATTERN_OPT_INT) + // Note: If any _individual_ subexpression is null, we pass that + // intention through to the formatter. If the _entire format-string_ + // is omitted (i.e. the property is not set), then we fall back to + // "1.1-3" as a sensible default. + ?? ["", "1", "1", "3"]; + const [ minInt, minFrac, maxFrac, unitSuffix] = matches.slice(1); + + this.#fmtDecimal = new Intl.NumberFormat("en-US", { + minimumIntegerDigits: minInt != null ? parseInt(minInt) : undefined, + minimumFractionDigits: minFrac != null ? parseInt(minFrac) : undefined, + maximumFractionDigits: maxFrac != null ? parseInt(maxFrac) : undefined, + }); + this.#fmtUnitSuffix = unitSuffix ?? ""; + } + + super.willUpdate(changes); + } + + protected override update(changes: PropertyValues): void { + if (changes.has("_editing")) + this.classList.toggle("editing", this._editing); + + if (changes.has("value")) { + this.ariaValueText = `${this.#fmtDecimal.format(this.value)}${this.#fmtUnitSuffix}`; + + if (this._editing && this.emitOn === "change") { + // If we receive a new value from outside (i.e. not due to the + // user's own input), replace the input's content. + // + // Note: This is a bit of a hack to ensure a decent UX when tabbing + // between fields in the `` -- please don't + // change this without validating that use case still functions in + // a nice way. + this.#activateDirectInput(); + } + } + + super.update(changes); + } + + override disconnectedCallback(): void { + this.#abortControllers.pointermove?.abort(); + this.#abortControllers.pointerup?.abort(); + super.disconnectedCallback(); + } + + @on("pointerdown") + onPointerDown(event: PointerEvent): void { + if (this._editing || event.button !== 0) + return; + + event.preventDefault(); + + this.requestPointerLock(); + + this.#abortControllers.pointermove?.abort(); + this.#abortControllers.pointermove = new AbortController(); + + this.#abortControllers.pointerup?.abort(); + this.#abortControllers.pointerup = new AbortController(); + + const pointerMoveAbortSignal = this.#abortControllers.pointermove.signal; + + let totalMovement = 0; + + this.addEventListener("pointermove", event => { + totalMovement += event.movementX; + + const step = event.ctrlKey ? this.microStep : this.step; + this.#emitValue(this.value + event.movementX * step); + }, { + signal: pointerMoveAbortSignal, + }); + + pointerMoveAbortSignal.addEventListener("abort", () => { + document.exitPointerLock(); + // If no actual dragging occurred between pointerdown and pointerup, + // treat this like a click + if (totalMovement === 0) + this.#activateDirectInput(); + }); + + this.addEventListener("pointerup", () => { + this.#abortControllers.pointermove?.abort(); + }, { + once: true, + signal: this.#abortControllers.pointerup.signal, + }); + } + + @on("focus") + onFocus(): void { + this.#activateDirectInput(); + } + + onInputEnter(event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); + + const input = this.#inputRef.value; + if (!input) return; + + // Arbitrary HTML elements with `contenteditable` don't actually emit + // a "change" event, so we'll emit one manually. + input.dispatchEvent(new Event("change", { + bubbles: true, + cancelable: true, + composed: false, + })); + } + + onInputChange(event: InputEvent | Event): void { + if (event.type === this.emitOn) + this.processTextInput(event); + } + + processTextInput(event: InputEvent | Event): void { + try { + const input = this.#inputRef.value!; + const textContent = input.textContent?.trim() ?? ""; + + if ( + event instanceof InputEvent + && event.data + && !VALID_INPUT_PATTERN.test(event.data) + ) { + input.textContent = this.#fmtDecimal.format(this.value); + } else { + const value = parseFloat(textContent); + + if (!Number.isNaN(value) && Number.isFinite(value)) { + this.#emitValue(value); + } else { + input.textContent = this.#fmtDecimal.format(this.value); + } + } + } catch {} + } + + async #activateDirectInput(): Promise { + this._editing = true; + this.tabIndex = -1; + + this.requestUpdate(); + await this.updateComplete; + + const input = this.#inputRef.value; + if (!input) + throw new Error("Expected input to be available in editing mode"); + + input.addEventListener("blur", () => { + // Arbitrary HTML elements with `contenteditable` don't actually emit + // a "change" event, so we'll emit one manually. + input.dispatchEvent(new Event("change", { + bubbles: true, + cancelable: true, + composed: false, + })); + + this._editing = false; + this.tabIndex = 0; + }, { + once: true, + }); + + input.focus(); + // Note: Unit suffix intentionally omitted when text field is editable + input.textContent = this.#fmtDecimal.format(this.value); + + const range = document.createRange(); + range.selectNodeContents(input); + + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + } + + #emitValue(value: number): void { + if (this.max != null) + value = Math.min(value, this.max); + + if (this.min != null) + value = Math.max(value, this.min); + + this.dispatchEvent(new CustomEvent("value-change", { + detail: value, + })); + } + + protected override render = () => html` + ${!this._editing ? html` + ${this.#fmtDecimal.format(this.value)}${this.#fmtUnitSuffix} + ` : html` + { + if (event.key === "Enter") + this.onInputEnter(event); + }} + > + `} + `; +} diff --git a/studio-ui/src/lib/forms/text-field.element.scss b/studio-ui/src/lib/forms/text-field.element.scss index 27f954b..cf29771 100644 --- a/studio-ui/src/lib/forms/text-field.element.scss +++ b/studio-ui/src/lib/forms/text-field.element.scss @@ -2,10 +2,12 @@ :host { box-sizing: border-box; - display: block; + display: flex; + align-items: center; width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.25rem; + gap: 0.75rem; background: #0004; border: 1px solid #FFF2; @@ -22,6 +24,14 @@ } } +*, *::before, *::after { + box-sizing: border-box; +} + +[part^="icon-"] { + color: #FFF6; +} + :host(:focus) { outline: none; border-color: #FFF4; @@ -30,11 +40,16 @@ } .input { - width: 100%; + flex: 1 0 0; appearance: none; background: transparent; border: none; font: inherit; color: inherit; outline: none !important; + + &::placeholder { + font: inherit; + color: #FFF6; + } } diff --git a/studio-ui/src/lib/forms/text-field.element.ts b/studio-ui/src/lib/forms/text-field.element.ts index 95b4fe9..e241c32 100644 --- a/studio-ui/src/lib/forms/text-field.element.ts +++ b/studio-ui/src/lib/forms/text-field.element.ts @@ -1,6 +1,7 @@ import { on } from "@storyteller/framework"; -import { html, unsafeCSS } from "lit"; +import { html, nothing, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { FormFieldBaseElement } from "./form-field-base.element"; @@ -14,8 +15,18 @@ export class TextFieldElement extends FormFieldBaseElement { delegatesFocus: true, } + @property({ + attribute: "type", + reflect: true, + }) + fieldType = "text"; + + @property() placeholder?: string; @property() value = ""; + @property({ attribute: "icon-pre" }) iconPre?: string; + @property({ attribute: "icon-post" }) iconPost?: string; + @on("keydown") @on("keyup") blockKeypressBubbling(event: KeyboardEvent): void { @@ -24,18 +35,25 @@ export class TextFieldElement extends FormFieldBaseElement { } #onInput(event: InputEvent): void { - const updated = (event.target as HTMLInputElement).value; - console.log("updated:", updated); - - this.dispatchEvent(new CustomEvent("value-change", { detail: updated })); + this.dispatchEvent(new CustomEvent("value-change", { + detail: (event.target as HTMLInputElement).value, + })); } protected override render = () => html` + ${this.iconPre ? html` + + ` : nothing} + ${this.iconPost ? html` + + ` : nothing} `; } diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 7e6b556..72307a2 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -12,6 +12,7 @@ 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 fasPause from "@fortawesome/fontawesome-pro/svgs/solid/pause.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"; @@ -31,6 +32,22 @@ import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark import fasMinus from "@fortawesome/fontawesome-pro/svgs/solid/minus.svg?raw"; import fasListTree from "@fortawesome/fontawesome-pro/svgs/solid/list-tree.svg?raw"; import fasBars from "@fortawesome/fontawesome-pro/svgs/solid/bars.svg?raw"; +import fasPlusLarge from "@fortawesome/fontawesome-pro/svgs/solid/plus-large.svg?raw"; +import farFileImport from "@fortawesome/fontawesome-pro/svgs/regular/file-import.svg?raw"; +import farPhotoFilmMusic from "@fortawesome/fontawesome-pro/svgs/regular/photo-film-music.svg?raw"; +import fasChevronDown from "@fortawesome/fontawesome-pro/svgs/solid/chevron-down.svg?raw"; +import farVolume from "@fortawesome/fontawesome-pro/svgs/regular/volume.svg?raw"; +import farFilm from "@fortawesome/fontawesome-pro/svgs/regular/film.svg?raw"; +import fasPersonRunning from "@fortawesome/fontawesome-pro/svgs/solid/person-running.svg?raw"; +import farMountainSun from "@fortawesome/fontawesome-pro/svgs/regular/mountain-sun.svg?raw"; +import farArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/arrow-right-to-bracket.svg?raw"; +import farMagnifyingGlass from "@fortawesome/fontawesome-pro/svgs/regular/magnifying-glass.svg?raw"; +import fasHouse from "@fortawesome/fontawesome-pro/svgs/solid/house.svg?raw"; +import fasRecord from "@fortawesome/fontawesome-pro/svgs/solid/circle.svg?raw"; + +import customCylinder from "../assets/icons/cylinder.svg?raw"; +import customSphere from "../assets/icons/sphere.svg?raw"; +import customTorus from "../assets/icons/torus.svg?raw"; import styles from "./icon.element.scss?inline"; @@ -61,35 +78,51 @@ function renderSvg(iconId: string) { "bone": () => farBone, "bwd-fast": () => fasBwdFast, "bwd-step": () => fasBwdStep, + "caret-right": () => fasCaretRight, "check": () => fasCheck, + "chevron-down": () => fasChevronDown, "circle-check": () => fasCircleCheck, "circle-question": () => fasCircleQuestion, "circle-xmark": () => fasCircleXmark, - "caret-right": () => fasCaretRight, "crosshairs": () => farCrosshairs, "cube": () => farCube, + "cylinder": () => customCylinder, + "figure": () => fasPersonRunning, + "file-import": () => farFileImport, + "film": () => farFilm, "floppy-disk": () => fasFloppyDisk, "fwd-fast": () => fasFwdFast, "fwd-step": () => fasFwdStep, + "home": () => fasHouse, + "import": () => farArrowRightToBracket, "key": () => fasKey, "light": () => fasBrightness, "list-tree": () => fasListTree, "minus": () => fasMinus, "globe": () => farGlobe, + "photo-film-music": () => farPhotoFilmMusic, "play": () => fasPlay, + "pause": () => fasPause, + "plus-large": () => fasPlusLarge, "question": () => fasQuestion, "rotate": () => farRotate, "scale": () => farScale, + "scene": () => farMountainSun, + "search": () => farMagnifyingGlass, "skeleton": () => farSkeleton, + "sphere": () => customSphere, + "torus": () => customTorus, "up-down-left-right": () => farUpDownLeftRight, "video": () => farVideo, + "volume": () => farVolume, "xmark": () => fasXmark, + "record": () => fasRecord, _: () => null, }); if (!svgSrc) return nothing; - const [, attrsString, body] = svgSrc.match(/(.+?)<\/svg>/)!; + const [, attrsString, body] = svgSrc.match(/(.+?)<\/svg>/s)!; const attrs = Object.fromEntries( Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g)) .map(([, attr, value]) => [attr, value]) diff --git a/studio-ui/src/lib/menu.element.scss b/studio-ui/src/lib/menu.element.scss new file mode 100644 index 0000000..f1cb459 --- /dev/null +++ b/studio-ui/src/lib/menu.element.scss @@ -0,0 +1,35 @@ +:host { + display: flex; + flex-direction: column; + border-radius: 0.5rem; + padding: 0.5rem 0; + min-width: 220px; + + background: #0006; + backdrop-filter: blur(16px); + overflow: hidden; +} + +::slotted(sts-button) { + background: unset; + backdrop-filter: unset; + border-radius: unset; +} + +::slotted(sts-button:hover) { + background: #0004; + + // FIXME: https://github.com/w3c/csswg-drafts/issues/3896 + &::part(icon) { + opacity: 0.75; + } +} + +::slotted(hr) { + display: block; + width: 100%; + border: none; + height: 1px; + margin: 0.5rem 0; + background: #FFF1; +} diff --git a/studio-ui/src/lib/menu.element.ts b/studio-ui/src/lib/menu.element.ts new file mode 100644 index 0000000..6c51893 --- /dev/null +++ b/studio-ui/src/lib/menu.element.ts @@ -0,0 +1,213 @@ +import { inject, on } from "@storyteller/framework"; +import { LitElement, PropertyValues, css, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { createRef, ref, type Ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { v4 as uuid } from "uuid"; + +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "./overlay-provider.element"; + +import panelStyles from "./menu.element.scss?inline"; + +/** + * A simple, WIP [menu widget](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) + * implementation. + * + * @note + * This should _not_ be used for a ` + + + + + + + + + +
+ `; + } +} + +const HEX_PATTERN = /#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?/i; +const HEX_BASE = 16; +const U8_MAX = 255; + +function channelToHex(gamma: number) { + return (channel: number): string => { + return Math + .round(Math.pow(channel, gamma) * U8_MAX) + .toString(HEX_BASE) + .padStart(2, "0"); + } +} + +function channelFromHex(gamma: number) { + return (channel: string): number => { + return Math.pow(parseInt(channel, HEX_BASE) / U8_MAX, gamma); + } +} + +namespace RgbaLinear { + export function toHex({ red, green, blue, alpha }: Rgba): [string, number] { + const hexRgb = [red, green, blue] + .map(channelToHex(1 / 2.2)) + .join(""); + + return [`#${hexRgb}`, alpha]; + } + + export function fromHex(hex: string, alpha = 1): Rgba { + const [red, green, blue] = hex + .match(HEX_PATTERN)! + .slice(1) + .map(channelFromHex(2.2)); + + return { red, green, blue, alpha }; + } +} + +namespace Srgba { + export function toHex({ red, green, blue, alpha }: Rgba): [string, number] { + const hexRgb = [red, green, blue] + .map(channelToHex(2.2)) + .join(""); + + return [`#${hexRgb}`, alpha]; + } + + export function fromHex(hex: string, alpha = 1): Rgba { + const [red, green, blue] = hex + .match(HEX_PATTERN)! + .slice(1) + .map(channelFromHex(1 / 2.2)); + + return { red, green, blue, alpha }; + } +} diff --git a/studio-web/src/lib/inspector/name-inspector.element.ts b/studio-web/src/lib/inspector/name-inspector.element.ts index aff85f5..65a0613 100644 --- a/studio-web/src/lib/inspector/name-inspector.element.ts +++ b/studio-web/src/lib/inspector/name-inspector.element.ts @@ -1,3 +1,4 @@ +import * as studio from "@storyteller/studio"; import { bind } from "@storyteller/framework"; import { LitElement, PropertyValues, type TemplateResult, html, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -13,8 +14,20 @@ export class NameInspectorElement extends LitElement { @property() value = ""; protected override updated(changes: PropertyValues): void { - if (changes.has("value")) - console.log("New value:", this.value); + // FIXME: `Name` component needs special handling due to: + // 1. Its `hash` field, which doesn't get updated by just directly + // mutating the `name` field + // 2. Its use in our hierarchy. We'll need to add some logic to check + // for changed names on existing entities and notify the frontend so + // the hierarchy can reflect the new value. + + // if (changes.has("value")) { + // studio.updateComponentField({ + // component: "bevy_core::name::Name", + // reflectPath: "name", + // value: this.value, + // }); + // } super.updated(changes); } diff --git a/studio-web/src/lib/inspector/quat-inspector.element.scss b/studio-web/src/lib/inspector/quat-inspector.element.scss new file mode 100644 index 0000000..0d440a2 --- /dev/null +++ b/studio-web/src/lib/inspector/quat-inspector.element.scss @@ -0,0 +1,31 @@ +@use "sass:math"; + +:host { + display: flex; + gap: 0.25rem; +} + +.input { + padding: 0.5rem; + width: math.div(1, 3) * 100%; + border-left-width: 4px; + + &:nth-of-type(1) { + border-left-color: rgb(220, 53, 69); + &:focus { + border-left-color: rgb(255, 48, 68); + } + } + &:nth-of-type(2) { + border-left-color: rgb(25, 135, 84); + &:focus { + border-left-color: rgb(0, 187, 100); + } + } + &:nth-of-type(3) { + border-left-color: rgb(13, 110, 253); + &:focus { + border-left-color: rgb(47, 131, 255); + } + } +} diff --git a/studio-web/src/lib/inspector/quat-inspector.element.ts b/studio-web/src/lib/inspector/quat-inspector.element.ts new file mode 100644 index 0000000..9d52614 --- /dev/null +++ b/studio-web/src/lib/inspector/quat-inspector.element.ts @@ -0,0 +1,126 @@ +import * as studio from "@storyteller/studio"; +import { RotationAxis } from "@storyteller/studio"; +import { match } from "@storyteller/utility"; +import { LitElement, PropertyValues, TemplateResult, html, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import { type Quat, type Vec3 } from "./inspector.types"; + +import styles from "./quat-inspector.element.scss?inline"; + +@customElement("sts-quat-inspector") +export class QuatInspectorElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ attribute: false }) + value: Quat = { w: 1, x: 0, y: 0, z: 0 }; + + @state() _eulerX = 0; + @state() _eulerY = 0; + @state() _eulerZ = 0; + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has("value")) { + const { x, y, z } = quatToEuler(this.value); + this._eulerX = rad2deg(x); + this._eulerY = rad2deg(y); + this._eulerZ = rad2deg(z); + } + + super.willUpdate(changes); + } + + #emitUpdate(axis: RotationAxis) { + return ({ detail: newAngle }: CustomEvent) => { + const prevAngle = match (axis, { + [RotationAxis.X]: () => this._eulerX, + [RotationAxis.Y]: () => this._eulerY, + [RotationAxis.Z]: () => this._eulerZ, + }); + + const deltaTheta = deg2rad(newAngle - prevAngle); + + if (Number.isFinite(deltaTheta) && !Number.isNaN(deltaTheta)) + studio.rotateLocalTransform(axis, deltaTheta); + } + } + + protected override render = (): TemplateResult => html` + + + + `; +} + +// Reference: https://gamemath.com/book/orient.html#quaternion_to_euler_angles +// TODO: The fields here "work" on a basic level, but I'm not convinced they're +// actually displaying intelligible values. For example, if you tilt the +// X-axis by any amount and rotate the Y-axis, you'll typically see all +// three values spinning wildly. I hate Euler angles. +function quatToEuler({ w, x, y, z }: Quat): Vec3 { + let sinPitch = -2 * (y*z - w*x); + // Clamp in the range of [-1,1] to avoid NaNs + sinPitch = Math.min(Math.max(sinPitch, -1), 1); + + // Check for gimbal lock + if (nearlyEq(sinPitch, 1, 1e-3)) { + const yaw = Math.atan2(-x*z + w*y, 0.5 - y*y - z*z); + const pitch = Math.PI * 0.5 * sinPitch; + const roll = 0; + + return { + x: pitch, + y: yaw, + z: roll, + } + } + + const yaw = Math.atan2(x*z + w*y, 0.5 - x*x - y*y); + const pitch = Math.asin(sinPitch); + const roll = Math.atan2(x*y + w*z, 0.5 - x*x - z*z); + + return { + x: pitch, + y: yaw, + z: roll, + } +} + +function nearlyEq(lhs: number, rhs: number, tolerance = Number.EPSILON): boolean { + return Math.abs(lhs - rhs) < tolerance; +} + +function rad2deg(rad: number): number { + return rad * (180 / Math.PI); +} + +function deg2rad(deg: number): number { + return (deg * Math.PI) / 180; +} diff --git a/studio-web/src/lib/inspector/transform-inspector.element.scss b/studio-web/src/lib/inspector/transform-inspector.element.scss index c9afa4b..451e37c 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.scss +++ b/studio-web/src/lib/inspector/transform-inspector.element.scss @@ -17,48 +17,29 @@ h4 { margin: 0 0 0.25rem; } -fieldset { +.fieldset { display: flex; + position: relative; width: 100%; - padding: 0; + padding: 0 { + block-start: math.div(14, 16) + 0.333rem !important; + } margin: 0; gap: 0.25rem; border: none; legend { + position: absolute; + inset-block-start: 0; + margin-block-end: 0.333rem; color: #FFF9; font-size: math.div(14, 16) * 1rem; - margin-block-end: 0.333rem; } - input { - appearance: textfield; - width: math.div(1, 3) * 100%; + .input { padding: 0.5rem; - border-radius: 0.25rem; - - background: #0004; - border: 1px solid #FFF2 { - left: 4px solid #FFF2; - } - color: #FFFC; - transition: { - property: background-color, border-color, color; - duration: 33.333ms; - timing-function: linear; - } - - font: { - family: "Fira Sans", sans-serif; - size: math.div(15, 16) * 1rem; - } - - &:focus { - outline: none; - border-color: #FFF4; - background: #0006; - color: #FFFF; - } + width: math.div(1, 3) * 100%; + border-left-width: 4px; &:nth-of-type(1) { border-left-color: rgb(220, 53, 69); @@ -78,11 +59,9 @@ fieldset { border-left-color: rgb(47, 131, 255); } } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - margin: 0; - } } } + +sts-quat-inspector { + width: 100%; +} diff --git a/studio-web/src/lib/inspector/transform-inspector.element.ts b/studio-web/src/lib/inspector/transform-inspector.element.ts index 006c441..aaecb83 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.ts +++ b/studio-web/src/lib/inspector/transform-inspector.element.ts @@ -1,7 +1,13 @@ +import * as studio from "@storyteller/studio" import { LitElement, html, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { v4 as uuid } from "uuid"; + import { type Quat, type Vec3 } from "./inspector.types"; +import "@storyteller/studio-ui/forms/scalar-field"; +import "./quat-inspector.element"; + import styles from "./transform-inspector.element.scss?inline"; @customElement("sts-transform-inspector") @@ -17,27 +23,101 @@ export class TransformInspectorElement extends LitElement { @property({ attribute: false }) scale: Vec3 = { x: 1, y: 1, z: 1 }; + #positionLabelId = uuid(); + #rotationLabelId = uuid(); + #scaleLabelId = uuid(); + + updateField(reflectPath: `${"translation"|"scale"}.${"x"|"y"|"z"}` | "rotation") { + return ({ detail: value }: CustomEvent) => { + console.log("value:", value); + studio.updateComponentField({ + component: "bevy_transform::components::transform::Transform", + reflectPath, + value, + }); + } + } + protected override render = () => html` -

Transform

-
- Translation - - - -
-
- Rotation - - - - -
-
- Scale - - - -
+
+ + Position + + + + +
+
+ + Rotation + + +
+
+ + Scale + + + + +
`; } diff --git a/studio-web/src/lib/media-library-dialog.element.scss b/studio-web/src/lib/media-library-dialog.element.scss new file mode 100644 index 0000000..ce711b8 --- /dev/null +++ b/studio-web/src/lib/media-library-dialog.element.scss @@ -0,0 +1,196 @@ +@use "sass:math"; + +:host { + width: 1024px; + height: 640px; + max-width: 80%; + max-height: 80%; +} + +.main { + max-height: calc(80vh - 3rem); +} + +.sidebar { + flex: 0 0 256px; + + &__list { + list-style: none; + list-style-type: none; + margin: 0; + padding: 0; + } + + &__listitem { + margin: 0; + padding: 0; + + sts-button { + width: 100%; + background: transparent; + backdrop-filter: none; + + &:not(.disabled):hover { + background: #0003; + } + + &.active, &.active:hover { + background: #FFF2; + } + } + } +} + +.content { + width: calc(100% - 256px); + position: relative; + + &__ui { + display: flex; + gap: 0.5rem; + + .search-field { + border: none; + border-radius: 0.5rem; + + &::part(icon-pre) { + font-size: math.div(18, 16) * 1rem; + } + } + } + + &__results { + flex: 1 0 0; + padding-block-end: 3rem; + + &--centered { + display: flex; + flex: 1 0 0; + align-items: center; + justify-content: center; + } + + &--grid { + display: grid; + grid-template: { + columns: 1fr 1fr 1fr; + rows: min-content; + } + gap: 1rem; + overflow-y: auto; + } + + &::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; + + &-thumb { + border-radius: 2px; + background: #FFF2; + + &:hover { + background: #FFF3; + } + } + + &-corner { + background: transparent; + } + } + } + + &__media-file { + display: flex; + flex-direction: column; + justify-content: flex-end; + height: math.div(174, 16) * 1rem; + padding: 1rem; + border-radius: 0.5rem; + overflow: hidden; + background: #FFF1; + + &.selected { + border: 2px solid #E66462; + padding: calc(1rem - 2px); + } + + .last-updated, + .name { + line-height: 1.5; + user-select: none; + } + + .last-updated { + font: { + size: 0.75rem; + weight: 400; + } + color: #FFFC; + } + + .name { + margin: 0; + max-width: 100%; + overflow: hidden; + color: #FFFF; + font: { + size: 1rem; + weight: 500; + } + white-space: nowrap; + text-overflow: ellipsis; + } + } + + &__add-to-scene { + position: absolute; + bottom: 1rem; + right: 1rem; + } +} + +.error, .placeholder { + display: flex; + flex-direction: column; + align-items: center; + + .heading, p { + text-align: center; + } + + .heading { + margin: 0; + } + + p { + margin: 1em 0; + font-size: 1.25rem; + line-height: 1.5; + } +} + +.btn-primary { + backdrop-filter: none; + + &:not(.disabled) { + background: #E66462; + color: #FFFF; + + &:hover { + background: #EA7A78; + color: #FFFF; + } + } +} + +.btn-secondary { + backdrop-filter: none; + + &:not(:disabled) { + background: #44445B; + + &:hover { + background: #39394C; + } + } +} diff --git a/studio-web/src/lib/media-library-dialog.element.ts b/studio-web/src/lib/media-library-dialog.element.ts new file mode 100644 index 0000000..958fa35 --- /dev/null +++ b/studio-web/src/lib/media-library-dialog.element.ts @@ -0,0 +1,363 @@ +import { bind, inject } from "@storyteller/framework"; +import { ButtonElement } from "@storyteller/studio-ui/button"; +import { DialogBaseElement } from "@storyteller/studio-ui/dialog"; +import { + type MediaFile as IMediaFile, + MediaFileClass, + MediaFileSubtype, + MediaFileToken, + MediaFileType, + StorytellerApi, + Visibility, +} from "@storyteller/studio-web"; +import { match, type Pred } from "@storyteller/utility"; +import { + html, + nothing, + type PropertyValues, + TemplateResult, + unsafeCSS, + css, +} from "lit"; +import { customElement, queryAll, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import "@storyteller/studio-ui/button"; +import "@storyteller/studio-ui/icon"; +import "@storyteller/studio-ui/forms/text-field"; + +import styles from "./media-library-dialog.element.scss?inline"; + +@customElement("sts-media-library-dialog") +export class MediaLibraryDialogElement extends DialogBaseElement { + static override styles = css` + ${DialogBaseElement.styles} + ${unsafeCSS(styles)} + `; + + override heading = "Media Library"; + + @state() _mediaFiles?: MediaFile[]; + @state() _searchTerm = ""; + @state() _selected: MediaFileToken[] = []; + @state() _activeCategory: MediaFileCategory = MEDIA_FILE_CATEGORIES[0]; + @state() _filters: Pred[] = []; + @state() _apiError?: Error; + + @inject(StorytellerApi) + _api!: StorytellerApi; + + @queryAll("[data-import-trigger]") + importTriggers!: ButtonElement[]; + + #dateFmt = new Intl.DateTimeFormat("en-us"); + + get #mediaFiles() { + return this._mediaFiles?.filter(it => + this._filters.every(pred => pred(it)) + ); + } + + get #resultsStatus() { + if (this._apiError) + return ResultsStatus.Error; + if (!this.#mediaFiles) + return ResultsStatus.Loading; + if (!this.#mediaFiles.length) + return ResultsStatus.Empty; + + return ResultsStatus.NonEmpty; + } + + override connectedCallback(): void { + this.loadResults(); + super.connectedCallback(); + } + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has("_activeCategory") || changes.has("_searchTerm")) { + this._filters = [ + this._activeCategory.predicate, + it => it.displayName.includes(this._searchTerm), + ]; + } + + super.willUpdate(changes); + } + + async loadResults(): Promise { + this._mediaFiles = undefined; + this._apiError = undefined; + + try { + const session = await this._api.session(); + if (!session.logged_in || !session.user?.username) + throw new Error("Not logged in"); + + const response = await this._api.listMediaFiles({ + username: session.user.username, + filterMediaType: [ + MediaFileType.Bvh, + MediaFileType.Glb, + MediaFileType.Gltf, + MediaFileType.ScnRon, + ], + }); + + const results = await Promise.all( + response.results.map(({ token }) => this._api.mediaFile(token)) + ); + + this._mediaFiles = results.map(info => new MediaFile(info)); + } + catch (err) { + if (typeof err === "object" && err !== null && err instanceof Error) { + this._apiError = err; + } else { + this._apiError = new Error(String(err)); + } + } + } + + onSelect(token: MediaFileToken) { + return (event: PointerEvent) => { + event.stopPropagation(); + + if (event.getModifierState("Control") || event.getModifierState("Shift")) + this.toggleSelected(token); + else + this.select(token); + } + } + + select(token: MediaFileToken): void { + this._selected = [token]; + } + + toggleSelected(token: MediaFileToken): void { + if (this._selected.includes(token)) + this._selected = this._selected.filter(it => it !== token); + else + this._selected = this._selected.concat(token); + } + + clearSelection(): void { + this._selected = []; + } + + get importDialogTemplate() { + return html` + + Import Asset + + +

Lorem ipsum dolor sit amet

+ `; + } + + protected override render = (): TemplateResult => html` + + ${this.heading} + + +
+ +
+
+ + Import... + + +
+
+ ${match (this.#resultsStatus, { + [ResultsStatus.Loading]: () => html` + Loading... + `, + [ResultsStatus.Empty]: () => html` +
+

No results found

+

Import a new asset to get started!

+ + Import... + +
+ `, + [ResultsStatus.Error]: () => html` +
+

Error

+

${this._apiError!.message}

+ + Retry + +
+ `, + [ResultsStatus.NonEmpty]: () => this.#mediaFiles!.map(file => html` +
+ + ${this.#dateFmt.format(file.updated)} + +

+ ${file.displayName} +

+
+ `), + })} +
+ ${this._selected.length > 0 ? html` + + Add to Scene + + ` : nothing} +
+
+ + + `; +} + +interface MediaFileCategory { + name: string; + icon: string; + disabled?: boolean; + tooltip?: string; + predicate: Pred; +} + +const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [ + { + name: "Home", + icon: "home", + predicate: () => true, + }, + { + name: "Animation", + icon: "film", + predicate: it => it.mediaClass === MediaFileClass.Animation, + }, + { + name: "Audio", + icon: "volume", + disabled: true, + tooltip: "Coming soon!", + predicate: it => it.mediaClass === MediaFileClass.Audio, + }, + { + name: "Characters", + icon: "figure", + predicate: it => it.mediaClass === MediaFileClass.Character, + }, + { + name: "Props", + icon: "cube", + predicate: it => it.mediaClass === MediaFileClass.Prop, + }, + { + name: "Scenes", + icon: "scene", + predicate: it => it.mediaClass === MediaFileClass.Scene, + }, +]; + +enum ResultsStatus { + Loading, + Empty, + NonEmpty, + Error, +} + +class MediaFile { + token: MediaFileToken; + + type: MediaFileType; + subtype?: MediaFileSubtype; + mediaClass: MediaFileClass; + + title?: string; + filename?: string; + + visibility: Visibility; + bucketPath: string; + created: Date; + updated: Date; + + get displayName(): string { + return this.title ?? this.filename ?? this.token; + } + + constructor (info: IMediaFile) { + this.token = info.token; + + this.type = info.media_type; + this.subtype = info.maybe_media_subtype ?? undefined; + this.mediaClass = info.media_class; + + this.title = info.maybe_title ?? undefined; + this.filename = info.maybe_original_filename ?? undefined; + + this.visibility = info.creator_set_visibility; + this.bucketPath = info.public_bucket_path; + this.created = new Date(info.created_at); + this.updated = new Date(info.updated_at); + } +} diff --git a/studio-web/src/lib/scene-data-provider.element.ts b/studio-web/src/lib/scene-data-provider.element.ts index ac1e1d8..1b5e034 100644 --- a/studio-web/src/lib/scene-data-provider.element.ts +++ b/studio-web/src/lib/scene-data-provider.element.ts @@ -6,7 +6,7 @@ import { type EntityMultiSpawnEvent, type EntitySelectEvent, type EntitySpawnEvent, - type InspectorChanged, + type InspectorChangedEvent, } from "@storyteller/studio"; import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree"; import { match } from "@storyteller/utility"; @@ -109,7 +109,7 @@ export class SceneDataProviderElement } @on("window:inspector-changed") - onInspectorChanged({ detail: json }: InspectorChanged): void { + onInspectorChanged({ detail: json }: InspectorChangedEvent): void { this._components = json ? JSON.parse(json) : []; } diff --git a/studio-web/src/lib/storyteller-api.ts b/studio-web/src/lib/storyteller-api.ts new file mode 100644 index 0000000..c62ebbb --- /dev/null +++ b/studio-web/src/lib/storyteller-api.ts @@ -0,0 +1,436 @@ +import axios, { AxiosResponse } from "axios"; +import { v4 as uuid } from "uuid"; + +export class StorytellerApi { + #api = axios.create({ + baseURL: "https://api.fakeyou.com", + withCredentials: true, + }); + + async createScene(file: File): Promise { + const response = await this.#api.postForm( + "v1/engine/create_scene", + { + uuid_idempotency_token: uuid(), + file, + }, { + headers: { Accept: "text/plain" }, + }, + ); + this.#validateResponse(response); + + return response.data.media_file_token; + } + + async session(): Promise { + const response = await this.#api.get("v1/session"); + this.#validateResponse(response); + + return response.data; + } + + async listMediaFiles(options: ListMediaFilesOptions = {}): Promise { + const params = {} as any; + + if (options.filterMediaType) + params.filter_media_type = options.filterMediaType.join(","); + if (options.filterMediaClasses) + params.filter_media_classes = options.filterMediaClasses.join(","); + if (options.sortAscending != null) + params.soer_ascending = options.sortAscending; + if (options.pageSize != null) + params.page_size = options.pageSize; + if (options.pageIndex != null) + params.page_index = options.pageIndex; + + const endpoint = options.username + ? `v1/media_files/list/user/${options.username}` + : "v1/media_files/list"; + + const response = await this.#api.get(endpoint, { params }); + this.#validateResponse(response); + + return response.data; + } + + async uploadMediaFile( + file: File, + { subtype, mediaClass }: UploadMediaFileOptions, + ): Promise { + const response = await this.#api.postForm( + "v1/media_files/upload/engine_asset", + { + uuid_idempotency_token: uuid(), + file, + media_file_subtype: subtype, + media_file_class: mediaClass, + }, + { + headers: { Accept: file.type }, + }, + ); + this.#validateResponse(response); + + return response.data.media_file_token; + } + + async mediaFile(token: MediaFileToken): Promise { + const response = await this.#api.get(`v1/media_files/file/${token}`); + this.#validateResponse(response); + + return response.data.media_file; + } + + #validateResponse(response: R): asserts response is OkResponse { + if (response.status !== 200) + throw new Error(`API responded with status ${response.status}: "${response.statusText}"`); + + if ( + response.data + && "success" in response.data + && response.data.success === false + ) { + throw new ApiError(response); + } + } +} + +export class ApiError extends Error { + detail: T; + + constructor (detail: T) { + super("API Error"); + this.detail = detail; + } +} + +type OkResponse = T & { status: 200 } + +export interface ListMediaFilesOptions { + username?: string; + filterMediaType?: MediaFileType[]; + filterMediaClasses?: MediaFileClass[]; + pageSize?: number; + pageIndex?: number; + sortAscending?: boolean; +} + +export interface UploadMediaFileOptions { + subtype: MediaFileSubtype; + mediaClass: MediaFileClass; +} + +export interface ListMediaFilesResult { + pagination: PaginationPage; + results: MediaFileForUserListItem[]; + success: boolean; +} + +export interface MediaUploadResponse { + success: boolean; + media_file_token: MediaFileToken; +} + +/** + * Pagination by page id. + * + * This type of pagination is by "page id" and "page count". This should never + * be used to walk the entire public database as competitors and investors can + * use it to glean information about the scale of our service. This is best used + * for user profiles and scoped down results. + */ +export interface PaginationPage { + current: number; + total_page_count: number; +} + +export interface MediaFileResult { + success: boolean; + media_file: MediaFile; +} + +export interface MediaFile { + token: MediaFileToken; + media_type: MediaFileType; + maybe_media_subtype?: MediaFileSubtype; + media_class: MediaFileClass; + maybe_batch_token?: unknown; + public_bucket_path: string; + maybe_model_weight_info?: unknown; + maybe_creator_user?: UserDetailsLight; + creator_set_visibility: Visibility; + maybe_text_transcript?: string; + maybe_prompt_token?: unknown; + maybe_title?: string; + maybe_original_filename?: string; + is_emulated_media_file: boolean; + stats: Stats; + /** Date-time string */ + created_at: string; + /** Date-time string */ + updated_at: string; +} + +export interface MediaFileListItem { + /** Date-time string */ + created_at: string; + creator_set_visibility: Visibility; + maybe_creator?: UserDetailsLight; + /** @deprecated */ + maybe_origin_model_token?: string; + maybe_origin_model_type?: MediaFileOriginModelType; + /** Text transcripts for TTS, etc. */ + maybe_text_transcript?: string; + media_type: MediaFileType; + maybe_media_subtype?: MediaFileSubtype; + origin: MediaFileOriginDetails; + origin_category: MediaFileOriginCategory; + origin_product_category: MediaFileOriginProductCategory; + /** URL to the media file. */ + public_bucket_path: string; + /** The primary key for Media Files */ + token: MediaFileToken; + /** Date-time string */ + updated_at: string; +} + +export interface Stats { + positive_rating_count: number; + bookmark_count: number; +} + +export type MediaFileForUserListItem = Omit; + +export enum Visibility { + Public = "public", + Hidden = "hidden", + Private = "private", +} + +/** + * Used in the `media_files` table in a `VARCHAR` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileOriginModelType { + RvcV2 = "rvc_v2", + SadTalker = "sad_talker", + SoVitsSvc = "so_vits_svc", + Tacotron2 = "tacotron2", + VallEX = "vall_e_x", + Rerender = "rerender", + MocapNet = "mocap_net", + ComfyUi = "comfy_ui", + StyleTts2 = "styletts2", + StableDiffusion_1_5 = "stable_diffusion_1_5", +} + +/** + * Used in the `media_files` table in a `VARCHAR(16)` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileType { + Audio = "audio", + Image = "image", + Video = "video", + Mocap = "mocap", + Bvh = "bvh", + Fbx = "fbx", + Glb = "glb", + Gltf = "gltf", + ScnRon = "scene_ron", +} + +export enum MediaFileSubtype { + /** + * Animation file from Mixamo. + * Primarily used for FBX and GLB. + */ + Mixamo = "mixamo", + /** + * Animation file from MocapNet. + * Primarily used for BVH. + */ + MocapNet = "mocap_net", + /** + * Generic animation case. + * Used for BVH files, but can also pertain to animation-only files of other + * types. + */ + AnimationOnly = "animation_only", + /** + * @deprecated Use `SceneImport` instead. + */ + Scene = "scene", + /** + * Generic 3D scene file. + * Can pertain to glTF, glB, FBX, etc. + */ + SceneImport = "scene_import", + /** + * Native Storyteller scene format. + * Typically stored in a `.scn.ron` file. + */ + StorytellerScene = "storyteller_scene", +} + +/** + * Used in the `media_files` table in a `VARCHAR(16)` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileClass { + /** This will be present until we migrate all old files. */ + Unknown = "unknown", + /** Audio files: wav, mp3, etc. */ + Audio = "audio", + /** Image files: png, jpeg, etc. */ + Image = "image", + /** Video files: mp4, etc. */ + Video = "video", + /** Engine "animations" */ + Animation = "animation", + /** Engine "characters" */ + Character = "character", + /** Engine "prop" items */ + Prop = "prop", + /** Engine scenes (internal and external scenes) */ + Scene = "scene", +} + +/** Fields useful for enriching media file listings */ +export interface MediaFileOriginDetails { + maybe_model?: Model; + origin_category: MediaFileOriginCategory; + product_category: MediaFileOriginProductCategory; +} + +export interface Model { + model_type: MediaFileOriginModelType; + /** + * The model title (typically only populated for model_weights models, not + * legacy tables such as tts_models.) + */ + title?: string; + /** The primary key for the "model_weights" table. */ + token?: string; +} + +/** + * Used in the `media_files` table in a `VARCHAR` field. + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileOriginCategory { + Inference = "inference", + Processed = "processed", + Upload = "upload", + DeviceApi = "device_api", + StoryEngine = "story_engine", +} + +/** + * Used in the `media_files` table in `VARCHAR(16)` field + * `origin_product_category`. + * + * This value indicates what product originally created the media file. (Not the + * ML model or user upload process.) This will let us scope media files to the + * product that generated them and filter them out of unrelated products if + * necessary (eg. a user probably doesn't want "Voice Designer" dataset samples + * in a video generation flow.) + * + * DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. + */ +export enum MediaFileOriginProductCategory { + Unknown = "unknown", + FaceAnimator = "face_animator", + Tts = "tts", + VoiceConversion = "voice_conversion", + ZsVoice = "zs_voice", + VideoFilter = "video_filter", + Mocap = "mocap", + ImageGen = "image_gen", + Workflow = "workflow", +} + +export type MediaFileToken = string; + +export interface Session { + logged_in: boolean; + success: boolean; + user?: { + can_access_studio: boolean; + can_approve_w21_templates: boolean; + can_ban_users: boolean; + can_delete_other_users_tts_models: boolean; + can_delete_other_users_tts_results: boolean; + can_delete_other_users_w2l_results: boolean; + can_delete_other_users_w2l_templates: boolean; + can_delete_own_account: boolean; + can_delete_own_tts_models: boolean; + can_delete_own_tts_results: boolean; + can_delete_own_w2l_results: boolean; + can_delete_own_w2l_templates: boolean; + can_delete_users: boolean; + can_edit_other_users_profiles: boolean; + can_edit_other_users_tts_models: boolean; + can_edit_other_users_w2l_templates: boolean; + can_upload_tts_models: boolean; + can_upload_w2l_templates: boolean; + can_use_tts: boolean; + can_use_w2l: boolean; + core_info: UserDetailsLight; + display_name: string; + email_gravatar_hash: string; + fakeyou_plan: FakeYouPlan; + maybe_feature_flags: unknown; + storyteller_stream_plan: StorytellerStreamPlan; + user_token: string; + username: string; + }; +} + +/** + * Everything we need to refer to a user on the public web interface. + */ +export interface UserDetailsLight { + default_avatar: DefaultAvatarInfo; + /** + * As of 2023-08-23, this is the username with capitalization (In the future, + * a display name can be customized by the user.) + */ + 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. + */ + gravatar_hash: string; + /** The primary key for users. */ + user_token: string; + /** + * The unique username someone logs in with. As of 2023-08-23, this is always + * lowercase. + */ + username: string; +} + +export interface DefaultAvatarInfo { + color_index: number; + image_index: number; +} + +export enum FakeYouPlan { + Free = "free", + Basic = "basic", + Standard = "standard", + Pro = "pro", +} + +export enum StorytellerStreamPlan { + Free = "free", + Basic = "basic", + Standard = "standard", + Pro = "pro", +} diff --git a/studio-web/src/lib/studio.element.ts b/studio-web/src/lib/studio.element.ts index 3562a8d..2759275 100644 --- a/studio-web/src/lib/studio.element.ts +++ b/studio-web/src/lib/studio.element.ts @@ -28,7 +28,6 @@ export class StudioElement extends LitElement { #canvas?: HTMLCanvasElement; #resizeObserver?: ResizeObserver; - #frameRequest?: number; override connectedCallback(): void { super.connectedCallback(); @@ -41,14 +40,9 @@ export class StudioElement extends LitElement { 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)); @@ -63,6 +57,8 @@ export class StudioElement extends LitElement { } else { // TODO } + + super.updated(changes); } #onResize(): void { @@ -77,17 +73,6 @@ export class StudioElement extends LitElement { })); } - #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; diff --git a/studio/Cargo.toml b/studio/Cargo.toml index dea451a..d8c3aa6 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -47,11 +47,15 @@ futures-lite = "2.2.0" futures = "0.3.30" image = "0.25.0" bytemuck = "1.14.1" + space_editor = {git = "https://github.com/rewin123/space_editor", rev = "7b93e21ee1eb8a42418890b6cd525eaa7db43b95"} anyhow.workspace = true serde_repr = "0.1.18" wasm-bindgen-futures = "0.4" +async-compat = "0.2" +uuid = { version = "1", features = ["v4", "serde"]} + [dependencies.reqwest] version = "0.12.2" features = [ diff --git a/studio/assets/desync.glb b/studio/assets/desync.glb new file mode 100644 index 0000000..ab9af79 Binary files /dev/null and b/studio/assets/desync.glb differ diff --git a/studio/assets/desync.scn.ron b/studio/assets/desync.scn.ron new file mode 100644 index 0000000..5cc5410 --- /dev/null +++ b/studio/assets/desync.scn.ron @@ -0,0 +1,936 @@ +( + resources: {}, + entities: { + 9: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: -0.27984813, + y: 0.36470518, + z: 0.11591689, + w: 0.88047624, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 4042927597982576294, + name: "Directional Light", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "bevy_pbr::light::DirectionalLight": ( + color: Rgba( + red: 1.0, + green: 1.0, + blue: 1.0, + alpha: 1.0, + ), + illuminance: 20000.0, + shadows_enabled: false, + shadow_depth_bias: 0.02, + shadow_normal_bias: 1.8, + ), + "studio::anim::timeline_animation::AnimationTarget": Uuid("57217c56-d86b-441f-a0b6-a471dfe1d463"), + }, + ), + 10: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 23.715185, + z: 0.0000007320094, + ), + rotation: ( + x: 0.70710677, + y: 0.0, + z: 0.0, + w: 0.7071067, + ), + scale: ( + x: 0.01, + y: 0.010000001, + z: 0.010000001, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("2c3faa37-9320-4bc7-825f-75b745477901"), + time: 1.1854, + ), + }, + ), + 11: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 27.426285, + z: 0.00000051081065, + ), + rotation: ( + x: 0.7071068, + y: 0.0, + z: 0.0, + w: 0.70710677, + ), + scale: ( + x: 0.01, + y: 0.010000001, + z: 0.010000001, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("2c3faa37-9320-4bc7-825f-75b745477901"), + time: 3.0443585, + ), + }, + ), + 12: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -5.861992, + y: 16.39393, + z: -44.6979, + ), + rotation: ( + x: 0.70710677, + y: 0.0, + z: 0.0, + w: 0.7071067, + ), + scale: ( + x: 0.01, + y: 0.010000001, + z: 0.010000001, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("34f59412-5a7b-44e9-aaa3-3e74840121c9"), + time: 0.0, + ), + }, + ), + 13: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -1.6321859, + y: 16.393932, + z: -46.45551, + ), + rotation: ( + x: 0.70710677, + y: 0.0, + z: 0.0, + w: 0.7071067, + ), + scale: ( + x: 0.01, + y: 0.010000001, + z: 0.010000001, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("34f59412-5a7b-44e9-aaa3-3e74840121c9"), + time: 1.6179003, + ), + }, + ), + 14: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.90487814, + y: 19.255144, + z: -44.125862, + ), + rotation: ( + x: 0.7071068, + y: 0.0, + z: 0.0, + w: 0.70710677, + ), + scale: ( + x: 0.01, + y: 0.010000001, + z: 0.010000001, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("34f59412-5a7b-44e9-aaa3-3e74840121c9"), + time: 3.0603817, + ), + }, + ), + 15: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -9.611355, + y: 20.93744, + z: -28.69173, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("2b76e191-e079-417a-9369-2443f21f3ce5"), + time: 0.0, + ), + }, + ), + 16: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -9.611355, + y: 20.93744, + z: 19.342052, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("2b76e191-e079-417a-9369-2443f21f3ce5"), + time: 0.967, + ), + }, + ), + 17: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -9.611355, + y: 11.742281, + z: -28.69173, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("408b5d7e-9456-4d81-a08e-feebba2ce1c4"), + time: 0.0, + ), + }, + ), + 18: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -9.611355, + y: 11.742281, + z: 13.85043, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("408b5d7e-9456-4d81-a08e-feebba2ce1c4"), + time: 0.97205836, + ), + }, + ), + 19: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -2.1474366, + y: 20.93744, + z: -28.69173, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("38601448-b10c-43ec-91de-367e51c06daf"), + time: 0.0, + ), + }, + ), + 20: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -2.1474366, + y: 20.93744, + z: 18.621094, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("38601448-b10c-43ec-91de-367e51c06daf"), + time: 1.0002003, + ), + }, + ), + 21: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -2.1474366, + y: 11.742281, + z: -28.69173, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("8c5f79d4-6093-4372-9d2c-722353c76013"), + time: 0.0, + ), + }, + ), + 22: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -2.1474366, + y: 11.742281, + z: 22.233124, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("8c5f79d4-6093-4372-9d2c-722353c76013"), + time: 0.98274034, + ), + }, + ), + 23: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -6.1651826, + y: 28.684336, + z: 21.026539, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("b1db578c-b426-414c-bc95-f701f1b9ca41"), + time: 0.0, + ), + }, + ), + 24: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -6.1651826, + y: 28.684336, + z: 34.293587, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("b1db578c-b426-414c-bc95-f701f1b9ca41"), + time: 0.7156913, + ), + }, + ), + 25: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -6.1651826, + y: 26.61894, + z: 22.791157, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("f7567e27-bb0b-4ac7-a840-3c043b8d2ec2"), + time: 0.0, + ), + }, + ), + 26: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -6.1651826, + y: 26.61894, + z: 38.36416, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("f7567e27-bb0b-4ac7-a840-3c043b8d2ec2"), + time: 0.70500934, + ), + }, + ), + 27: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 6.205474, + y: 28.684336, + z: 21.026539, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("48006f09-9f10-477f-8308-02343f34a8c9"), + time: 0.0, + ), + }, + ), + 28: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 6.205474, + y: 26.61894, + z: 22.791157, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("5e2e70d9-5b6d-495b-998d-6a1f6dc2f964"), + time: 0.0, + ), + }, + ), + 29: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 6.205474, + y: 26.61894, + z: 37.461624, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("5e2e70d9-5b6d-495b-998d-6a1f6dc2f964"), + time: 0.71035033, + ), + }, + ), + 30: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 6.205474, + y: 28.684336, + z: 34.863163, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 0.122251, + y: 0.122251, + z: 8.75498, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("48006f09-9f10-477f-8308-02343f34a8c9"), + time: 0.71035033, + ), + }, + ), + 31: ( + components: { + "space_prefab::sub_scene::CollapsedSubScene": ("(\n resources: {},\n entities: {\n 31: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 6313511421271274664,\n name: \"Scene\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::component::GltfPrefab\": (\n path: \"desync.glb\",\n scene: \"Scene0\",\n ),\n },\n ),\n 36: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 37,\n 38,\n 39,\n 40,\n 41,\n 42,\n 43,\n 44,\n 45,\n 46,\n 47,\n 48,\n 49,\n 50,\n 51,\n 52,\n 53,\n 54,\n ]),\n },\n ),\n 37: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: -0.25713548,\n z: -4.3361616,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 17930118900081507113,\n name: \"empty.026\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n },\n ),\n 38: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 1,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: -0.45699087,\n z: -4.3361616,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2932322132067973075,\n name: \"empty.027\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n },\n ),\n 39: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 2,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 13395407226770374147,\n name: \"empty.028\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n },\n ),\n 40: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 3,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 15213343571318681368,\n name: \"empty.029\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n },\n ),\n 41: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 4,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: -0.55585074,\n z: -4.3361616,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 6637236937475434455,\n name: \"empty.030\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n },\n ),\n 42: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 5,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2359233632329934770,\n name: \"empty.031\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n },\n ),\n 43: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 6,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 37.414967,\n y: 61.1213,\n z: 112.0113,\n ),\n rotation: (\n x: -0.30626115,\n y: 0.43745646,\n z: -0.16405046,\n w: 0.8294115,\n ),\n scale: (\n x: 2.0169291,\n y: 2.016929,\n z: 2.016929,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 1734594374362272207,\n name: \"Sun\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 55,\n ]),\n },\n ),\n 44: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 7,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 27.426285,\n z: 0.00000051081065,\n ),\n rotation: (\n x: 0.7071068,\n y: 0.0,\n z: 0.0,\n w: 0.70710677,\n ),\n scale: (\n x: 0.01,\n y: 0.010000001,\n z: 0.010000001,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 5100648900209823736,\n name: \"StarFighterA.001\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 56,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"2c3faa37-9320-4bc7-825f-75b745477901\"),\n },\n ),\n 45: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 8,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -6.1651826,\n y: 28.684336,\n z: 21.026539,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2356508911138325861,\n name: \"LaserA\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 57,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"b1db578c-b426-414c-bc95-f701f1b9ca41\"),\n },\n ),\n 46: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 9,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -9.611355,\n y: 20.93744,\n z: -28.69173,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 3771605970233175782,\n name: \"LaserA.001\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 58,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"2b76e191-e079-417a-9369-2443f21f3ce5\"),\n },\n ),\n 47: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 10,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -9.611355,\n y: 11.742281,\n z: -28.69173,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 9811213967899955357,\n name: \"LaserA.002\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 59,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"408b5d7e-9456-4d81-a08e-feebba2ce1c4\"),\n },\n ),\n 48: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 11,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -2.1474366,\n y: 20.93744,\n z: -28.69173,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 4239528607526478068,\n name: \"LaserA.003\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 60,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"38601448-b10c-43ec-91de-367e51c06daf\"),\n },\n ),\n 49: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 12,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -2.1474366,\n y: 11.742281,\n z: -28.69173,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 17360688022630626399,\n name: \"LaserA.004\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 61,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"8c5f79d4-6093-4372-9d2c-722353c76013\"),\n },\n ),\n 50: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 13,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 6.205474,\n y: 28.684336,\n z: 34.863163,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2771954425045718751,\n name: \"LaserA.005\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 62,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"48006f09-9f10-477f-8308-02343f34a8c9\"),\n },\n ),\n 51: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 14,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -6.1651826,\n y: 26.61894,\n z: 22.791157,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 17690120629478967177,\n name: \"LaserA.006\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 63,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"f7567e27-bb0b-4ac7-a840-3c043b8d2ec2\"),\n },\n ),\n 52: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 15,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 6.205474,\n y: 26.61894,\n z: 22.791157,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 0.122251,\n y: 0.122251,\n z: 8.75498,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 16845800620004850392,\n name: \"LaserA.007\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 64,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"5e2e70d9-5b6d-495b-998d-6a1f6dc2f964\"),\n },\n ),\n 53: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 16,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 7.8028564,\n z: -77.33632,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 4339032300311916617,\n name: \"Trench_merged\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 65,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"4499fa6b-3ebd-410c-bee7-72d373e1aabb\"),\n },\n ),\n 54: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 17,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.90368176,\n y: 19.253796,\n z: -44.12696,\n ),\n rotation: (\n x: 0.7071068,\n y: 0.0,\n z: 0.0,\n w: 0.70710677,\n ),\n scale: (\n x: 0.01,\n y: 0.010000001,\n z: 0.010000001,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 14408680411555793288,\n name: \"StarFighterB\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 66,\n ]),\n \"studio::scene::SceneElement\": Mesh,\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"34f59412-5a7b-44e9-aaa3-3e74840121c9\"),\n },\n ),\n 55: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 6,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 1734594374362272207,\n name: \"Sun\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"bevy_pbr::light::DirectionalLight\": (\n color: Rgba(\n red: 1.0,\n green: 1.0,\n blue: 1.0,\n alpha: 1.0,\n ),\n illuminance: 34150.0,\n shadows_enabled: false,\n shadow_depth_bias: 0.02,\n shadow_normal_bias: 1.8,\n ),\n },\n ),\n 56: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 7,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 9518542504113058593,\n name: \"StarFighterA\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 1.0,\n blue: 1.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.09210527,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 57: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 8,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 516045924745151091,\n name: \"Cube.305\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 58: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 9,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 11455141375658386197,\n name: \"Cube.306\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 0.24252349,\n blue: 0.26523626,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 59: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 10,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 7710930313815418106,\n name: \"Cube.307\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 0.24252349,\n blue: 0.26523626,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 60: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 11,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2006570935111361085,\n name: \"Cube.308\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 0.24252349,\n blue: 0.26523626,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 61: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 12,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 18402460489604278594,\n name: \"Cube.309\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 0.24252349,\n blue: 0.26523626,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 62: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 13,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 11261500935898758914,\n name: \"Cube.310\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 63: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 14,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 12965872175589560571,\n name: \"Cube.311\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 64: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 15,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 12943195592201606100,\n name: \"Cube.312\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.5,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 65: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 16,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 14833676912841048273,\n name: \"Cube.215\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 1.0,\n blue: 1.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 0.46721607,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n 66: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 17,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 14408680411555793288,\n name: \"StarFighterB\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::ui::inspector::ShortMaterial\": (\n base_color: RgbaLinear(\n red: 1.0,\n green: 1.0,\n blue: 1.0,\n alpha: 1.0,\n ),\n perceptual_roughness: 1.0,\n metallic: 0.0,\n reflectance: 0.5,\n ),\n },\n ),\n },\n)"), + "space_prefab::sub_scene::ChildPath": ([]), + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 6313511421271274664, + name: "Scene", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "space_prefab::component::GltfPrefab": ( + path: "desync.glb", + scene: "Scene0", + ), + }, + ), + 32: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 27.808878, + z: 0.0, + ), + rotation: ( + x: 0.70710677, + y: 0.0, + z: 0.0, + w: 0.7071067, + ), + scale: ( + x: 0.01, + y: 0.010000001, + z: 0.010000001, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("2c3faa37-9320-4bc7-825f-75b745477901"), + time: 0.0, + ), + }, + ), + 33: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -26.600437, + y: 57.639946, + z: 45.259254, + ), + rotation: ( + x: -0.323544, + y: -0.2166858, + z: -0.07637838, + w: 0.91789585, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: MainCamera, + time: 0.0, + ), + }, + ), + 34: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 7.8028564, + z: -77.33632, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("4499fa6b-3ebd-410c-bee7-72d373e1aabb"), + time: 0.0, + ), + }, + ), + 35: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 7.8028564, + z: -297.5813, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("4499fa6b-3ebd-410c-bee7-72d373e1aabb"), + time: 3.037852, + ), + }, + ), + 4294967303: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -7.1710815, + y: 43.12783, + z: 30.99612, + ), + rotation: ( + x: -0.29587442, + y: -0.08895508, + z: -0.02768518, + w: 0.9506726, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: MainCamera, + time: 3.0597014, + ), + }, + ), + }, +) \ No newline at end of file diff --git a/studio/assets/scenes/test-2.scn.ron b/studio/assets/scenes/test-2.scn.ron new file mode 100644 index 0000000..0cd4248 --- /dev/null +++ b/studio/assets/scenes/test-2.scn.ron @@ -0,0 +1,210 @@ +( + resources: {}, + entities: { + 4: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: -0.27984813, + y: 0.36470518, + z: 0.11591689, + w: 0.88047624, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 18065451097249435342, + name: "Directional Light", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "bevy_pbr::light::DirectionalLight": ( + color: Rgba( + red: 1.0, + green: 1.0, + blue: 1.0, + alpha: 1.0, + ), + illuminance: 20000.0, + shadows_enabled: false, + shadow_depth_bias: 0.02, + shadow_normal_bias: 1.8, + ), + "studio::anim::timeline_animation::AnimationTarget": Uuid("874db6bd-de61-4f9a-a931-5dc7d9a1cf18"), + }, + ), + 9: ( + components: { + "space_prefab::sub_scene::CollapsedSubScene": ("(\n resources: {},\n entities: {\n 9: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 4651330846179949623,\n name: \"Mannequin.gltf\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::component::GltfPrefab\": (\n path: \"gltf/Mannequin.gltf\",\n scene: \"Scene0\",\n ),\n },\n ),\n 10: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: -4.2479,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 18,\n ]),\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"ade422d0-7775-404c-a4a2-4d02ba693111\"),\n },\n ),\n 11: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0000000000000018873791,\n y: 0.0772129,\n z: -0.0000000009313232,\n ),\n rotation: (\n x: -0.02840662,\n y: -0.00000017426322,\n z: -0.00000005048938,\n w: 0.9995964,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 10494895437773786916,\n name: \"DEF-spine.006\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0000000000000018873791,\n y: 0.0772129,\n z: -0.0000000009313232,\n ),\n rotation: (\n x: -0.02840662,\n y: -0.00000017426322,\n z: -0.00000005048938,\n w: 0.9995964,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000004317161,\n z: 0.0000002815904,\n ),\n y_axis: (\n x: 0.000000043171664,\n y: 1.0000001,\n z: -0.000000007450581,\n ),\n z_axis: (\n x: -0.00000028159027,\n y: 0.000000029802322,\n z: 1.0000001,\n ),\n ),\n translation: (\n x: -0.0000000098720045,\n y: 2.1084576,\n z: 0.009409847,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000004317161,\n z: -0.00000028159036,\n ),\n y_axis: (\n x: -0.000000043171653,\n y: 0.9999999,\n z: 0.000000007450591,\n ),\n z_axis: (\n x: 0.00000028159025,\n y: -0.000000029802303,\n z: 0.9999999,\n ),\n ),\n translation: (\n x: 0.00000009824788,\n y: -2.1084573,\n z: -0.009409862,\n ),\n )),\n },\n ),\n 18: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2255923429770983233,\n name: \"Mannequin_gamerig\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 19,\n 20,\n ]),\n \"studio::anim::Skeleton\": (),\n },\n ),\n 19: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 5794164128714325705,\n name: \"Mannequin\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 38,\n 39,\n ]),\n },\n ),\n 20: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 1.1824518,\n z: -0.0052269525,\n ),\n rotation: (\n x: 0.018155448,\n y: 0.000000119189586,\n z: 0.0000000021643014,\n w: 0.9998352,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 14743125227235264954,\n name: \"DEF-spine\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 21,\n 14,\n 15,\n 32,\n 35,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0,\n y: 1.1824518,\n z: -0.0052269525,\n ),\n rotation: (\n x: 0.018155448,\n y: 0.000000119189586,\n z: 0.0000000021643014,\n w: 0.9998352,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000000865577,\n z: -0.0000002382613,\n ),\n y_axis: (\n x: -0.000000000000008881783,\n y: 0.9993407,\n z: 0.03630491,\n ),\n z_axis: (\n x: 0.00000023841845,\n y: -0.03630491,\n z: 0.9993407,\n ),\n ),\n translation: (\n x: 0.0,\n y: 1.1824518,\n z: -0.0052269525,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.000000000000008881785,\n z: 0.00000023841848,\n ),\n y_axis: (\n x: 0.00000000865577,\n y: 0.99934083,\n z: -0.036304913,\n ),\n z_axis: (\n x: -0.0000002382613,\n y: 0.036304913,\n z: 0.99934083,\n ),\n ),\n translation: (\n x: -0.000000011480412,\n y: -1.1814827,\n z: 0.04815232,\n ),\n )),\n },\n ),\n 21: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0000000000000007771561,\n y: 0.24725592,\n z: -0.0000000013969839,\n ),\n rotation: (\n x: 0.017829195,\n y: -0.00000015639537,\n z: -0.0000000337525,\n w: 0.9998411,\n ),\n scale: (\n x: 1.0,\n y: 1.0000001,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 6655260424214022128,\n name: \"DEF-spine.001\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 22,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0000000000000007771561,\n y: 0.24725592,\n z: -0.0000000013969839,\n ),\n rotation: (\n x: 0.017829195,\n y: -0.00000015639537,\n z: -0.0000000337525,\n w: 0.9998411,\n ),\n scale: (\n x: 1.0,\n y: 1.0000001,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000007567748,\n z: 0.00000007041797,\n ),\n y_axis: (\n x: 0.00000007041773,\n y: 0.99741113,\n z: 0.07191105,\n ),\n z_axis: (\n x: -0.000000075677704,\n y: -0.071911044,\n z: 0.997411,\n ),\n ),\n translation: (\n x: -0.0000000000000017519841,\n y: 1.4295447,\n z: 0.0037496504,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000007041772,\n z: -0.00000007567771,\n ),\n y_axis: (\n x: -0.00000007567748,\n y: 0.997411,\n z: -0.07191105,\n ),\n z_axis: (\n x: 0.000000070417975,\n y: 0.071911044,\n z: 0.99741113,\n ),\n ),\n translation: (\n x: 0.00000010792029,\n y: -1.4261132,\n z: 0.09906012,\n ),\n )),\n },\n ),\n 22: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -0.000000000000004718448,\n y: 0.07935408,\n z: -0.0000000037252907,\n ),\n rotation: (\n x: -0.05250668,\n y: 0.00000019664517,\n z: 0.0000000696546,\n w: 0.9986205,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0000001,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 14630683112409401510,\n name: \"DEF-spine.002\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 23,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: -0.000000000000004718448,\n y: 0.07935408,\n z: -0.0000000037252907,\n ),\n rotation: (\n x: -0.05250668,\n y: 0.00000019664517,\n z: 0.0000000696546,\n w: 0.9986205,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0000001,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000007125139,\n z: -0.00000032008967,\n ),\n y_axis: (\n x: -0.00000008180174,\n y: 0.9994527,\n z: -0.033082463,\n ),\n z_axis: (\n x: 0.00000031755735,\n y: 0.033082478,\n z: 0.9994527,\n ),\n ),\n translation: (\n x: 0.0000000055879275,\n y: 1.5086935,\n z: 0.009456081,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000008180175,\n z: 0.00000031755727,\n ),\n y_axis: (\n x: 0.00000007125138,\n y: 0.9994526,\n z: 0.03308246,\n ),\n z_axis: (\n x: -0.00000032008973,\n y: -0.033082474,\n z: 0.9994526,\n ),\n ),\n translation: (\n x: -0.00000011005763,\n y: -1.5075548,\n z: -0.059362195,\n ),\n )),\n },\n ),\n 23: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -0.0000000000000029976022,\n y: 0.11385107,\n z: 0.0000000009313214,\n ),\n rotation: (\n x: -0.006840455,\n y: -0.000000045082487,\n z: -0.000000048790202,\n w: 0.9999767,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 1672047257949964828,\n name: \"DEF-spine.003\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 24,\n 26,\n 29,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: -0.0000000000000029976022,\n y: 0.11385107,\n z: 0.0000000009313214,\n ),\n rotation: (\n x: -0.006840455,\n y: -0.000000045082487,\n z: -0.000000048790202,\n w: 0.9999767,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.000000022652003,\n z: -0.00000022610132,\n ),\n y_axis: (\n x: 0.000000012056448,\n y: 0.9989066,\n z: -0.04675247,\n ),\n z_axis: (\n x: 0.00000022691317,\n y: 0.046752494,\n z: 0.9989066,\n ),\n ),\n translation: (\n x: -0.000000003725292,\n y: 1.6224821,\n z: 0.0056896103,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.000000012056432,\n z: 0.00000022691309,\n ),\n y_axis: (\n x: -0.000000022652012,\n y: 0.9989064,\n z: 0.04675246,\n ),\n z_axis: (\n x: -0.00000022610133,\n y: -0.046752483,\n z: 0.9989064,\n ),\n ),\n translation: (\n x: 0.0000000417642,\n y: -1.6204417,\n z: -0.08153842,\n ),\n )),\n },\n ),\n 24: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0000000000000013322676,\n y: 0.33216262,\n z: 0.0000000037252903,\n ),\n rotation: (\n x: 0.11835184,\n y: -0.00000007592711,\n z: 0.000000057157614,\n w: 0.9929718,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2382727264231059338,\n name: \"DEF-spine.004\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 25,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0000000000000013322676,\n y: 0.33216262,\n z: 0.0000000037252903,\n ),\n rotation: (\n x: 0.11835184,\n y: -0.00000007592711,\n z: 0.000000057157614,\n w: 0.9929718,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.0000000804653,\n z: -0.0000000664313,\n ),\n y_axis: (\n x: -0.00000006643164,\n y: 0.98191166,\n z: 0.18934037,\n ),\n z_axis: (\n x: 0.00000008046504,\n y: -0.18934034,\n z: 0.9819116,\n ),\n ),\n translation: (\n x: 0.00000000027940938,\n y: 1.9542816,\n z: -0.009839805,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000006643167,\n z: 0.00000008046497,\n ),\n y_axis: (\n x: 0.000000080465256,\n y: 0.98191136,\n z: -0.18934032,\n ),\n z_axis: (\n x: -0.00000006643136,\n y: 0.1893403,\n z: 0.9819114,\n ),\n ),\n translation: (\n x: -0.00000015818485,\n y: -1.9170681,\n z: 0.37968612,\n ),\n )),\n },\n ),\n 25: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -0.00000000000000036082248,\n y: 0.078507915,\n z: -0.0000000023283069,\n ),\n rotation: (\n x: -0.06678496,\n y: -0.0000000020318844,\n z: -0.000000006195938,\n w: 0.9977674,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 27449503429050499,\n name: \"DEF-spine.005\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 11,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: -0.00000000000000036082248,\n y: 0.078507915,\n z: -0.0000000023283069,\n ),\n rotation: (\n x: -0.06678496,\n y: -0.0000000020318844,\n z: -0.000000006195938,\n w: 0.9977674,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000006766682,\n z: -0.00000006392702,\n ),\n y_axis: (\n x: -0.00000006392714,\n y: 0.9983862,\n z: 0.056790303,\n ),\n z_axis: (\n x: 0.00000006766669,\n y: -0.056790277,\n z: 0.9983862,\n ),\n ),\n translation: (\n x: -0.0000000049359974,\n y: 2.0313694,\n z: 0.0050249086,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.000000063927175,\n z: 0.00000006766667,\n ),\n y_axis: (\n x: 0.00000006766678,\n y: 0.9983861,\n z: -0.056790296,\n ),\n z_axis: (\n x: -0.00000006392704,\n y: 0.05679027,\n z: 0.9983861,\n ),\n ),\n translation: (\n x: -0.00000013219902,\n y: -2.0283763,\n z: 0.110345274,\n ),\n )),\n },\n ),\n 38: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2641625536990733995,\n name: \"Mannequin.0\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::anim::ProcessedSkinnedMesh\": (),\n },\n ),\n 39: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 0,\n 1,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 3807248060729159681,\n name: \"Mannequin.1\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::anim::ProcessedSkinnedMesh\": (),\n },\n ),\n },\n)"), + "space_prefab::sub_scene::ChildPath": ([]), + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 4651330846179949623, + name: "Mannequin.gltf", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "space_prefab::component::GltfPrefab": ( + path: "gltf/Mannequin.gltf", + scene: "Scene0", + ), + }, + ), + 67: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -1.6869695, + y: 4.214031, + z: 3.4296653, + ), + rotation: ( + x: -0.323544, + y: -0.2166858, + z: -0.07637838, + w: 0.91789585, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: MainCamera, + time: 0.0, + ), + }, + ), + 68: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 1.5047787, + y: 3.9984386, + z: 3.9482107, + ), + rotation: ( + x: -0.31489795, + y: 0.09083189, + z: 0.030290442, + w: 0.9442835, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: MainCamera, + time: 0.99502486, + ), + }, + ), + 69: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("ade422d0-7775-404c-a4a2-4d02ba693111"), + time: 0.986733, + ), + }, + ), + 70: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: -4.2479, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("ade422d0-7775-404c-a4a2-4d02ba693111"), + time: 0.0, + ), + }, + ), + }, +) \ No newline at end of file diff --git a/studio/assets/scenes/test-3.scn.ron b/studio/assets/scenes/test-3.scn.ron new file mode 100644 index 0000000..95619ab --- /dev/null +++ b/studio/assets/scenes/test-3.scn.ron @@ -0,0 +1,337 @@ +( + resources: {}, + entities: { + 3: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: -0.27984813, + y: 0.36470518, + z: 0.11591689, + w: 0.88047624, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 895934885003161521, + name: "Directional Light", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "bevy_pbr::light::DirectionalLight": ( + color: Rgba( + red: 1.0, + green: 1.0, + blue: 1.0, + alpha: 1.0, + ), + illuminance: 20000.0, + shadows_enabled: false, + shadow_depth_bias: 0.02, + shadow_normal_bias: 1.8, + ), + "studio::anim::timeline_animation::AnimationTarget": Uuid("4947b31e-36b3-4594-83d1-48ae486eb90c"), + }, + ), + 9: ( + components: { + "space_prefab::sub_scene::CollapsedSubScene": ("(\n resources: {},\n entities: {\n 9: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 3148297947468522912,\n name: \"Mannequin.gltf\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::component::GltfPrefab\": (\n path: \"gltf/Mannequin.gltf\",\n scene: \"Scene0\",\n ),\n },\n ),\n 10: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 18,\n ]),\n },\n ),\n 11: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0000000000000018873791,\n y: 0.0772129,\n z: -0.0000000009313232,\n ),\n rotation: (\n x: -0.02840662,\n y: -0.00000017426322,\n z: -0.00000005048938,\n w: 0.9995964,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 14876170861677685739,\n name: \"DEF-spine.006\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0000000000000018873791,\n y: 0.0772129,\n z: -0.0000000009313232,\n ),\n rotation: (\n x: -0.02840662,\n y: -0.00000017426322,\n z: -0.00000005048938,\n w: 0.9995964,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000004317161,\n z: 0.0000002815904,\n ),\n y_axis: (\n x: 0.000000043171664,\n y: 1.0000001,\n z: -0.000000007450581,\n ),\n z_axis: (\n x: -0.00000028159027,\n y: 0.000000029802322,\n z: 1.0000001,\n ),\n ),\n translation: (\n x: -0.0000000098720045,\n y: 2.1084576,\n z: 0.009409847,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000004317161,\n z: -0.00000028159036,\n ),\n y_axis: (\n x: -0.000000043171653,\n y: 0.9999999,\n z: 0.000000007450591,\n ),\n z_axis: (\n x: 0.00000028159025,\n y: -0.000000029802303,\n z: 0.9999999,\n ),\n ),\n translation: (\n x: 0.00000009824788,\n y: -2.1084573,\n z: -0.009409862,\n ),\n )),\n },\n ),\n 18: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 10178146706366111795,\n name: \"Mannequin_gamerig\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 19,\n 20,\n ]),\n \"studio::anim::Skeleton\": (),\n },\n ),\n 19: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 2751297732507581066,\n name: \"Mannequin\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 39,\n 38,\n ]),\n },\n ),\n 20: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 1.1824518,\n z: -0.0052269525,\n ),\n rotation: (\n x: 0.018155448,\n y: 0.000000119189586,\n z: 0.0000000021643014,\n w: 0.9998352,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 6299767129936315372,\n name: \"DEF-spine\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 21,\n 14,\n 15,\n 32,\n 35,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0,\n y: 1.1824518,\n z: -0.0052269525,\n ),\n rotation: (\n x: 0.018155448,\n y: 0.000000119189586,\n z: 0.0000000021643014,\n w: 0.9998352,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000000865577,\n z: -0.0000002382613,\n ),\n y_axis: (\n x: -0.000000000000008881783,\n y: 0.9993407,\n z: 0.03630491,\n ),\n z_axis: (\n x: 0.00000023841845,\n y: -0.03630491,\n z: 0.9993407,\n ),\n ),\n translation: (\n x: 0.0,\n y: 1.1824518,\n z: -0.0052269525,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.000000000000008881785,\n z: 0.00000023841848,\n ),\n y_axis: (\n x: 0.00000000865577,\n y: 0.99934083,\n z: -0.036304913,\n ),\n z_axis: (\n x: -0.0000002382613,\n y: 0.036304913,\n z: 0.99934083,\n ),\n ),\n translation: (\n x: -0.000000011480412,\n y: -1.1814827,\n z: 0.04815232,\n ),\n )),\n },\n ),\n 21: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0000000000000007771561,\n y: 0.24725592,\n z: -0.0000000013969839,\n ),\n rotation: (\n x: 0.017829195,\n y: -0.00000015639537,\n z: -0.0000000337525,\n w: 0.9998411,\n ),\n scale: (\n x: 1.0,\n y: 1.0000001,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 6194981351786922998,\n name: \"DEF-spine.001\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 22,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0000000000000007771561,\n y: 0.24725592,\n z: -0.0000000013969839,\n ),\n rotation: (\n x: 0.017829195,\n y: -0.00000015639537,\n z: -0.0000000337525,\n w: 0.9998411,\n ),\n scale: (\n x: 1.0,\n y: 1.0000001,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000007567748,\n z: 0.00000007041797,\n ),\n y_axis: (\n x: 0.00000007041773,\n y: 0.99741113,\n z: 0.07191105,\n ),\n z_axis: (\n x: -0.000000075677704,\n y: -0.071911044,\n z: 0.997411,\n ),\n ),\n translation: (\n x: -0.0000000000000017519841,\n y: 1.4295447,\n z: 0.0037496504,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000007041772,\n z: -0.00000007567771,\n ),\n y_axis: (\n x: -0.00000007567748,\n y: 0.997411,\n z: -0.07191105,\n ),\n z_axis: (\n x: 0.000000070417975,\n y: 0.071911044,\n z: 0.99741113,\n ),\n ),\n translation: (\n x: 0.00000010792029,\n y: -1.4261132,\n z: 0.09906012,\n ),\n )),\n },\n ),\n 22: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -0.000000000000004718448,\n y: 0.07935408,\n z: -0.0000000037252907,\n ),\n rotation: (\n x: -0.05250668,\n y: 0.00000019664517,\n z: 0.0000000696546,\n w: 0.9986205,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0000001,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 8506696300667946272,\n name: \"DEF-spine.002\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 23,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: -0.000000000000004718448,\n y: 0.07935408,\n z: -0.0000000037252907,\n ),\n rotation: (\n x: -0.05250668,\n y: 0.00000019664517,\n z: 0.0000000696546,\n w: 0.9986205,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0000001,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000007125139,\n z: -0.00000032008967,\n ),\n y_axis: (\n x: -0.00000008180174,\n y: 0.9994527,\n z: -0.033082463,\n ),\n z_axis: (\n x: 0.00000031755735,\n y: 0.033082478,\n z: 0.9994527,\n ),\n ),\n translation: (\n x: 0.0000000055879275,\n y: 1.5086935,\n z: 0.009456081,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000008180175,\n z: 0.00000031755727,\n ),\n y_axis: (\n x: 0.00000007125138,\n y: 0.9994526,\n z: 0.03308246,\n ),\n z_axis: (\n x: -0.00000032008973,\n y: -0.033082474,\n z: 0.9994526,\n ),\n ),\n translation: (\n x: -0.00000011005763,\n y: -1.5075548,\n z: -0.059362195,\n ),\n )),\n },\n ),\n 23: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -0.0000000000000029976022,\n y: 0.11385107,\n z: 0.0000000009313214,\n ),\n rotation: (\n x: -0.006840455,\n y: -0.000000045082487,\n z: -0.000000048790202,\n w: 0.9999767,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 1870632963092955579,\n name: \"DEF-spine.003\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 24,\n 26,\n 29,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: -0.0000000000000029976022,\n y: 0.11385107,\n z: 0.0000000009313214,\n ),\n rotation: (\n x: -0.006840455,\n y: -0.000000045082487,\n z: -0.000000048790202,\n w: 0.9999767,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.000000022652003,\n z: -0.00000022610132,\n ),\n y_axis: (\n x: 0.000000012056448,\n y: 0.9989066,\n z: -0.04675247,\n ),\n z_axis: (\n x: 0.00000022691317,\n y: 0.046752494,\n z: 0.9989066,\n ),\n ),\n translation: (\n x: -0.000000003725292,\n y: 1.6224821,\n z: 0.0056896103,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.000000012056432,\n z: 0.00000022691309,\n ),\n y_axis: (\n x: -0.000000022652012,\n y: 0.9989064,\n z: 0.04675246,\n ),\n z_axis: (\n x: -0.00000022610133,\n y: -0.046752483,\n z: 0.9989064,\n ),\n ),\n translation: (\n x: 0.0000000417642,\n y: -1.6204417,\n z: -0.08153842,\n ),\n )),\n },\n ),\n 24: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0000000000000013322676,\n y: 0.33216262,\n z: 0.0000000037252903,\n ),\n rotation: (\n x: 0.110946305,\n y: -0.3457283,\n z: -0.041207135,\n w: 0.93083996,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 6889122292892927709,\n name: \"DEF-spine.004\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 25,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: 0.0000000000000013322676,\n y: 0.33216262,\n z: 0.0000000037252903,\n ),\n rotation: (\n x: 0.11835184,\n y: -0.00000007592711,\n z: 0.000000057157614,\n w: 0.9929718,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.0000000804653,\n z: -0.0000000664313,\n ),\n y_axis: (\n x: -0.00000006643164,\n y: 0.98191166,\n z: 0.18934037,\n ),\n z_axis: (\n x: 0.00000008046504,\n y: -0.18934034,\n z: 0.9819116,\n ),\n ),\n translation: (\n x: 0.00000000027940938,\n y: 1.9542816,\n z: -0.009839805,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.00000006643167,\n z: 0.00000008046497,\n ),\n y_axis: (\n x: 0.000000080465256,\n y: 0.98191136,\n z: -0.18934032,\n ),\n z_axis: (\n x: -0.00000006643136,\n y: 0.1893403,\n z: 0.9819114,\n ),\n ),\n translation: (\n x: -0.00000015818485,\n y: -1.9170681,\n z: 0.37968612,\n ),\n )),\n \"studio::anim::timeline_animation::AnimationTarget\": Uuid(\"08dc1314-18ad-48bb-a649-6f0ca473f47e\"),\n },\n ),\n 25: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 1,\n 0,\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: -0.00000000000000036082248,\n y: 0.078507915,\n z: -0.0000000023283069,\n ),\n rotation: (\n x: -0.06678496,\n y: -0.0000000020318844,\n z: -0.000000006195938,\n w: 0.9977674,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 5717292836147049997,\n name: \"DEF-spine.005\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"space_prefab::save::ChildrenPrefab\": ([\n 11,\n ]),\n \"studio::anim::SkeletalJoint\": (Rgba(\n red: 0.0,\n green: 0.0,\n blue: 0.0,\n alpha: 1.0,\n )),\n \"studio::anim::BindPoseLocal\": ((\n translation: (\n x: -0.00000000000000036082248,\n y: 0.078507915,\n z: -0.0000000023283069,\n ),\n rotation: (\n x: -0.06678496,\n y: -0.0000000020318844,\n z: -0.000000006195938,\n w: 0.9977674,\n ),\n scale: (\n x: 1.0,\n y: 0.99999994,\n z: 0.99999994,\n ),\n )),\n \"studio::anim::BindPoseGlobal\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: 0.00000006766682,\n z: -0.00000006392702,\n ),\n y_axis: (\n x: -0.00000006392714,\n y: 0.9983862,\n z: 0.056790303,\n ),\n z_axis: (\n x: 0.00000006766669,\n y: -0.056790277,\n z: 0.9983862,\n ),\n ),\n translation: (\n x: -0.0000000049359974,\n y: 2.0313694,\n z: 0.0050249086,\n ),\n )),\n \"studio::anim::InvBindPose\": ((\n matrix3: (\n x_axis: (\n x: 1.0,\n y: -0.000000063927175,\n z: 0.00000006766667,\n ),\n y_axis: (\n x: 0.00000006766678,\n y: 0.9983861,\n z: -0.056790296,\n ),\n z_axis: (\n x: -0.00000006392704,\n y: 0.05679027,\n z: 0.9983861,\n ),\n ),\n translation: (\n x: -0.00000013219902,\n y: -2.0283763,\n z: 0.110345274,\n ),\n )),\n },\n ),\n 38: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 0,\n 1,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 10696431091907580023,\n name: \"Mannequin.1\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::anim::ProcessedSkinnedMesh\": (),\n },\n ),\n 39: (\n components: {\n \"space_prefab::sub_scene::ChildPath\": ([\n 0,\n 0,\n 0,\n 0,\n ]),\n \"bevy_transform::components::transform::Transform\": (\n translation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n ),\n rotation: (\n x: 0.0,\n y: 0.0,\n z: 0.0,\n w: 1.0,\n ),\n scale: (\n x: 1.0,\n y: 1.0,\n z: 1.0,\n ),\n ),\n \"bevy_core::name::Name\": (\n hash: 1275687721817851979,\n name: \"Mannequin.0\",\n ),\n \"bevy_render::view::visibility::Visibility\": Inherited,\n \"studio::anim::ProcessedSkinnedMesh\": (),\n },\n ),\n },\n)"), + "space_prefab::sub_scene::ChildPath": ([]), + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 3148297947468522912, + name: "Mannequin.gltf", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "space_prefab::component::GltfPrefab": ( + path: "gltf/Mannequin.gltf", + scene: "Scene0", + ), + }, + ), + 56: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 1.8939692, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_core::name::Name": ( + hash: 15493249626985138933, + name: "Cube", + ), + "bevy_render::view::visibility::Visibility": Inherited, + "space_prefab::component::material::MaterialPrefab": ( + base_color: Rgba( + red: 1.0, + green: 1.0, + blue: 1.0, + alpha: 1.0, + ), + base_color_texture: "", + emissive: Rgba( + red: 0.0, + green: 0.0, + blue: 0.0, + alpha: 1.0, + ), + emissive_texture: "", + perceptual_roughness: 0.5, + metallic: 0.0, + metallic_roughness_texture: "", + reflectance: 0.5, + normal_map_texture: "", + flip_normal_map_y: false, + occlusion_texture: "", + double_sided: false, + unlit: false, + fog_enabled: true, + alpha_mode: Opaque, + depth_bias: 0.0, + depth_map: "", + parallax_depth_scale: 0.1, + parallax_mapping_method: Occlusion, + max_parallax_layer_count: 16.0, + ), + "space_prefab::component::shape::MeshPrimitivePrefab": Cube(1.0), + "studio::anim::timeline_animation::AnimationTarget": Uuid("1b743502-3f43-458a-a9f5-87f7e45f9496"), + }, + ), + 68: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 1.8939692, + y: 0.0, + z: 0.0, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("1b743502-3f43-458a-a9f5-87f7e45f9496"), + time: 0.0, + ), + }, + ), + 69: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 1.8939692, + y: 0.0, + z: -4.7119884, + ), + rotation: ( + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("1b743502-3f43-458a-a9f5-87f7e45f9496"), + time: 0.986733, + ), + }, + ), + 70: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -1.6869695, + y: 4.214031, + z: 3.4296653, + ), + rotation: ( + x: -0.323544, + y: -0.2166858, + z: -0.07637838, + w: 0.91789585, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: MainCamera, + time: 0.0, + ), + }, + ), + 71: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 2.656954, + y: 2.5739927, + z: 4.295441, + ), + rotation: ( + x: -0.18481158, + y: 0.16737497, + z: 0.031959027, + w: 0.96788955, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: MainCamera, + time: 0.99502486, + ), + }, + ), + 30064771133: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: -0.0000000000000035527137, + y: 0.33216262, + z: 0.000000007450581, + ), + rotation: ( + x: 0.23730049, + y: 0.43568358, + z: -0.14108452, + w: 0.85671663, + ), + scale: ( + x: 0.9999988, + y: 0.9999998, + z: 0.99999875, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("08dc1314-18ad-48bb-a649-6f0ca473f47e"), + time: 0.986733, + ), + }, + ), + 30064771134: ( + components: { + "bevy_transform::components::transform::Transform": ( + translation: ( + x: 0.0000000000000013322676, + y: 0.33216262, + z: 0.0000000037252903, + ), + rotation: ( + x: 0.110946305, + y: -0.3457283, + z: -0.041207135, + w: 0.93084, + ), + scale: ( + x: 1.0, + y: 1.0, + z: 1.0, + ), + ), + "bevy_render::view::visibility::Visibility": Inherited, + "studio::anim::timeline_animation::TransformKeyframe": ( + incoming_easing: Linear, + affect_translation: true, + affect_rotation: true, + affect_scale: true, + ), + "studio::anim::timeline_animation::Keyframe": ( + target: Uuid("08dc1314-18ad-48bb-a649-6f0ca473f47e"), + time: 0.0, + ), + }, + ), + }, +) \ No newline at end of file diff --git a/studio/project.json b/studio/project.json index b3b28be..e72cbaf 100644 --- a/studio/project.json +++ b/studio/project.json @@ -41,7 +41,7 @@ "headless": { "executor": "nx:run-commands", "options": { - "command": "cargo run -p studio --bin studio-headless -- {args}" + "command": "cargo run -p studio --bin studio-headless --" } }, "test": { diff --git a/studio/scripts/build-web.mjs b/studio/scripts/build-web.mjs index b24aab3..cbb872c 100644 --- a/studio/scripts/build-web.mjs +++ b/studio/scripts/build-web.mjs @@ -2,11 +2,12 @@ import fs from "node:fs"; import url from "node:url"; import path from "node:path"; import { rimraf } from "rimraf"; -import which from "which"; import copyAssets from "./copy-assets.mjs"; import handleError from "./handle-error.mjs"; -import run from "./run.mjs"; +import run, { wasmBindgenCliVersion } from "./run.mjs"; + +const WASM_BINDGEN_CLI_VERSION = "0.2.92"; async function main() { try { @@ -39,11 +40,11 @@ async function main() { stdio, }); - const wasmBindgenInstalled = Boolean(await which("wasm-bindgen", { nothrow: true })); - if (!wasmBindgenInstalled) { + const wbVersion = await wasmBindgenCliVersion(); + if (!wbVersion || !wbVersion.includes(WASM_BINDGEN_CLI_VERSION)) { await run("cargo", [ "install", "-f", "wasm-bindgen-cli", - "--version", "0.2.88", + "--version", WASM_BINDGEN_CLI_VERSION, ], { shell: true, cwd: workspaceRoot, diff --git a/studio/scripts/run.mjs b/studio/scripts/run.mjs index 91ffe08..5f9d021 100644 --- a/studio/scripts/run.mjs +++ b/studio/scripts/run.mjs @@ -1,5 +1,6 @@ import chalk from "chalk"; import cp from "node:child_process"; +import which from "which"; /** * @param {string} command @@ -25,3 +26,35 @@ export default function run(command, args, options) { .on("error", reject); }); } + +/** + * @returns {Promise} + */ +export async function wasmBindgenCliVersion() { + const exePath = await which("wasm-bindgen", { nothrow: true }); + if (!exePath) return null; + + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + + const finish = code => { + if (code) reject(new Error(stderr)); + else resolve(stdout); + } + + const proc = cp.spawn(exePath, ["--version"], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], + }) + .on("exit", finish) + .on("close", finish) + .on("error", reject); + + proc.stdout.setEncoding("utf-8"); + proc.stdout.on("data", chunk => stdout += chunk); + + proc.stderr.setEncoding("utf-8"); + proc.stderr.on("data", chunk => stderr += chunk); + }); +} diff --git a/studio/src/anim/camera.rs b/studio/src/anim/camera.rs deleted file mode 100644 index ce3e368..0000000 --- a/studio/src/anim/camera.rs +++ /dev/null @@ -1,208 +0,0 @@ -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::() - .editor_registry::() - .register_type::() - .editor_registry::(); - - 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, - q_cam_keyframe_xforms: Query<&GlobalTransform, With>, - q_cam_target_keyframe_xforms: Query<&GlobalTransform, With>, - 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 = pos_a.lerp(pos_b, t); - 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 = 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, - 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; -} - -declare global { - export interface GlobalEventHandlersEventMap { - "camera-keyframes": CameraKeyframesEvent; - } -} - "#; - - pub(super) fn notify_keyframe_changes( - mut l_notified: Local>, - q_keyframes: Query<(Entity, &CameraKeyframe)>, - q_window: Query<&Window, With>, - ) -> 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) - } -} diff --git a/studio/src/anim/mod.rs b/studio/src/anim/mod.rs index 25b8d21..2c5d4f9 100644 --- a/studio/src/anim/mod.rs +++ b/studio/src/anim/mod.rs @@ -9,8 +9,8 @@ use space_editor::space_prefab::editor_registry::EditorRegistryExt; use crate::{hierarchy, scene::SceneElement}; pub use retargeting::*; -mod camera; mod retargeting; +pub mod timeline_animation; pub struct AnimPlugin; impl Plugin for AnimPlugin { @@ -29,19 +29,14 @@ impl Plugin for AnimPlugin { .editor_registry::(); app.add_systems(Update, process_skinned_meshes); - app.insert_resource(GlobalSeekTime(0.)); - app.add_plugins((retargeting::RetargetingPlugin, camera::CameraAnimPlugin)); - - #[cfg(feature = "wasm")] - app.add_systems(Update, wasm::update_global_seek_time); + app.add_plugins(( + retargeting::RetargetingPlugin, + timeline_animation::TimelinePlugin, + )); } } -#[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; @@ -149,26 +144,3 @@ 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 = 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) { - if let Some(timestamp) = SEEK_TIME.take_message() { - **r_seek_time = timestamp; - } - } -} diff --git a/studio/src/anim/timeline_animation.rs b/studio/src/anim/timeline_animation.rs new file mode 100644 index 0000000..97ae6f9 --- /dev/null +++ b/studio/src/anim/timeline_animation.rs @@ -0,0 +1,993 @@ +use std::{str::FromStr, time::Duration}; + +use bevy::{prelude::*, utils::hashbrown::HashMap}; + +use bevy_tweening::{ + lens::{TransformPositionLens, TransformRotationLens, TransformScaleLens}, + Animator, EaseMethod, Sequence, Targetable, Tracks, Tween, TweenCompleted, +}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use space_editor::{prelude::PrefabMarker, space_prefab::editor_registry::EditorRegistryExt}; + +use crate::{ + headless::HeadlessSettings, + interaction::{MarkedForDeletion, Selectable}, + scene::SceneElement, +}; + +pub struct TimelinePlugin; + +impl Plugin for TimelinePlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .editor_registry::() + .register_type::() + .register_type::() + .add_event::() + .add_event::() + .add_state::(); + + app.insert_resource(GlobalSeekTime { + time: 0., + playing: false, + delta: None, + }) + .add_event::(); + + app.add_systems(First, generate_transform_animation); + app.add_systems(PreUpdate, (process_timeline_events, update_tick).chain()); + + app.add_systems( + Update, + ( + process_auto_key.run_if(in_state(KeyingMode::Auto)), + process_keyframe_events, + timeline_bound_component_animator_system::, + ), + ); + + // app.add_systems(PostUpdate, generate_transform_animation); + + #[cfg(feature = "wasm")] + { + app.add_systems( + Last, + ( + wasm::update_global_seek_time, + wasm::update_keyframe_interaction, + ), + ); + app.add_systems( + PostUpdate, + ( + wasm::notify_seek_time_changes.map(bevy::utils::error), + wasm::notify_keyframe_changes.map(bevy::utils::error), + wasm::notify_updated_animation_targets.map(bevy::utils::error), + wasm::notify_keying_mode_changed.map(bevy::utils::error), + ), + ); + } + } +} + +const MIN_KEYFRAME_DISTANCE: f32 = 0.01; + +#[allow(dead_code)] +#[derive(Event, Clone, Copy, Debug)] +pub enum TimelineInteractionEvent { + Play, + Pause, + Seek(f32), + ToggleAutoKey, +} + +#[allow(dead_code)] +#[derive(Event, Clone, Copy, Debug)] +pub enum KeyframeInteractionEvent { + RecordKeyframe { target: Entity }, + MoveKeyframeTime { target: Entity, time: f32 }, + AddEntityTrack { target: Entity }, +} + +#[derive(Resource, Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize)] +#[reflect(Resource)] +pub struct GlobalSeekTime { + pub time: f32, + pub playing: bool, + pub delta: Option, +} + +#[derive(States, Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum KeyingMode { + #[default] + Manual, + Auto, +} + +#[derive(Clone, Debug, Copy, Default, Reflect, PartialEq, Serialize, Deserialize)] +pub enum EaseType { + #[default] + Linear, + CubicHermite, +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq)] +#[reflect(Component)] +pub struct TransformKeyframe { + incoming_easing: EaseType, + affect_translation: bool, + affect_rotation: bool, + affect_scale: bool, +} + +impl Default for TransformKeyframe { + fn default() -> Self { + Self { + incoming_easing: Default::default(), + affect_translation: true, + affect_rotation: true, + affect_scale: true, + } + } +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq)] +#[reflect(Component)] +pub struct Keyframe { + target: AnimationTarget, + pub time: f32, +} + +#[derive(Component, Clone, Debug, Default, Reflect, PartialEq, Eq, PartialOrd, Ord)] +#[reflect(Component)] +pub struct KeyframeOrder(u32); + +impl PartialOrd for Keyframe { + fn partial_cmp(&self, other: &Self) -> Option { + self.time.partial_cmp(&other.time) + } +} + +impl Default for Keyframe { + fn default() -> Self { + Self { + time: Default::default(), + target: AnimationTarget::Unknown, + } + } +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq)] +#[reflect(Component)] +pub struct TimedAnimationClipAsset { + clip: Handle, + duration: f32, + speed: f32, +} + +impl PartialOrd for TimedAnimationClipAsset { + fn partial_cmp(&self, other: &Self) -> Option { + match self.duration.partial_cmp(&other.duration) { + Some(core::cmp::Ordering::Equal) => {} + ord => return ord, + } + match self.speed.partial_cmp(&other.speed) { + Some(core::cmp::Ordering::Equal) => {} + ord => return ord, + } + None + } +} + +impl Default for TimedAnimationClipAsset { + fn default() -> Self { + Self { + clip: Default::default(), + duration: Default::default(), + speed: 1.0, + } + } +} + +#[derive( + Component, Clone, Debug, Default, Reflect, PartialEq, Eq, Hash, Copy, Serialize, Deserialize, +)] +#[reflect(Component)] +#[serde(try_from = "&str", into = "String")] +pub enum AnimationTarget { + #[default] + Unknown, + MainCamera, + Uuid(uuid::Uuid), +} + +impl AnimationTarget { + pub fn new() -> Self { + Self::Uuid(uuid::Uuid::new_v4()) + } +} + +impl ToString for AnimationTarget { + fn to_string(&self) -> String { + match self { + Self::Unknown => "unknown".to_string(), + Self::MainCamera => "MainCamera".to_string(), + Self::Uuid(uuid) => uuid.to_string(), + } + } +} + +impl From for String { + fn from(val: AnimationTarget) -> Self { + val.to_string() + } +} + +impl TryFrom<&str> for AnimationTarget { + type Error = uuid::Error; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl FromStr for AnimationTarget { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + if s == "unknown" { + Ok(Self::Unknown) + } else if s == "MainCamera" { + Ok(Self::MainCamera) + } else { + Ok(Self::Uuid(s.parse()?)) + } + } +} + +fn process_timeline_events( + mut e_timeline_event: EventReader, + mut r_global_seek_time: ResMut, + key_mode: Res>, + mut next_keying_mode: ResMut>, +) { + for event in e_timeline_event.read() { + match event { + TimelineInteractionEvent::Play => r_global_seek_time.playing = true, + TimelineInteractionEvent::Pause => { + r_global_seek_time.playing = false; + } + TimelineInteractionEvent::Seek(time) => { + r_global_seek_time.time = time.max(0.); + } + TimelineInteractionEvent::ToggleAutoKey => { + next_keying_mode.set(match key_mode.get() { + KeyingMode::Manual => KeyingMode::Auto, + KeyingMode::Auto => KeyingMode::Manual, + }); + } + } + + r_global_seek_time.delta = None; + } +} + +fn update_tick( + mut r_global_seek_time: ResMut, + time: Res