Merge branch 'main' into update-version

This commit is contained in:
rewin
2024-03-27 22:56:50 +03:00
90 changed files with 7820 additions and 977 deletions
+2
View File
@@ -41,3 +41,5 @@ Thumbs.db
/target
/screen_shots
/frames
+1
View File
@@ -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";
+38
View File
@@ -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`<div ${ref(elRef)}></div>`, 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<HTMLElement>`", () => {
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);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Ref } from "lit/directives/ref.js";
export function deref<T>(refOrValue: Ref<T> | T | null | undefined): T | undefined {
if (isRef(refOrValue))
return refOrValue.value;
return (refOrValue ?? undefined);
}
function isRef<T>(refOrValue: Ref<T> | T): refOrValue is Ref<T> {
if (!refOrValue || typeof refOrValue !== "object")
return false;
const keys = Object.keys(refOrValue);
return keys.length === 1 && keys[0] === "value"
}
@@ -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;
@@ -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`
<sts-canvas-provider class="app-root">
<sts-scene-data-provider>
<div
class="hierarchy-wrapper"
style=${styleMap({ width: `${this.hierarchyWidth}px` })}
@@ -297,6 +299,7 @@ export class AppElement extends LitElement {
${this.loading ? html`
<sts-loading></sts-loading>
` : nothing}
</sts-scene-data-provider>
</sts-canvas-provider>
`
}
+29 -20
View File
@@ -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",
+3 -2
View File
@@ -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"
}
}
+1
View File
@@ -501,6 +501,7 @@ mod tests {
use std::time::Duration;
// #[test] // FIXME
#[allow(dead_code)]
fn macro_create() {
let bvh = bvh! {
HIERARCHY
+9
View File
@@ -15,5 +15,14 @@
<body>
<app-root></app-root>
<script type="module" src="/src/main.ts"></script>
<!-- Smartlook recording -->
<script type='text/javascript'>
window.smartlook||(function(d) {
var o=smartlook=function(){ o.api.push(arguments)},h=d.getElementsByTagName('head')[0];
var c=d.createElement('script');o.api=new Array();c.async=true;c.type='text/javascript';
c.charset='utf-8';c.src='https://web-sdk.smartlook.com/recorder.js';h.appendChild(c);
})(document);
smartlook('init', '3d1ddaf51ab1fa28ffe0b2bd94aa0e233bb0fd9e', { region: 'eu' });
</script>
</body>
</html>
@@ -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;
}
}
}
}
+305 -50
View File
@@ -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<string, string> = {
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<string, number> = {};
@state() playing = false;
@state() tracks: Record<string, { name?: string, target_type: studio.SceneElement, animation_target: studio.AnimationTarget }> = {};
@state() track_ordered: [string, studio.AnimationTarget][] = [];
@state() keyframes: Record<string, Keyframe> = {};
@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<this>): void {
if (changes.has("playHead"))
studio.seekTime(this.playHead);
if (changes.has("selectedTrack")) {
const treeSelectionChanges: PropertyValues<TreeSelectionProvider> = 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<string, number> = {};
const track_animation_target_order_hashed: Record<studio.AnimationTarget, number> = {};
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`
<sts-tree class="track"
role="listitem"
name=${name}
icon=${icon}
@tree-select=${() => {
studio.select(entity);
}}
.id=${entity}
></sts-tree>
`;
});
if (canAddSelected) {
tracklist.push(html`
<sts-tree class=${`track`}
role="button"
.name=${`Add Track: ${this.sceneData?.selectedObject?.name ?? canAddSelected}`}
icon="plus-large"
@click=${this.addTarget}></sts-tree>
`);
}
const none_selected = !selected;
return html`
<div class="sidebar"
style=${styleMap({
width: `${this.sidebarWidth}px`,
})}
width: `${this.sidebarWidth}px`,
...timeline_css_variables
})}
>
<sts-toolbar>
<sts-toolbar-group>
<sts-button class="btn-keyframe"
icon="key"
aria-label="Add Keyframe"
title="Add Keyframe"
@click=${this.addKeyframe}
></sts-button>
${this.keyingMode === "automatic" ? html`
<sts-button class="btn-autokey active"
icon="record"
aria-label="Auto Keyframe"
title="Auto Keyframe"
@click=${studio.toggleAutoKey}
></sts-button>
` : html`
<sts-button class="btn-keyframe"
icon="key"
aria-label="Add Keyframe"
title="Add Keyframe"
?disabled=${none_selected}
@click=${this.addKeyframe}
></sts-button>
<sts-button class="btn-autokey"
icon="record"
aria-label="Auto Keyframe"
title="Auto Keyframe"
@click=${studio.toggleAutoKey}
></sts-button>
`}
</sts-toolbar-group>
<sts-toolbar-group class="timeline-nav">
<sts-button
@@ -110,9 +368,10 @@ export class AnimEditorElement extends LitElement {
@click=${this.prevKeyframe}
></sts-button>
<sts-button class="play"
icon="play"
icon=${this.playing ? "pause" : "play"}
aria-label="Play"
title="Play"
@click=${this.togglePlaying}
></sts-button>
<sts-button
icon="fwd-step"
@@ -129,32 +388,28 @@ export class AnimEditorElement extends LitElement {
</sts-toolbar-group>
</sts-toolbar>
<div class="tracklist" role="list">
<sts-tree class="track"
role="listitem"
name="Camera"
icon="video"
></sts-tree>
<sts-tree class="track"
role="listitem"
name="Camera Target"
icon="crosshairs"
></sts-tree>
${tracklist}
</div>
</div>
<div class="divider"
<div class="divider" style=${styleMap(timeline_css_variables)}
${drag({
move: this.onSidebarResize,
})}
></div>
<sts-anim-timeline class="timeline"
style=${styleMap(timeline_css_variables)}
.rangeStart=${bind(this, "timelineStart")}
.rangeEnd=${bind(this, "timelineEnd")}
.clipStart=${this.clipStart}
.clipEnd=${this.clipEnd}
.playHead=${bind(this, "playHead")}
.playHead=${this.playHead}
.keyframes=${bind(this, "keyframes")}
.track_ordered=${track_order_hashed}
.track_animation_target_ordered=${track_animation_target_order_hashed}
></sts-anim-timeline>
`;
}
`
};
};
@@ -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: "";
+201 -142
View File
@@ -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<string, number> = {};
/** Keyframe positions (in seconds) */
@property({ attribute: false }) keyframes: Record<string, studio.Keyframe> = {};
@property({ attribute: false }) track_ordered: Record<string, number> = {};
@property({ attribute: false }) track_animation_target_ordered: Record<string, number> = {};
@state() _selectedKeyframe: string | null = null;
/** The width (in px) of the timeline view */
@state() _renderWidth!: number;
/** The offset from 0ms of the left edge of the timeline view, in px */
@state() _offsetX = 0;
/** The vertical scroll offset (in px) */
@state() _offsetY = 0;
/**
* `true` when the timeline is being mouse-panned. Primarily useful for
* setting the cursor style to the grabby hand.
*/
@state() _panning = false;
@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<this>): 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")
}<!--<span class="frac">.${
frac.toString(10).padEnd(3, "0")
}</span>-->`;
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`
<div
class=${classMap({
wrapper: true,
panning: this._panning,
panning: this._panning || this._scrubbing,
})}
${drag({
button: 1,
start: this.#onDragStart,
move: this.#onDrag,
end: this.#onDragEnd,
start: this.onMidDragStart,
move: this.onMidDrag,
end: this.onMidDragEnd,
})}
tabindex="-1"
>
<div class="clip-region"
style=${styleMap({
left: `${this.#msToPx(this.clipStart)}px`,
right: `${this._renderWidth - this.#msToPx(this.clipEnd)}px`,
})}
></div>
<div class="timeline-nav-dragger"
${drag({
button: 0,
start: this.onDragStart,
move: this.onDrag,
end: this.onDragEnd,
})}
@click=${this.onSetPlayhead}></div>
${subdivs.map((_, idx) => {
const pxOffset = (idx - 1) * pxPerSubdiv + fracPxOffset;
const ms = (startingIdx + idx) * msPerSubdiv;
const msFrac = Math.abs(Math.round(ms) % 1000);
const strong = msFrac === 0;
${subdivisions.map(({ seconds, fraction, isMajor, isLabelled}, idx) => {
const secondsPercentage = fraction * 100;
return html`
<div
class=${classMap({
hash: true,
strong,
strong: isMajor,
})}
style=${styleMap({
left: `${pxOffset}px`
left: `${secondsPercentage}%`
})}
></div>
${strong ? html`
${isMajor || isLabelled ? html`
<span class="timestamp"
style=${styleMap({
left: `${pxOffset + 8}px`
left: `calc(${secondsPercentage}% + 8px)`
})}
>
${this.#timestamp(ms)}
${this.timestamp(seconds, isMajor)}
</span>
` : nothing}
`;
@@ -335,10 +390,10 @@ export class AnimTimelineElement extends LitElement {
<div class="playhead"
${drag({
move: this.#onPlayheadDrag
move: this.onPlayheadDrag
})}
style=${styleMap({
left: `${this.#msToPx(this.playHead)}px`
left: `${100 * (this.playHead - this.rangeStart) / range}%`
})}
tabindex="-1"
>
@@ -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`
<div id=${id}
class=${classMap({
keyframe: true,
selected: this._selectedKeyframe === id,
selected: this._selectedKeyframe === id
})}
style=${styleMap({
left: `${this.#msToPx(keyframe)}px`
left: `${100 * (keyframe.time - this.rangeStart) / range}%`,
gridRow: row + 2,
})}
tabindex="-1"
${drag({
move: event => this.#onKeyframeDrag(id, event),
move: event => this.onKeyframeDrag(id, event),
})}
@click=${() => this.selectKeyframe(id)}
></div>
`
`;
}
)}
</div>
`;
+6
View File
@@ -165,3 +165,9 @@ sts-button.active {
}
}
}
// TODO: Move this
.menu-button-icon {
font-size: 12px;
opacity: 0.75;
}
+77 -31
View File
@@ -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<ButtonElement>();
#mediaLibraryRef = createRef<DialogElement>();
#inspectorRef = createRef<HTMLDivElement>();
#hierarchyRef = createRef<SceneHierarchyElement>();
@@ -59,33 +71,44 @@ export class AppElement extends LitElement {
@on("scene-saved")
async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise<void> {
const file = new File([scene], "scene.scn.ron", { type: "text/ron" });
const formData = new FormData();
formData.append("uuid_idempotency_token", uuid());
formData.append("file", file);
try {
const response = await fetch("https://api.fakeyou.com/v1/engine/create_scene", {
method: "POST",
credentials: "include",
headers: { "Accept": "text/plain" },
body: formData,
});
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`
<sts-overlay-provider>
<sts-canvas-provider class="app-root">
<sts-scene-data-provider>
<sts-iframe-messaging-bridge></sts-iframe-messaging-bridge>
@@ -174,6 +199,16 @@ export class AppElement extends LitElement {
>
Save
</sts-button>
<sts-button
${ref(this.#addToSceneButtonRef)}
icon="plus-large"
>
Add to Scene
<sts-icon
class="menu-button-icon"
icon="chevron-down"
></sts-icon>
</sts-button>
` : nothing}
<sts-button
class=${classMap({ active: this.showHelp })}
@@ -187,7 +222,16 @@ export class AppElement extends LitElement {
<!-- Studio-mode-only elements -->
${this.#params.mode === StudioMode.Editor ? html`
<sts-blocking></sts-blocking>
<sts-add-to-scene-menu
.trigger=${this.#addToSceneButtonRef}
@open-media-library=${() => {
this.#mediaLibraryRef.value?.open();
}}
></sts-add-to-scene-menu>
<sts-dialog
${ref(this.#mediaLibraryRef)}
tag="sts-media-library-dialog"
></sts-dialog>
<sts-transform-toolbar class="v-start h-center"></sts-transform-toolbar>
@@ -276,5 +320,7 @@ export class AppElement extends LitElement {
` : nothing}
</sts-scene-data-provider>
</sts-canvas-provider>
</sts-overlay-provider>
`;
}
}
@@ -1,8 +0,0 @@
:host {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
position: absolute;
top: 60px;
left: 16px;
}
@@ -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`
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Cube);
}}>
Cube
</sts-button>
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Sphere);
}}>
Sphere
</sts-button>
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Cylinder);
}}>
Cylinder
</sts-button>
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Torus);
}}>
Torus
</sts-button>
`;
}
+4
View File
@@ -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");
}
+2 -1
View File
@@ -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"]
},
+6
View File
@@ -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]
})
],
});
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill-rule="evenodd" clip-rule="evenodd" d="M256 0C201.996 0 151.917 10.8937 114.452 29.6264C78.4224 47.6412 47 76.9689 47 116.5C47 116.667 47.0006 116.834 47.0017 117H47V394.5C47 434.031 78.4224 463.359 114.452 481.374C151.917 500.106 201.996 511 256 511C310.004 511 360.083 500.106 397.548 481.374C433.578 463.359 465 434.031 465 394.5V117H464.998C464.999 116.834 465 116.667 465 116.5C465 76.9689 433.578 47.6412 397.548 29.6264C360.083 10.8937 310.004 0 256 0ZM95 116.5C95 104.945 104.991 88.0224 135.918 72.5589C165.41 57.8131 207.832 48 256 48C304.168 48 346.59 57.8131 376.082 72.5589C407.009 88.0224 417 104.945 417 116.5C417 128.055 407.009 144.978 376.082 160.441C346.59 175.187 304.168 185 256 185C207.832 185 165.41 175.187 135.918 160.441C104.991 144.978 95 128.055 95 116.5ZM417 192.155C410.79 196.281 404.249 200.023 397.548 203.374C360.083 222.106 310.004 233 256 233C201.996 233 151.917 222.106 114.452 203.374C107.751 200.023 101.21 196.281 95 192.155V394.5C95 406.055 104.991 422.978 135.918 438.441C165.41 453.187 207.832 463 256 463C304.168 463 346.59 453.187 376.082 438.441C407.009 422.978 417 406.055 417 394.5V192.155Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs><style>.fa-secondary{opacity:.4}</style></defs>
<path class="fa-primary" fill-rule="evenodd" clip-rule="evenodd" d="M512 256C512 397.385 397.385 512 256 512C114.615 512 0 397.385 0 256C0 114.615 114.615 0 256 0C397.385 0 512 114.615 512 256ZM186.928 426.524C189.796 436.76 193.011 446.466 196.6 455.395C129.489 435.432 76.5679 382.511 56.6051 315.4C65.5341 318.989 75.24 322.204 85.4764 325.072C109.953 331.93 139.137 337.259 171.326 340.674C174.741 372.863 180.07 402.047 186.928 426.524ZM220.032 344.246C223.188 370.597 227.691 394.094 233.148 413.574C238.705 433.409 244.882 447.719 250.708 456.515C254.094 461.627 256.33 463.434 257.191 463.997C350.905 463.471 429.941 400.971 455.395 315.4C446.466 318.989 436.76 322.204 426.524 325.072C381.892 337.577 321.611 345 256 345C243.805 345 231.793 344.743 220.032 344.246ZM463.997 254.809C463.434 255.67 461.627 257.906 456.515 261.292C447.719 267.118 433.409 273.295 413.574 278.852C374.238 289.873 318.519 297 256 297C242.304 297 228.935 296.658 215.992 296.008C215.342 283.065 215 269.696 215 256C215 193.481 222.127 137.762 233.148 98.426C238.705 78.5909 244.882 64.2806 250.708 55.4854C254.094 50.3726 256.33 48.5664 257.191 48.0033C371.121 48.6421 463.358 140.879 463.997 254.809ZM48.0033 254.809C48.5664 255.67 50.3726 257.906 55.4854 261.292C64.2806 267.118 78.5909 273.295 98.426 278.852C117.906 284.31 141.403 288.812 167.754 291.968C167.256 280.207 167 268.195 167 256C167 190.389 174.423 130.108 186.928 85.4764C189.796 75.24 193.011 65.5341 196.6 56.6051C111.029 82.0589 48.5287 161.095 48.0033 254.809Z"/>
<g class="fa-secondary">
<path d="M315.4 56.6051C296.941 51.1141 277.407 48.1167 257.191 48.0034C256.924 48.1779 256.525 48.4721 256 48.9472C257.167 50.0045 258.956 51.958 261.292 55.4854C267.118 64.2806 273.295 78.591 278.852 98.4261C284.31 117.906 288.812 141.403 291.968 167.754C280.207 167.256 268.195 167 256 167C243.805 167 231.793 167.256 220.032 167.754C218.212 182.958 216.84 199.112 215.992 215.992C228.935 215.342 242.304 215 256 215C269.696 215 283.065 215.342 296.008 215.992C296.658 228.935 297 242.304 297 256C297 269.696 296.658 283.065 296.008 296.008C312.888 295.16 329.042 293.788 344.246 291.968C344.744 280.207 345 268.195 345 256C345 243.805 344.744 231.793 344.246 220.032C370.597 223.188 394.094 227.691 413.574 233.148C433.409 238.705 447.719 244.882 456.515 250.708C460.042 253.044 461.996 254.833 463.053 256C463.528 255.475 463.822 255.076 463.997 254.809C463.883 234.593 460.886 215.059 455.395 196.6C446.466 193.011 436.76 189.796 426.524 186.928C402.047 180.07 372.863 174.741 340.674 171.326C337.259 139.137 331.93 109.953 325.072 85.4764C322.204 75.24 318.989 65.5341 315.4 56.6051Z"/>
<path d="M340.674 340.674C325.08 342.329 308.779 343.534 291.968 344.246C288.812 370.597 284.31 394.094 278.852 413.574C273.295 433.409 267.118 447.719 261.292 456.515C258.956 460.042 257.167 461.996 256 463.053C256.525 463.528 256.924 463.822 257.191 463.997C277.407 463.883 296.941 460.886 315.4 455.395C318.989 446.466 322.204 436.76 325.072 426.524C331.93 402.047 337.259 372.863 340.674 340.674Z"/>
<path d="M167.754 220.032C168.466 203.221 169.671 186.921 171.326 171.326C139.137 174.741 109.953 180.07 85.4764 186.928C75.24 189.796 65.5341 193.011 56.6051 196.6C51.1141 215.059 48.1167 234.593 48.0034 254.809C48.1779 255.076 48.4721 255.475 48.9472 256C50.0045 254.833 51.958 253.044 55.4854 250.708C64.2806 244.882 78.591 238.705 98.4261 233.148C117.906 227.691 141.403 223.188 167.754 220.032Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill-rule="evenodd" clip-rule="evenodd" d="M414.075 374.952C451.433 350.649 464 324.197 464 306V206C464 187.706 451.322 160.985 413.919 136.446C377.543 112.58 322.985 95 256 95C189.015 95 134.457 112.58 98.0807 136.446C60.6776 160.985 48 187.706 48 206V306C48 324.197 60.5666 350.649 97.9251 374.952C134.288 398.608 188.89 416 256 416C323.11 416 377.712 398.608 414.075 374.952ZM256 464C406 464 512 386.5 512 306V206C512 125.5 406 47 256 47C106 47 0 125.5 0 206V306C0 386.5 106 464 256 464Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M322.721 287.56C303.166 297.338 279.509 302 256 302C232.491 302 208.834 297.338 189.279 287.56C172.595 279.218 144 258.812 144 222C144 185.188 172.595 164.782 189.279 156.44C208.834 146.662 232.491 142 256 142C279.509 142 303.166 146.662 322.721 156.44C339.405 164.782 368 185.188 368 222C368 258.812 339.405 279.218 322.721 287.56ZM256 254C291.346 254 320 239.673 320 222C320 204.327 291.346 190 256 190C220.654 190 192 204.327 192 222C192 239.673 220.654 254 256 254Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -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";
+1
View File
@@ -35,6 +35,7 @@
:host(.disabled:hover) {
background: #0003;
color: #FFF6;
cursor: default;
}
.icon {
+10 -4
View File
@@ -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<this>): void {
@@ -35,9 +37,13 @@ export class ButtonElement extends LitElement {
}
protected override render = () => html`
${this.icon
? html`<sts-icon icon=${this.icon} aria-hidden="true"></sts-icon>`
: nothing}
${this.icon ? html`
<sts-icon
part="icon"
icon=${this.icon}
aria-hidden="true"
></sts-icon>
` : nothing}
<slot></slot>
`
}
@@ -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;
}
}
@@ -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`
<slot
name="dialog-header"
@slotchange=${this.onDialogHeaderChanged}
>
<sts-dialog-header
part="header"
.icon=${this.icon}
>
${this.heading}
</sts-dialog-header>
</slot>
<section class="main" part="main">
<slot name="sidebar" part="sidebar"></slot>
<section class="content" part="content">
<slot></slot>
</section>
</section>
<slot name="footer"></slot>
`;
}
}
@@ -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;
}
}
@@ -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`
<div
class="title"
part="title"
>
${this.icon ? html`
<sts-icon
class="title__icon"
part="icon"
.icon=${this.icon}
></sts-icon>
` : nothing}
<span
class="title__heading"
part="heading"
>
<slot>Dialog</slot>
</span>
</div>
<slot name="extras"></slot>
<slot name="close">
${/*
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}
<sts-icon
class="close"
part="close"
icon="xmark"
@click=${this.onClose}
></sts-icon>
</slot>
`;
}
+399
View File
@@ -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`
* <sts-dialog-header>
* My Dialog
* </sts-dialog-header>
* <section class="main">
* <section class="content">
* <p>Hello, world!</p>
* </section>
* </section>
* `
* }
* ```
* ### Trigger-based implementation
* ```
* \@customElement("my-app")
* class MyApp extends LitElement {
* dialogTriggerRef = createRef<ButtonElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.dialogTriggerRef)}>
* Open Dialog
* </sts-button>
*
* <sts-dialog
* tag="my-dialog"
* .trigger=${this.dialogTriggerRef}
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
* ### Manual-open implementation
* ```
* \@customElement("my-app")
* class MyApp extends LitElement {
* dialogRef = createRef<DialogElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button
* \@click=${() => {
* this.dialogRef.value?.open();
* }}
* >
* Open Dialog
* </sts-button>
*
* <sts-dialog
* ${ref(this.dialogRef)}
* tag="my-dialog"
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
*/
@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<ButtonElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.dialogTriggerRef)}>
* Open Dialog
* </sts-button>
*
* <sts-dialog
* .trigger=${this.dialogTriggerRef}
* .template=${[this, html`
* <p>Hey folks here's my dialog, hope you like it!</p>
* `] as const}
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
*
* @example
*
* ### More complex custom content
* ```
* \@customElement("my-app")
* class MyApp extends LitElement {
* dialogTriggerRef = createRef<ButtonElement>();
*
* get dialogTemplate() {
* return html`
* <sts-dialog-header icon="cube">
* My Dialog
* </sts-dialog-header>
*
* <div slot="sidebar">
* <h4>Custom sidebar</h4>
* <ul>
* <li>Lorem ipsum</li>
* <li>Dolor</li>
* <li>Sit amet</li>
* </ul>
* </div>
*
* <p>These paragraphs will appear in the main content area.</p>
* <p>Lorem ipsum dolor sit amet.</p>
* <p>Consectetur adipiscing elit.</p>
*
* <footer
* slot="footer"
* style=${css`
* display: flex;
* justify-content: flex-end;
* padding: 1em;
* gap: 0.75em;
* `}
* >
* <sts-button icon="check">
* Confirm
* </sts-button>
* <sts-button icon="xmart">
* Cancel
* </sts-button>
* </footer>
* `
* }
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.dialogTriggerRef)}>
* Open Dialog
* </sts-button>
*
* <sts-dialog
* .trigger=${this.dialogTriggerRef}
* .template=${[this, this.dialogTemplate] as const}
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
*
* @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`
* <sts-dialog-header>
* Counter Dialog
* </sts-dialog-header>
*
* <section class="main">
* <secion class="content">
* <p>The count is ${this.count}.</p>
* <slot></slot>
* </secion>
* </section>
*
* <footer class="footer">
* <sts-button
* \@click=${() => this.dispatchEvent(new CustomEvent("increment"))}
* >
* ++
* </sts-button>
* </footer>
* `
* }
*
* \@customElement("my-app")
* class MyApp extends LitElement {
* dialogTriggerRef = createRef<ButtonElement>();
* dialogRef = createRef<DialogElement>();
*
* \@state() count = 0;
*
* onIncrement(): void {
* this.count++;
* }
*
* onClose(): void {
* this.dialogRef.value?.close();
* }
*
* get dialogTemplate() {
* return html`
* <my-counter-dialog
* .count=${this.count}
* \@increment=${this.onIncrement}
* \@close=${this.onClose}
* >
* ${this.count === 69 ? html`
* <p>(Nice.)</p>
* ` : nothing}
* </my-counter-dialog>
* `
* }
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.dialogTriggerRef)}>
* Open Dialog
* </sts-button>
*
* <sts-dialog
* ${ref(this.dialogRef)}
* .trigger=${this.dialogTriggerRef}
* .template=${[this, this.dialogTemplate] as const}
* skipWrapper
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
*
* @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
* `<sts-dialog-base role="dialog">` 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<Ref<HTMLElement> | HTMLElement> = [];
@inject(OVERLAY_PROVIDER)
_overlay!: OverlayProvider;
#overlayHandle?: WeakRef<OverlayHandle>;
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`
<sts-dialog-base
role=${ifDefined(this.dialogRole)}
@close=${() => this.close()}
>
${template}
</sts-dialog-base>
`,
{
containerStyles,
onBackdropClick,
},
);
} else {
this.#overlayHandle = this._overlay.attach(this, html`
<${unsafeStatic(this.tag!)}
role=${ifDefined(this.dialogRole)}
@close=${() => this.close()}
></${unsafeStatic(this.tag!)}>
`, {
containerStyles,
onBackdropClick,
});
}
}
close(): void {
if (this.#overlayHandle)
this._overlay.detach(this.#overlayHandle);
}
protected override updated(changes: PropertyValues<this>): 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();
}
}
+7
View File
@@ -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";
+1
View File
@@ -1 +1,2 @@
export * from "./text-field.element";
export * from "./scalar-field.element";
@@ -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;
}
}
@@ -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
* <!--
* The value of this field will be displayed with:
* - At least one digit before the decimal place
* - At least one and at most three digits after the decimal place
* - A degree symbol after the value, separated by a space
* -->
* <sts-scalar-field format="1.1-3 °"></sts-scalar-field>
*
* <!--
* The value will be displayed with exactly three digits after the decimal
* place. No digits will appear before the decimal if `-1 < value < 1`. No
* units will appear after the value.
* -->
* <sts-scalar-field format=".3"></sts-scalar-field>
* ```
*/
@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<HTMLSpanElement>();
#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<this>): 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<this>): 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 `<sts-quat-inspector>` -- 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<void> {
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`
<span ${ref(this.#inputRef)}
class="input"
part="input"
contenteditable
inputmode="numeric"
@input=${this.onInputChange}
@change=${this.onInputChange}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter")
this.onInputEnter(event);
}}
></span>
`}
`;
}
@@ -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;
}
}
+24 -6
View File
@@ -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`
<sts-icon part="icon-pre" .icon=${this.iconPre}></sts-icon>
` : nothing}
<input
class="input"
type="text"
part="input"
.type=${this.fieldType}
placeholder=${ifDefined(this.placeholder)}
.value=${this.value}
@input=${this.#onInput}
/>
${this.iconPost ? html`
<sts-icon part="icon-post" .icon=${this.iconPost}></sts-icon>
` : nothing}
`;
}
+35 -2
View File
@@ -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(.+?)>(.+?)<\/svg>/)!;
const [, attrsString, body] = svgSrc.match(/<svg(.+?)>(.+?)<\/svg>/s)!;
const attrs = Object.fromEntries(
Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g))
.map(([, attr, value]) => [attr, value])
+35
View File
@@ -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;
}
+213
View File
@@ -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 `<select />` style combo-box widget, as it
* does not implement the correct ARIA roles/behavior for that use case.
*
* @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-app")
* class MyApp extends LitElement {
* menuTriggerRef = createRef<ButtonElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.menuTriggerRef)}>
* Open Menu
* </sts-button>
*
* <sts-menu
* autoClose
* .trigger=${this.menuTriggerRef}
* >
* <sts-button role="menuitem">Lorem</sts-button>
* <sts-button role="menuitem">Ipsum</sts-button>
* <sts-button role="menuitem">Dolor</sts-button>
* <sts-button role="menuitem">Sit Amet</sts-button>
* <hr />
* <sts-button role="menuitem">Consectetur</sts-button>
* <sts-button role="menuitem">Adipiscing Elit</sts-button>
* </sts-menu>
* </sts-overlay-provider>
* `
* }
* ```
*/
@customElement("sts-menu")
export class MenuElement extends LitElement {
static override styles = css`
:host {
visibility: hidden;
}
`;
/**
* A {@linkcode Ref} of the button that should open this menu when pressed.
*/
@property({ attribute: false })
trigger = createRef<HTMLElement>();
/** Close the menu when one of its menuitems is pressed */
@property({ type: Boolean })
autoClose = false;
@inject(OVERLAY_PROVIDER)
_overlay!: OverlayProvider;
#menuId = uuid();
#slotRef = createRef<HTMLSlotElement>();
#panelRef = createRef<MenuPanelElement>();
#handle?: WeakRef<OverlayHandle>;
show(): void {
const trigger = this.trigger.value;
if (!trigger) return;
trigger.classList.toggle("active", true);
trigger.ariaExpanded = "true";
const rect = trigger.getBoundingClientRect();
this.#handle = this._overlay.attach(this, html`
<sts-menu-panel
${ref(this.#panelRef)}
id=${this.#menuId}
class=${ifDefined(this.className)}
style=${styleMap({
position: "absolute",
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
})}
?autoClose=${this.autoClose}
@hide=${this.hide}
>${
// Move the nodes assigned to the <sts-menu>'s slot into the
// <sts-menu-panel>'s slot
this.#slotRef.value?.assignedNodes()
}</sts-menu-panel>
`);
}
hide(): void {
const trigger = this.trigger.value;
if (trigger) {
trigger.classList.toggle("active", false);
trigger.ariaExpanded = "false";
}
if (this.#panelRef.value) {
// Move the nodes assigned to the <sts-menu-panel>'s slot back into the
// <sts-menu>'s slot
for (let node of this.#panelRef.value.getSlot()!.assignedNodes())
this.appendChild(node);
}
if (this.#handle)
this._overlay.detach(this.#handle);
this.#handle = undefined;
}
protected override update(changes: PropertyValues<this>): void {
super.update(changes);
if (this.#handle)
this._overlay.reRender(this.#handle);
}
protected override updated(changes: PropertyValues<this>): void {
if (changes.has("trigger")) {
const prevTrigger = changes.get("trigger");
if (prevTrigger?.value) {
prevTrigger.value.removeEventListener("click", this.#onTriggerClick);
}
const trigger = this.trigger.value;
if (!trigger) return;
trigger.setAttribute("aria-owns", this.#menuId);
trigger.ariaHasPopup = "true";
trigger.ariaExpanded = "false";
trigger.addEventListener("click", this.#onTriggerClick);
}
super.updated(changes);
}
override disconnectedCallback(): void {
this.trigger.value?.removeEventListener("click", this.#onTriggerClick);
super.disconnectedCallback();
}
#onTriggerClick = (event: Event): void => {
event.stopPropagation();
this.show();
}
protected override render = () => html`
<slot ${ref(this.#slotRef)}></slot>
`
}
// TODO: ARIA keyboard navigation: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
@customElement("sts-menu-panel")
export class MenuPanelElement extends LitElement {
static override styles = unsafeCSS(panelStyles);
@property({ reflect: true })
override role = "menu";
@property({ type: Boolean })
autoClose = false;
#slotRef = createRef<HTMLSlotElement>();
getSlot(): HTMLSlotElement | undefined { return this.#slotRef.value; }
@on("click")
onClick(event: PointerEvent): void {
event.stopPropagation();
if (
this.autoClose
&& event.target
&& event.target instanceof HTMLElement
&& event.target.role === "menuitem"
) {
this.dispatchEvent(new CustomEvent("hide"));
}
}
@on("window:click")
onWindowClick(event: PointerEvent): void {
if (!event.composedPath().includes(this))
this.dispatchEvent(new CustomEvent("hide"));
}
protected override render = () => html`
<slot ${ref(this.#slotRef)}></slot>
`
}
@@ -0,0 +1,4 @@
.overlay-stack {
isolation: isolate;
z-index: 99999;
}
@@ -0,0 +1,275 @@
import { UniqueToken, provide } from "@storyteller/framework";
import { LitElement, TemplateResult, html, render, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import { StyleInfo, styleMap } from "lit/directives/style-map.js";
import styles from "./overlay-provider.element.scss?inline";
export const OVERLAY_PROVIDER = UniqueToken.create<OverlayProvider>();
/**
* A low-level utility for rendering "overlay" elements to the DOM. Overlays are
* rendered in a separate [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context)
* which sits "on top" of the rest of the DOM, which is useful for creating
* things like modals and pop-up menus.
*
* To use this utility, first ensure that your entire application is wrapped
* with an `<sts-overlay-provider>` element, then inject the
* {@linkcode OVERLAY_PROVIDER} token to access this interface.
*
* @example
* ```
* class MyCustomOverlay extends LitElement {
* \@inject(OVERLAY_PROVIDER)
* _overlay?: OverlayProvider;
*
* #handle?: WeakRef<OverlayHandle>;
*
* openOverlay(): void {
* this.#handle = this._overlay.attach(this, html`
* <div>
* <p>This is a custom overlay!</p>
* <sts-button \@click=${this.closeOverlay}>
* Close Overlay
* </sts-button>
* </div>
* `);
* }
*
* closeOverlay(): void {
* if (this.#handle)
* this._overlay.detach(this.#handle);
*
* this.#handle = undefined;
* }
*
* render = () => html`
* <sts-button \@click=${this.openOverlay}>
* Open Overlay
* </sts-button>
* `
* }
* ```
*/
export interface OverlayProvider {
/**
* Attach a template to the overlay stack.
*
* @param host
* This is the instance that will be the receiver (i.e. the `this` argument)
* for any event-handlers that are bound in the provided `template`.
*
* @param template
* The Lit template to render as an overlay, usually created with Lit's
* `html` tagged template function.
*
* @param options
* Additional options to customize the overlay. See
* {@linkcode AttachOptions} for details.
*/
attach(
host: LitElement,
template: TemplateResult,
options?: AttachOptions,
): WeakRef<OverlayHandle>;
/**
* Detach a currently-attached overlay from the DOM.
*
* After invoking this method, the provided `handle` will no longer be valid
* and should be discarded by the caller.
*/
detach(handle: WeakRef<OverlayHandle>): void;
/** Re-render a currently-attached overlay. */
reRender(handle: WeakRef<OverlayHandle>): void;
}
export interface AttachOptions {
/** Custom styles for the container that will wrap the overlay template. */
containerStyles?: Readonly<StyleInfo>;
/** Handler to call when the overlay's backdrop is clicked. */
onBackdropClick?: (event: PointerEvent) => any;
}
/** An opaque handle to an attached overlay template. */
export interface OverlayHandle {
readonly id: number;
}
/**
* @internal
*
* A simple "handle" to an attached overlay instance. The `id` property tracks
* the current index of its associated parts in the `OverlayProviderElement`'s
* set of arrays.
*
* When entries are inserted, removed, or moved within the arrays, these handles
* must be updated with the correct indices by calling `_updateId`. This ensures
* that existing `OverlayHandle`s that have been passed out to consumers will
* remain valid for looking up their associated templates, overlay containers,
* etc.
*/
class OverlayHandleInternal implements OverlayHandle {
#id: number;
get id() { return this.#id; }
constructor (id: number) {
this.#id = id;
}
_updateId(id: number): void {
this.#id = id;
}
}
@customElement("sts-overlay-provider")
@provide(OVERLAY_PROVIDER)
export class OverlayProviderElement
extends LitElement
implements OverlayProvider
{
static override styles = unsafeCSS(styles);
/**
* Handles for each currently attached overlay instance. The `id` of these
* handles must always match their index within this array.
*/
#handles: OverlayHandleInternal[] = [];
/**
* Refs for the container elements of each currently attached overlay
* instance. The order and length of entries in this array must be kept in
* sync with their corresponding `#handles`.
*/
#containerRefs: Ref<HTMLElement>[] = [];
/**
* The Lit templates for the container elements of each currently attached
* overlay instance. The order and length of entries in this array must be
* kept in sync with their corresponding `#handles`.
*/
#containerTemplates: TemplateResult[] = [];
/**
* The Lit templates (and their hosts/event receivers) for each currently
* attached overlay instance. The order and length of entries in this array
* must be kept in sync with their corresponding `#handles`.
*/
#templates: [LitElement, TemplateResult][] = [];
attach(
host: LitElement,
template: TemplateResult,
options: AttachOptions = {},
): WeakRef<OverlayHandle> {
// Create and append a new handle for the overlay
const index = this.#handles.length;
const handle = new OverlayHandleInternal(index);
this.#handles.push(handle);
// Create and append the new container ref and template
const containerRef = createRef<HTMLElement>();
const containerStyles = options.containerStyles ?? {};
const handleBackdropClick = options.onBackdropClick;
const onBackdropClick = handleBackdropClick
? (event: PointerEvent) => {
if (event.target != null && event.target === containerRef.value)
handleBackdropClick(event);
}
: () => {}
this.#containerRefs.push(containerRef);
this.#containerTemplates.push(html`
<div
${ref(this.#containerRefs[index])}
slot="overlay"
style=${styleMap({
position: "fixed",
inset: 0,
...containerStyles,
})}
@click=${onBackdropClick}
></div>
`);
// Append the provided host and template
this.#templates.push([host, template]);
// (Re-)render the overlay stack and return the handle to the consumer
this.#updateLightDom();
return new WeakRef(handle);
}
detach(handle: WeakRef<OverlayHandle>): void {
// Find the index of the overlay to remove
const index = handle.deref()?.id;
if (index == null || index === -1) return;
// Remove the corresponding entry from each array
const [removedHandle] = this.#handles.splice(index, 1);
this.#containerRefs.splice(index, 1);
this.#containerTemplates.splice(index, 1);
this.#templates.splice(index, 1);
// Update the handle IDs to match their new indices
removedHandle._updateId(-1);
for (let i = index; i < this.#handles.length; ++i)
this.#handles[i]._updateId(i);
// Rerender the overlay stack
this.#updateLightDom();
}
// TODO: I'm not actually sure whether this serves a necessary function.
// Needs further testing.
reRender(handle: WeakRef<OverlayHandle>): void {
const index = handle.deref()?.id;
if (index == null || index === -1)
return;
const container = this.#containerRefs[index]?.value;
if (!container) return;
const tuple = this.#templates[index];
if (!tuple) return;
const [host, template] = tuple;
render(template, container, { host });
}
#updateLightDom(): void {
// First, render the set of container templates to this element's "light
// DOM" container (i.e. the publicly-accessible portion of the DOM which
// sits outside of the `#shadow-root`).
//
// Because each container template bears a `slot="overlay"` attribute,
// they should be automatically assigned to our shadow root's
// `slot[name="overlay"]`, which will position the containers in a
// separate stacking context above the other DOM nodes passed into the
// `<sts-overlay-provider>`.
//
// After this step (and _only_ after this step), the corresponding refs in
// the `#containerRefs` array should have valid, non-null `value`s.
render(this.#containerTemplates, this, { host: this });
for (let i = 0; i < this.#handles.length; ++i) {
// Render each overlay template into its corresponding container
const container = this.#containerRefs[i].value;
if (!container) throw new Error("Attempted to retrieve an invalid container ref!");
const [host, template] = this.#templates[i];
render(template, container, { host });
}
}
protected override render = () => html`
<slot></slot>
<div class="overlay-stack">
<slot name="overlay"></slot>
</div>
`;
}
+4
View File
@@ -1,8 +1,12 @@
export * from "./lib/storyteller-api";
export * from "./lib/add-to-scene-menu.element";
export * from "./lib/canvas-provider.element";
export * from "./lib/iframe-messaging-bridge.element";
export * from "./lib/inspector/entity-inspector.element";
export * from "./lib/inspector/inspector.types";
export * from "./lib/loading.element";
export * from "./lib/media-library-dialog.element";
export * from "./lib/scene-data-provider.element";
export * from "./lib/scene-hierarchy.element";
export * from "./lib/studio.element";
@@ -0,0 +1,65 @@
import * as studio from "@storyteller/studio";
import { BlockingCommand } from "@storyteller/studio";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef } from "lit/directives/ref.js"
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/menu";
@customElement("sts-add-to-scene-menu")
export class AddToSceneMenuElement extends LitElement {
@property({ attribute: false })
trigger = createRef<HTMLElement>();
protected override render = () => html`
<sts-menu autoClose .trigger=${this.trigger}>
<sts-button
role="menuitem"
icon="cube"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Cube);
}}
>
Cube
</sts-button>
<sts-button
role="menuitem"
icon="sphere"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Sphere);
}}
>
Sphere
</sts-button>
<sts-button
role="menuitem"
icon="torus"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Torus);
}}
>
Torus
</sts-button>
<sts-button
role="menuitem"
icon="cylinder"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Cylinder);
}}
>
Cylinder
</sts-button>
<hr />
<sts-button
role="menuitem"
icon="photo-film-music"
@click=${() => {
this.dispatchEvent(new CustomEvent("open-media-library"));
}}
>
Media library...
</sts-button>
</sts-menu>
`;
}
@@ -74,6 +74,13 @@ export class CanvasProviderElement
@on("keydown")
@on("keyup")
_pipeKeyboardEvents(event: KeyboardEvent): void {
if (event.repeat) return;
if (this.#canvas != null) {
// If we own the canvas, we're going to catch events emitting from the
// canvas and then repeat them, eventually causing a stack overflow
return;
}
// When the user interacts with one of the web UI elements, the Bevy
// canvas will lose focus, preventing it from receiving keyboard events.
// As a workaround, we can listen for any events that have bubbled up to
@@ -100,6 +107,52 @@ export class CanvasProviderElement
}));
}
@on("window:blur")
onWindowFocusLost(): void {
// If the browser window we're running in loses focus while certain keys
// or mouse buttons are held down, it can cause the input state to become
// "stuck," e.g. manipulating the camera, because Bevy is unable to detect
// when the bound key/mouse button is released.
//
// This is a hacky workaround -- we dispatch synthetic "keyup" and
// "pointerup" events to the canvas for each key and mouse button that may
// be bound to long-running input actions.
const canvas = document.getElementById(this.#canvasId);
if (!canvas) return;
const eventConfig = {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
bubbles: true,
cancelable: true,
composed: true,
};
const platform = sniffPlatform();
for (let key of ["Alt", "Control", "Shift"] as const) {
const map = KEYCODE_MAPS[key];
for (let { code, keyCode } of map[platform]) {
canvas.dispatchEvent(new KeyboardEvent("keyup", {
key,
code,
keyCode,
...eventConfig,
}));
}
}
for (let button of [0, 1, 2, 3, 4]) {
canvas.dispatchEvent(new PointerEvent("pointerup", {
button,
...eventConfig,
}));
}
}
protected override render = () => html`
<slot></slot>
<slot name="canvas"></slot>
@@ -122,3 +175,164 @@ declare global {
"sts-canvas-provider": CanvasProviderElement;
}
}
enum Platform {
Windows,
Apple,
Linux,
Android,
}
interface KeyCodes {
code: string;
keyCode: number;
}
/**
* Map of `KeyboardEvent.key` identifiers to their platform-specific `keyCode`s.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
*/
const KEYCODE_MAPS: {
[K in "Alt"|"Control"|"Shift"]: {
[P in Platform]: KeyCodes[];
}
} = {
"Alt": {
[Platform.Windows]: [{
code: "AltLeft",
keyCode: 0x12,
}, {
code: "AltLeft",
keyCode: 0xA4,
}, {
code: "AltRight",
keyCode: 0xA5,
}],
[Platform.Apple]: [{
code: "AltLeft",
keyCode: 0x3A,
}, {
code: "AltRight",
keyCode: 0x3D,
}],
[Platform.Linux]: [{
code: "AltLeft",
keyCode: 0xFFE9,
}, {
code: "AltRight",
keyCode: 0xFFEA,
}, {
code: "AltLeft",
keyCode: 0x01000023,
}],
[Platform.Android]: [{
code: "AltLeft",
keyCode: 57,
}, {
code: "AltRight",
keyCode: 58,
}],
},
"Control": {
[Platform.Windows]: [{
code: "ControlLeft",
keyCode: 0x11,
}, {
code: "ControlLeft",
keyCode: 0xA2,
}, {
code: "ControlRight",
keyCode: 0xA3,
}],
[Platform.Apple]: [{
code: "ControlLeft",
keyCode: 0x3B,
}, {
code: "ControlRight",
keyCode: 0x3E,
}, {
code: "MetaLeft",
keyCode: 0x37,
}, {
code: "MetaRight",
keyCode: 0x36,
}],
[Platform.Linux]: [{
code: "ControlLeft",
keyCode: 0xFFE3,
}, {
code: "ControlRight",
keyCode: 0xFFE4,
}, {
code: "ControlLeft",
keyCode: 0x01000021,
}],
[Platform.Android]: [{
code: "ControlLeft",
keyCode: 113,
}, {
code: "ControlRight",
keyCode: 114,
}]
},
"Shift": {
[Platform.Windows]: [{
code: "ShiftLeft",
keyCode: 0x10,
}, {
code: "ShiftLeft",
keyCode: 0xA0,
}, {
code: "ShiftRight",
keyCode: 0xA1,
}],
[Platform.Apple]: [{
code: "ShiftLeft",
keyCode: 0x38,
}, {
code: "ShiftRight",
keyCode: 0x3C,
}],
[Platform.Linux]: [{
code: "ShiftLeft",
keyCode: 0xFFE1,
}, {
code: "ShiftRight",
keyCode: 0xFFE2,
}, {
code: "ShiftLeft",
keyCode: 0x01000020,
}],
[Platform.Android]: [{
code: "ShiftLeft",
keyCode: 59,
}, {
code: "ShiftRight",
keyCode: 60,
}],
},
};
/**
* Try to determine the current {@linkcode Platform} by sniffing the User Agent
* string.
*
* @returns `Platform.Windows` if the actual platform could not be determined.
*/
function sniffPlatform(): Platform {
const userAgent = navigator.userAgent;
const platformString = userAgent.match(/\((.+?)\)/)?.[1] ?? "Windows";
if (platformString.includes("Windows"))
return Platform.Windows;
if (platformString.includes("Android"))
return Platform.Android;
if (platformString.includes("Mac OS"))
return Platform.Apple;
if (platformString.includes("Linux"))
return Platform.Linux;
return Platform.Windows;
}
@@ -1,11 +1,12 @@
import { inject } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
import { LitElement, type PropertyValues, html, unsafeCSS, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "../scene-data-provider.element";
import { type InspectorComponent } from "./inspector.types";
import { isLinear, type InspectorComponent } from "./inspector.types";
import "../scene-data-provider.element";
import "./material-inspector.element";
import "./name-inspector.element";
import "./transform-inspector.element";
@@ -40,17 +41,22 @@ export class EntityInspectorElement extends LitElement {
.sort((a, b) => {
const ordA = COMPONENT_SORT_ORDER.indexOf(a.name);
const ordB = COMPONENT_SORT_ORDER.indexOf(b.name);
if (ordA === -1) return Infinity;
if (ordB === -1) return -Infinity;
return ordA - ordB;
})
.map(({ name, value }) => {
switch (name) {
case "bevy_core::name::Name":
case "bevy_core::name::Name": {
return html`
<sts-name-inspector
.value=${value.name}
></sts-name-inspector>
`;
case "bevy_transform::components::transform::Transform":
}
case "bevy_transform::components::transform::Transform": {
return html`
<sts-transform-inspector
.translation=${value.translation}
@@ -58,11 +64,29 @@ export class EntityInspectorElement extends LitElement {
.scale=${value.scale}
></sts-transform-inspector>
`;
default:
}
case "bevy_pbr::pbr_material::StandardMaterial": {
const { colorSpace, baseColor } = isLinear(value.base_color)
? { colorSpace: "linear", baseColor: value.base_color.RgbaLinear }
: { colorSpace: "srgb", baseColor: value.base_color.Rgba };
return html`
<h3>Unknown Component: ${name}</h3>
<pre>${JSON.stringify({ name, value }, null, " ")}</pre>
<sts-material-inspector
.baseColor=${baseColor}
.colorSpace=${colorSpace}
.roughness=${value.perceptual_roughness}
.metallic=${value.metallic}
.reflectance=${value.reflectance}
></sts-material-inspector>
`;
}
default: {
return nothing;
// return html`
// <h3>Unknown Component: ${name}</h3>
// <pre>${JSON.stringify({ name, value }, null, " ")}</pre>
// `;
}
}
})
}
@@ -72,4 +96,5 @@ export class EntityInspectorElement extends LitElement {
const COMPONENT_SORT_ORDER: Array<InspectorComponent["name"]> = [
"bevy_core::name::Name",
"bevy_transform::components::transform::Transform",
"bevy_pbr::pbr_material::StandardMaterial",
];
@@ -1,6 +1,7 @@
export type InspectorComponent
= BevyReflect<"bevy_transform::components::transform::Transform", BevyTransform>
| BevyReflect<"bevy_core::name::Name", BevyName>
| BevyReflect<"bevy_pbr::pbr_material::StandardMaterial", StandardMaterial>
;
export interface BevyReflect<K extends string, T> {
@@ -31,3 +32,34 @@ export interface Vec3 {
y: number;
z: number;
}
export interface StandardMaterial {
base_color: BevyColor;
perceptual_roughness: number;
metallic: number;
reflectance: number;
}
export type BevyColor
= Color_RgbaLinear
| Color_Rgba
;
export interface Color_RgbaLinear {
RgbaLinear: Rgba;
}
export interface Color_Rgba {
Rgba: Rgba;
}
export interface Rgba {
red: number;
green: number;
blue: number;
alpha: number;
}
export function isLinear(color: BevyColor): color is Color_RgbaLinear {
return "RgbaLinear" in color;
}
@@ -0,0 +1,34 @@
@use "sass:math";
:host {
color-scheme: dark;
font-family: "Fira Sans", sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
}
h4 {
font-weight: 500;
margin: 0 0 1rem;
}
.fields {
display: grid;
grid-template-columns: min-content 1fr;
align-items: baseline;
row-gap: 0.25rem;
column-gap: 0.5rem;
}
label {
color: #FFF9;
font-size: math.div(14, 16) * 1rem;
margin-block-end: 0.333rem;
}
sts-text-field::part(input) {
flex: unset;
width: 100%;
}
@@ -0,0 +1,170 @@
import * as studio from "@storyteller/studio";
import { match } from "@storyteller/utility";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { v4 as uuid } from "uuid";
import { StandardMaterial, type Rgba } from "./inspector.types";
import "@storyteller/studio-ui/forms/scalar-field";
import styles from "./material-inspector.element.scss?inline";
@customElement("sts-material-inspector")
export class MaterialInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ attribute: false })
baseColor: Rgba = { red: 1, green: 1, blue: 1, alpha: 1 };
@property()
inputColorSpace: "linear"|"srgb" = "linear";
@property({ type: Number })
roughness = 0.5;
@property({ type: Number })
metallic = 0.0;
@property({ type: Number })
reflectance = 0.5;
#baseColorId = uuid();
#roughnessId = uuid();
#metallicId = uuid();
#reflectanceId = uuid();
onBaseColorInput(event: InputEvent): void {
const hex = (event.target as HTMLInputElement).value;
const value = match (this.inputColorSpace, {
"linear": () => RgbaLinear.fromHex(hex, this.baseColor.alpha),
"srgb": () => Srgba.fromHex(hex, this.baseColor.alpha),
});
studio.updateComponentField({
component: "bevy_pbr::pbr_material::StandardMaterial",
reflectPath: "base_color",
value,
});
}
updateField(reflectPath: Exclude<keyof StandardMaterial, "base_color">) {
return ({ detail: value }: CustomEvent<number>) => {
studio.updateComponentField({
component: "bevy_pbr::pbr_material::StandardMaterial",
reflectPath,
value,
});
}
}
protected override render() {
const [rgb, _] = match (this.inputColorSpace, {
"linear": () => RgbaLinear.toHex(this.baseColor),
"srgb": () => Srgba.toHex(this.baseColor),
});
return html`
<h4>Material</h4>
<div class="fields">
<label for=${this.#baseColorId}>
Tint
</label>
<input
id=${this.#baseColorId}
type="color"
.value=${rgb}
@input=${this.onBaseColorInput}
/>
<label for=${this.#roughnessId}>
Roughness
</label>
<sts-scalar-field
id=${this.#roughnessId}
.min=${0} .max=${1}
.value=${this.roughness}
@value-change=${this.updateField("perceptual_roughness")}
></sts-scalar-field>
<label for=${this.#metallicId}>
Metallic
</label>
<sts-scalar-field
id=${this.#metallicId}
.min=${0} .max=${1}
.value=${this.metallic}
@value-change=${this.updateField("metallic")}
></sts-scalar-field>
<label for=${this.#reflectanceId}>
Reflectance
</label>
<sts-scalar-field
id=${this.#reflectanceId}
.min=${0} .max=${1}
.value=${this.reflectance}
@value-change=${this.updateField("reflectance")}
></sts-scalar-field>
</div>
`;
}
}
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 };
}
}
@@ -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<this>): 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);
}
@@ -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);
}
}
}
@@ -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<this>): 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<number>) => {
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`
<sts-scalar-field
class="input"
aria-label="X"
format="1.1-2 °"
emitOn="change"
.step=${1}
.microStep=${0.1}
.value=${this._eulerX}
@value-change=${this.#emitUpdate(RotationAxis.X)}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
format="1.1-2 °"
emitOn="change"
.step=${1}
.microStep=${0.1}
.value=${this._eulerY}
@value-change=${this.#emitUpdate(RotationAxis.Y)}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
format="1.1-2 °"
emitOn="change"
.step=${1}
.microStep=${0.1}
.value=${this._eulerZ}
@value-change=${this.#emitUpdate(RotationAxis.Z)}
></sts-scalar-field>
`;
}
// 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;
}
@@ -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%;
}
@@ -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<number | Quat>) => {
console.log("value:", value);
studio.updateComponentField({
component: "bevy_transform::components::transform::Transform",
reflectPath,
value,
});
}
}
protected override render = () => html`
<!-- TODO -->
<h4>Transform</h4>
<fieldset>
<legend>Translation</legend>
<input type="number" value=${this.translation.x.toFixed(3)} />
<input type="number" value=${this.translation.y.toFixed(3)} />
<input type="number" value=${this.translation.z.toFixed(3)} />
</fieldset>
<fieldset>
<legend>Rotation</legend>
<input type="number" value=${this.rotation.x.toFixed(3)} />
<input type="number" value=${this.rotation.y.toFixed(3)} />
<input type="number" value=${this.rotation.z.toFixed(3)} />
<!-- <input type="number" value=${this.rotation.w.toFixed(3)} /> -->
</fieldset>
<fieldset>
<legend>Scale</legend>
<input type="number" value=${this.scale.x.toFixed(3)} />
<input type="number" value=${this.scale.y.toFixed(3)} />
<input type="number" value=${this.scale.z.toFixed(3)} />
</fieldset>
<div
class="fieldset"
role="group"
aria-labelledby=${this.#positionLabelId}
>
<legend id=${this.#positionLabelId}>
Position
</legend>
<sts-scalar-field
class="input"
aria-label="X"
.step=${0.01}
.microStep=${0.001}
.value=${this.translation.x}
@value-change=${this.updateField("translation.x")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
.step=${0.01}
.microStep=${0.001}
.value=${this.translation.y}
@value-change=${this.updateField("translation.y")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
.step=${0.01}
.microStep=${0.001}
.value=${this.translation.z}
@value-change=${this.updateField("translation.z")}
></sts-scalar-field>
</div>
<div
class="fieldset"
role="group"
aria-labelledby=${this.#rotationLabelId}
>
<legend id=${this.#rotationLabelId}>
Rotation
</legend>
<sts-quat-inspector
.value=${this.rotation}
@value-change=${this.updateField("rotation")}
></sts-quat-inspector>
</div>
<div
class="fieldset"
role="group"
aria-labelledby=${this.#scaleLabelId}
>
<legend id=${this.#scaleLabelId}>
Scale
</legend>
<sts-scalar-field
class="input"
aria-label="X"
.step=${0.1}
.microStep=${0.01}
.value=${this.scale.x}
@value-change=${this.updateField("scale.x")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Y"
.step=${0.1}
.microStep=${0.01}
.value=${this.scale.y}
@value-change=${this.updateField("scale.y")}
></sts-scalar-field>
<sts-scalar-field
class="input"
aria-label="Z"
.step=${0.1}
.microStep=${0.01}
.value=${this.scale.z}
@value-change=${this.updateField("scale.z")}
></sts-scalar-field>
</div>
`;
}
@@ -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;
}
}
}
@@ -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<MediaFile>[] = [];
@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<this>): 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<void> {
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`
<sts-dialog-header icon="import">
Import Asset
</sts-dialog-header>
<p>Lorem ipsum dolor sit amet</p>
`;
}
protected override render = (): TemplateResult => html`
<sts-dialog-header icon="photo-film-music">
${this.heading}
</sts-dialog-header>
<section class="main">
<nav class="sidebar" part="sidebar">
<ul class="sidebar__list">
${MEDIA_FILE_CATEGORIES.map(cat => html`
<li class="sidebar__listitem">
<sts-button
class=${classMap({
active: cat === this._activeCategory,
})}
.icon=${cat.icon}
title=${ifDefined(cat.tooltip)}
?disabled=${cat.disabled}
@click=${() => {
if (!cat.disabled)
this._activeCategory = cat;
}}
>
${cat.name}
</sts-button>
</li>
`)}
</ul>
</nav>
<section class="content">
<div class="content__ui">
<sts-button
class="btn-secondary"
icon="import"
data-import-trigger
>
Import...
</sts-button>
<sts-text-field
class="search-field"
type="search"
placeholder="Search"
icon-pre="search"
.value=${bind(this, "_searchTerm")}
></sts-text-field>
</div>
<div
class=${classMap({
"content__results": true,
"content__results--centered": this.#resultsStatus !== ResultsStatus.NonEmpty,
"content__results--grid": this.#resultsStatus === ResultsStatus.NonEmpty,
})}
@click=${this.clearSelection}
>
${match (this.#resultsStatus, {
[ResultsStatus.Loading]: () => html`
<span>Loading...</span>
`,
[ResultsStatus.Empty]: () => html`
<div class="placeholder">
<h2 class="heading">No results found</h2>
<p>Import a new asset to get started!</p>
<sts-button
class="btn-secondary"
icon="import"
data-import-trigger
>
Import...
</sts-button>
</div>
`,
[ResultsStatus.Error]: () => html`
<div class="error">
<h2 class="heading">Error</h2>
<p>${this._apiError!.message}</p>
<sts-button
class="btn-secondary"
icon="rotate"
@click=${this.loadResults}
>
Retry
</sts-button>
</div>
`,
[ResultsStatus.NonEmpty]: () => this.#mediaFiles!.map(file => html`
<div
class=${classMap({
"content__media-file": true,
"selected": this._selected.includes(file.token),
})}
@click=${this.onSelect(file.token)}
tabindex="0"
>
<span class="last-updated">
${this.#dateFmt.format(file.updated)}
</span>
<h4 class="name">
${file.displayName}
</h4>
</div>
`),
})}
</div>
${this._selected.length > 0 ? html`
<sts-button
class="btn-primary content__add-to-scene"
icon="plus-large"
>
Add to Scene
</sts-button>
` : nothing}
</section>
</section>
<sts-dialog
.trigger=${this.importTriggers}
.template=${[this, this.importDialogTemplate] as const}
></sts-dialog>
`;
}
interface MediaFileCategory {
name: string;
icon: string;
disabled?: boolean;
tooltip?: string;
predicate: Pred<MediaFile>;
}
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);
}
}
@@ -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) : [];
}
+436
View File
@@ -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<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"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<Session> {
const response = await this.#api.get<Session>("v1/session");
this.#validateResponse(response);
return response.data;
}
async listMediaFiles(options: ListMediaFilesOptions = {}): Promise<ListMediaFilesResult> {
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<ListMediaFilesResult>(endpoint, { params });
this.#validateResponse(response);
return response.data;
}
async uploadMediaFile(
file: File,
{ subtype, mediaClass }: UploadMediaFileOptions,
): Promise<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"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<MediaFile> {
const response = await this.#api.get<MediaFileResult>(`v1/media_files/file/${token}`);
this.#validateResponse(response);
return response.data.media_file;
}
#validateResponse<R extends AxiosResponse>(response: R): asserts response is OkResponse<R> {
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<T extends AxiosResponse = AxiosResponse> extends Error {
detail: T;
constructor (detail: T) {
super("API Error");
this.detail = detail;
}
}
type OkResponse<T extends AxiosResponse> = 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<MediaFileListItem, "maybe_creator">;
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",
}
+2 -17
View File
@@ -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;
+4
View File
@@ -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 = [
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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": {
+6 -5
View File
@@ -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,
+33
View File
@@ -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<string|null>}
*/
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);
});
}
-208
View File
@@ -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::<CameraKeyframe>()
.editor_registry::<CameraKeyframe>()
.register_type::<CameraTargetKeyframe>()
.editor_registry::<CameraTargetKeyframe>();
app.add_systems(Update, draw_camera_keyframes);
#[cfg(feature = "wasm")]
app.add_systems(
Update,
(
wasm::spawn_camera_keyframes,
wasm::notify_keyframe_changes.map(bevy::utils::error),
wasm::process_update_keyframe,
),
);
}
}
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
#[reflect(Component)]
pub struct CameraKeyframe(pub f32);
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
#[reflect(Component)]
pub struct CameraTargetKeyframe(pub f32);
fn draw_camera_keyframes(
mut gizmos: Gizmos,
r_seek_time: Res<GlobalSeekTime>,
q_cam_keyframe_xforms: Query<&GlobalTransform, With<CameraKeyframe>>,
q_cam_target_keyframe_xforms: Query<&GlobalTransform, With<CameraTargetKeyframe>>,
q_cam_keyframes: Query<(&CameraKeyframe, &GlobalTransform)>,
) {
if q_cam_keyframes.is_empty() {
return;
}
for world_xform in q_cam_keyframe_xforms
.iter()
.chain(q_cam_target_keyframe_xforms.iter())
{
let origin = world_xform.translation();
let x_axis = world_xform.right() * 0.25;
let y_axis = world_xform.up() * 0.25;
let z_axis = world_xform.back() * 0.25;
gizmos.line(origin, origin + x_axis, Color::RED);
gizmos.line(origin, origin + y_axis, Color::GREEN);
gizmos.line(origin, origin + z_axis, Color::BLUE);
}
let seek_time = **r_seek_time;
let keyframes_sorted = q_cam_keyframes
.iter()
.sorted_by(|(a, _), (b, _)| a.partial_cmp(&b.0).unwrap())
.collect_vec();
let (timestamp_first, xform_first) = keyframes_sorted.first().unwrap();
let (timestamp_last, xform_last) = keyframes_sorted.last().unwrap();
if seek_time <= timestamp_first.0 {
let (_, rot, pos) = xform_first.to_scale_rotation_translation();
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
} else if seek_time >= timestamp_last.0 {
let (_, rot, pos) = xform_last.to_scale_rotation_translation();
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
} else if let Some(((timestamp_a, xform_a), (timestamp_b, xform_b))) = keyframes_sorted
.iter()
.tuple_windows()
.find(|((a, _), (b, _))| a.0 <= seek_time && b.0 >= seek_time)
{
let t = seek_time.inv_lerp(timestamp_a.0, timestamp_b.0);
let (_, rot_a, pos_a) = xform_a.to_scale_rotation_translation();
let (_, rot_b, pos_b) = xform_b.to_scale_rotation_translation();
let pos = 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<f32> = WasmMessageBuffer::new();
static UPDATE_KEYFRAME: WasmMessageBuffer<(Entity, f32)> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = addCameraKeyframe)]
pub fn add_camera_keyframe(timestamp: f32) {
ADD_KEYFRAME.write_message(timestamp);
}
pub(super) fn spawn_camera_keyframes(
mut cmd: Commands,
mut ew_changes: EventWriter<space_undo::NewChange>,
q_camera: Query<(&CameraController, &GlobalTransform)>,
) {
if let Some(timestamp) = ADD_KEYFRAME.take_message() {
let Ok((cam_controller, world_xform)) = q_camera.get_single() else {
return;
};
let entity = cmd
.spawn((
Name::new("Camera Target Keyframe"),
TransformBundle::from_transform(Transform::from_translation(
cam_controller.focus,
)),
CameraTargetKeyframe(timestamp),
))
.id();
ew_changes.send(space_undo::NewChange {
change: Arc::new(AddedEntity { entity }),
});
let entity = cmd
.spawn((
Name::new("Camera Keyframe"),
TransformBundle::from_transform(Transform::from(*world_xform)),
CameraKeyframe(timestamp),
))
.id();
ew_changes.send(space_undo::NewChange {
change: Arc::new(AddedEntity { entity }),
});
}
}
#[wasm_bindgen(js_name = updateKeyframe)]
pub fn update_keyframe(entity: &str, timestamp: f32) {
let entity = Entity::from_str(entity).unwrap();
UPDATE_KEYFRAME.write_message((entity, timestamp));
}
pub(super) fn process_update_keyframe(
mut q_camera_keyframes: Query<&mut CameraKeyframe>,
mut q_camera_target_keyframes: Query<&mut CameraTargetKeyframe>,
) {
if let Some((ent, timestamp)) = UPDATE_KEYFRAME.take_message() {
if let Ok(mut keyframe) = q_camera_keyframes.get_mut(ent) {
**keyframe = timestamp;
} else if let Ok(mut keyframe) = q_camera_target_keyframes.get_mut(ent) {
**keyframe = timestamp;
} else {
warn!("No matching keyframe found for entity: {ent:?}");
}
}
}
#[wasm_bindgen(typescript_custom_section)]
const CAMERA_KEYFRAMES_EVENT: &str = r#"
export interface CameraKeyframesEvent extends CustomEvent {
type: "camera-keyframes";
detail: Record<string, number>;
}
declare global {
export interface GlobalEventHandlersEventMap {
"camera-keyframes": CameraKeyframesEvent;
}
}
"#;
pub(super) fn notify_keyframe_changes(
mut l_notified: Local<serde_json::Map<String, serde_json::Value>>,
q_keyframes: Query<(Entity, &CameraKeyframe)>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if l_notified.len() == q_keyframes.iter().count() {
return Ok(());
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
l_notified.clear();
for (ent, timestamp) in q_keyframes.iter() {
l_notified.insert(format!("{ent:?}"), timestamp.0.into());
}
wasm::dispatch_to_js(window, "camera-keyframes", &*l_notified)
}
}
+5 -33
View File
@@ -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::<InvBindPose>();
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<f32> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = seekTime)]
pub fn seek_time(timestamp: f32) {
SEEK_TIME.write_message(timestamp);
}
pub(super) fn update_global_seek_time(mut r_seek_time: ResMut<GlobalSeekTime>) {
if let Some(timestamp) = SEEK_TIME.take_message() {
**r_seek_time = timestamp;
}
}
}
+993
View File
@@ -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::<TimedAnimationClipAsset>()
.editor_registry::<TimedAnimationClipAsset>()
.register_type::<AnimationTarget>()
.editor_registry::<AnimationTarget>()
.register_type::<TransformKeyframe>()
.editor_registry::<TransformKeyframe>()
.register_type::<Keyframe>()
.editor_registry::<Keyframe>()
.register_type::<KeyframeOrder>()
.editor_registry::<KeyframeOrder>()
.register_type::<EaseType>()
.register_type::<uuid::Uuid>()
.add_event::<TimelineInteractionEvent>()
.add_event::<KeyframeInteractionEvent>()
.add_state::<KeyingMode>();
app.insert_resource(GlobalSeekTime {
time: 0.,
playing: false,
delta: None,
})
.add_event::<TweenCompleted>();
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::<Transform>,
),
);
// 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<f32>,
}
#[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<std::cmp::Ordering> {
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<AnimationClip>,
duration: f32,
speed: f32,
}
impl PartialOrd for TimedAnimationClipAsset {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
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<AnimationTarget> 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<Self, Self::Error> {
value.parse()
}
}
impl FromStr for AnimationTarget {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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<TimelineInteractionEvent>,
mut r_global_seek_time: ResMut<GlobalSeekTime>,
key_mode: Res<State<KeyingMode>>,
mut next_keying_mode: ResMut<NextState<KeyingMode>>,
) {
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<GlobalSeekTime>,
time: Res<Time>,
headless_settings: Option<Res<HeadlessSettings>>,
) {
if r_global_seek_time.playing {
let delta = match headless_settings {
Some(headless_settings) => headless_settings.frame_delta,
None => time.delta_seconds(),
};
r_global_seek_time.time += delta;
r_global_seek_time.delta = Some(delta);
}
}
fn process_keyframe_events(
mut commands: Commands,
mut e_timeline_event: EventReader<KeyframeInteractionEvent>,
r_global_seek_time: Res<GlobalSeekTime>,
q_animation_targets: Query<(Entity, &Transform, &AnimationTarget)>,
q_potential_targets: Query<Entity, (With<Transform>, Without<AnimationTarget>)>,
mut q_keyframes: Query<(Entity, &mut Keyframe)>,
) {
for event in e_timeline_event.read() {
info!("Processing Keyframe Event {event:?}");
match event {
KeyframeInteractionEvent::RecordKeyframe { target } => {
if let Ok((_target, transform, animation_target)) = q_animation_targets.get(*target)
{
let mut existing = None;
for (entity, keyframe) in q_keyframes.iter_mut() {
if keyframe.target == *animation_target
&& (keyframe.time - r_global_seek_time.time).abs()
< MIN_KEYFRAME_DISTANCE
{
existing = Some(entity);
break;
}
}
if let Some(existing) = existing {
commands.entity(existing).insert((
*transform,
TransformKeyframe::default(),
Keyframe {
time: r_global_seek_time.time,
target: *animation_target,
},
));
} else {
commands.spawn((
PrefabMarker,
SpatialBundle {
transform: *transform,
..Default::default()
},
Selectable,
Keyframe {
time: r_global_seek_time.time,
target: *animation_target,
},
TransformKeyframe::default(),
));
}
}
}
KeyframeInteractionEvent::MoveKeyframeTime { target, time } => {
if let Ok((_, mut keyframe)) = q_keyframes.get_mut(*target) {
keyframe.time = *time;
}
}
KeyframeInteractionEvent::AddEntityTrack { target } => {
if let Ok(target) = q_potential_targets.get(*target) {
info!("Adding Animation Target to Entity");
commands.entity(target).insert(AnimationTarget::new());
} else {
warn!("Can't add animation target to entity");
}
}
}
}
}
fn process_auto_key(
changed: Query<Entity, (With<AnimationTarget>, Changed<Transform>)>,
r_global_seek_time: Res<GlobalSeekTime>,
mut event: EventWriter<KeyframeInteractionEvent>,
) {
if r_global_seek_time.playing || r_global_seek_time.is_changed() {
info!("Skipping Auto Key");
return;
}
for entity in &changed {
info!("Setting Auto Key");
event.send(KeyframeInteractionEvent::RecordKeyframe { target: entity })
}
}
fn generate_transform_animation(
mut commands: Commands,
keyframes: Query<
(&Keyframe, &Transform, &TransformKeyframe, Entity),
Without<MarkedForDeletion>,
>,
valid_targets: Query<(
Entity,
&AnimationTarget,
Option<&Name>,
Option<&SceneElement>,
)>,
mut e_timeline_event: EventReader<TimelineInteractionEvent>,
) {
let mut playing = false;
for e in e_timeline_event.read() {
if matches!(
e,
TimelineInteractionEvent::Play | TimelineInteractionEvent::Seek(_)
) {
playing = true;
continue;
}
}
if !playing {
return;
}
info!("Generating Animation From Keyframes");
let available_targets = valid_targets
.iter()
.map(|(e, a, name, element)| (*a, (e, name, element)))
.collect::<HashMap<_, _>>();
let keyframes = keyframes
.iter()
.into_group_map_by(|(keyframe, _, _, _)| keyframe.target);
for (target, keyframes) in keyframes.into_iter() {
info!("Keyframes For Target {target:?}");
let Some(target) = available_targets.get(&target) else {
info!("No valid target.");
continue;
};
let (target, target_name, target_scene_element) = *target;
let keyframes = keyframes.iter();
let keyframes = keyframes.sorted_by(|a, b| a.0.time.total_cmp(&b.0.time));
let (_, _, _, translation, rotation, scale) = keyframes.fold(
(
Option::<(f32, Vec3)>::None,
Option::<(f32, Quat)>::None,
Option::<(f32, Vec3)>::None,
Vec::<Tween<Transform>>::new(),
Vec::<Tween<Transform>>::new(),
Vec::<Tween<Transform>>::new(),
),
|(
last_translation,
last_rotation,
last_scale,
mut translation_list,
mut rotation_list,
mut scale_list,
),
(keyframe, transform, transform_keyframe, _)| {
let time = keyframe.time;
let translation = transform.translation;
let rotation = transform.rotation;
let scale = transform.scale;
if transform_keyframe.affect_translation {
let (last_time, start) = match last_translation {
Some(v) => v,
None => (0., translation),
};
if (time - last_time).abs() > 0.00001 {
translation_list.push(Tween::new(
EaseMethod::Linear,
Duration::from_secs_f32((time - last_time).max(0.00001)),
TransformPositionLens {
start,
end: translation,
},
));
}
}
if transform_keyframe.affect_rotation {
let (last_time, start) = match last_rotation {
Some(v) => v,
None => (0., rotation),
};
if (time - last_time).abs() > 0.00001 {
rotation_list.push(Tween::new(
EaseMethod::Linear,
Duration::from_secs_f32((time - last_time).max(0.00001)),
TransformRotationLens {
start,
end: rotation,
},
));
}
}
if transform_keyframe.affect_scale {
let (last_time, start) = match last_scale {
Some(v) => v,
None => (0., scale),
};
if (time - last_time).abs() > 0.00001 {
scale_list.push(Tween::new(
EaseMethod::Linear,
Duration::from_secs_f32((time - last_time).max(0.00001)),
TransformScaleLens { start, end: scale },
));
}
}
let next_translation = if transform_keyframe.affect_translation {
Some((time, translation))
} else {
last_translation
};
let next_rotation = if transform_keyframe.affect_rotation {
Some((time, rotation))
} else {
last_rotation
};
let next_scale = if transform_keyframe.affect_scale {
Some((time, scale))
} else {
last_scale
};
(
next_translation,
next_rotation,
next_scale,
translation_list,
rotation_list,
scale_list,
)
},
);
let mut tracks = Vec::with_capacity(3);
if !translation.is_empty() {
tracks.push(Sequence::new(translation));
} else {
info!("No translation");
}
if !rotation.is_empty() {
tracks.push(Sequence::new(rotation));
} else {
info!("No rotation");
}
if !scale.is_empty() {
tracks.push(Sequence::new(scale));
} else {
info!("No scale");
}
if tracks.is_empty() {
info!("Empty Animation Track for {target_name:?} {target_scene_element:?} {target:?}");
commands.entity(target).remove::<Animator<Transform>>();
} else {
info!("Adding Animation Track for {target_name:?} {target_scene_element:?} {target:?}");
let tracks = Tracks::new(tracks);
commands.entity(target).insert(Animator::new(tracks));
}
}
}
struct ComponentTarget<'a, T: Component>(&'a mut T);
impl<'a, T: Component> ComponentTarget<'a, T> {
fn new(target: Mut<'a, T>) -> Self {
Self(target.into_inner())
}
}
impl<'a, T: Component> Targetable<T> for ComponentTarget<'a, T> {
fn target_mut(&mut self) -> &mut T {
self.0
}
}
pub fn timeline_bound_component_animator_system<T: Component>(
time: Res<GlobalSeekTime>,
mut query: Query<(Entity, &mut T, &mut Animator<T>)>,
events: ResMut<Events<TweenCompleted>>,
) {
if time.is_changed() && time.time >= 0.0 {
let mut events: Mut<Events<TweenCompleted>> = events.into();
for (entity, target, mut animator) in query.iter_mut() {
if animator.tweenable().duration() <= Duration::from_secs_f32(0.0001) {
continue;
}
let mut target = ComponentTarget::new(target);
if let Some(delta) = time.delta {
animator.tweenable_mut().tick(
Duration::from_secs_f32(delta),
&mut target,
entity,
&mut events,
);
} else {
let tween = animator.tweenable_mut();
tween.set_elapsed(Duration::from_secs_f32(time.time.max(0.)));
tween.tick(Duration::ZERO, &mut target, entity, &mut events);
}
}
}
}
#[cfg(feature = "wasm")]
mod wasm {
use bevy::{prelude::*, utils::hashbrown::HashMap, window::PrimaryWindow};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use crate::{
interaction::MarkedForDeletion,
scene::SceneElement,
wasm::{FromStr, StaticBuffer, WasmMessageBuffer},
};
use super::{
AnimationTarget, EaseType, GlobalSeekTime, Keyframe, KeyframeInteractionEvent, KeyingMode,
TimedAnimationClipAsset, TimelineInteractionEvent, TransformKeyframe,
};
static SEEK_TIME: WasmMessageBuffer<TimelineInteractionEvent> = WasmMessageBuffer::new();
static KEYFRAME_EVENT: WasmMessageBuffer<Vec<KeyframeInteractionEvent>> =
WasmMessageBuffer::new();
#[wasm_bindgen(js_name = seekTime)]
pub fn seek_time(timestamp: f32) {
SEEK_TIME.write_message(TimelineInteractionEvent::Seek(timestamp));
}
#[wasm_bindgen(js_name = play)]
pub fn play() {
SEEK_TIME.write_message(TimelineInteractionEvent::Play);
}
#[wasm_bindgen(js_name = pause)]
pub fn pause() {
SEEK_TIME.write_message(TimelineInteractionEvent::Pause);
}
#[wasm_bindgen(js_name = toggleAutoKey)]
pub fn toggle_auto_key() {
SEEK_TIME.write_message(TimelineInteractionEvent::ToggleAutoKey);
}
#[wasm_bindgen(js_name = recordKeyframe)]
pub fn record_keyframe(entities: Vec<String>) {
KEYFRAME_EVENT.write_message(
entities
.iter()
.filter_map(|entity| Entity::from_str(entity).ok())
.map(|target| KeyframeInteractionEvent::RecordKeyframe { target })
.collect(),
);
}
#[wasm_bindgen(js_name = moveKeyframeTime)]
pub fn move_keyframe_time(entity: String, time: f32) {
let Ok(target) = Entity::from_str(&entity) else {
return;
};
KEYFRAME_EVENT.write_message(vec![KeyframeInteractionEvent::MoveKeyframeTime {
target,
time,
}]);
}
#[wasm_bindgen(js_name = addEntityTrack)]
pub fn add_entity_track(entity: String) {
let Ok(target) = Entity::from_str(&entity) else {
return;
};
KEYFRAME_EVENT.write_message(vec![KeyframeInteractionEvent::AddEntityTrack { target }]);
}
pub(super) fn update_global_seek_time(
mut e_timeline_event: EventWriter<TimelineInteractionEvent>,
) {
if let Some(timestamp) = SEEK_TIME.take_message() {
info!("Updating Seek Time {timestamp:?}");
e_timeline_event.send(timestamp);
}
}
pub(super) fn update_keyframe_interaction(
mut e_keyframe_event: EventWriter<KeyframeInteractionEvent>,
) {
if let Some(keyframes) = KEYFRAME_EVENT.take_message() {
for keyframe in keyframes.into_iter() {
e_keyframe_event.send(keyframe);
}
}
}
#[wasm_bindgen(typescript_custom_section)]
const TIMELINE_TYPES: &str = r#"
export interface SeekTimeChanged extends CustomEvent {
type: "seek-time-changed";
detail: {
time: number,
playing: boolean
};
}
export interface UpdateAnimationTargets extends CustomEvent {
type: "update-animation-targets",
detail: {
added: Record<string, { name?: string, target_type: SceneElement, animation_target: AnimationTarget } >,
removed: string[]
}
}
export interface TransformKeyframeInfo {
target: AnimationTarget,
time: number,
entity: string,
incoming_easing: "linear" | "cubic-hermite",
affect_translation: bool,
affect_rotation: bool,
affect_scale: bool
}
export interface TimedAnimationClipInfo {
target: AnimationTarget,
time: number,
entity: string,
type: 'animation-clip',
clip: string,
duration: number,
speed: number
}
export type Keyframe = TransformKeyframeInfo | TimedAnimationClipInfo;
export type AnimationTarget = string;
export interface UpdateKeyframes extends CustomEvent {
type: "update-keyframes",
detail: {
added: Record<string, Keyframe>,
removed: string[]
}
}
export interface UpdateKeyingMode extends CustomEvent {
type: "keying-mode-changed",
detail: {
automatic: boolean
}
}
declare global {
export interface GlobalEventHandlersEventMap {
"seek-time-changed": CameraKeyframesEvent;
"update-animation-targets": UpdateAnimationTargets;
"update-keyframes": UpdateKeyframes;
}
}
"#;
#[derive(Serialize, Deserialize)]
struct AddedAnimationTargetRecordItem {
name: Option<String>,
target_type: SceneElement,
animation_target: AnimationTarget,
}
#[derive(Serialize, Deserialize)]
struct UpdatedAnimationTargetsEvent {
added: HashMap<String, AddedAnimationTargetRecordItem>,
removed: Vec<String>,
}
#[derive(Serialize, Deserialize)]
struct UpdateKeyingMode {
automatic: bool,
}
#[derive(Serialize, Deserialize)]
struct TimedAnimationClipInfo {
entity: String,
target: AnimationTarget,
time: f32,
clip: String,
duration: f32,
speed: f32,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TransformKeframeInfo {
entity: String,
target: AnimationTarget,
time: f32,
incoming_easing: EaseType,
affect_translation: bool,
affect_rotation: bool,
affect_scale: bool,
}
#[derive(Serialize, Deserialize, Debug)]
struct UpdateKeyframes {
added: HashMap<String, serde_json::Value>,
removed: Vec<String>,
}
pub(super) fn notify_updated_animation_targets(
q_window: Query<&Window, With<PrimaryWindow>>,
q_cameras: Query<
(Entity, Option<&Name>, &AnimationTarget),
(Added<AnimationTarget>, With<Camera>),
>,
q_scene_elements: Query<
(Entity, Option<&Name>, &SceneElement, &AnimationTarget),
(Added<AnimationTarget>, Without<Camera>),
>,
q_transforms: Query<
(Entity, Option<&Name>, &AnimationTarget),
(
Added<AnimationTarget>,
With<Transform>,
Without<Camera>,
Without<SceneElement>,
),
>,
mut removed: bevy::prelude::RemovedComponents<AnimationTarget>,
) -> Result<(), String> {
let Ok(window) = q_window.get_single() else {
return Ok(());
};
let added = q_transforms
.iter()
.map(|(entity, name, target)| {
(
format!("{entity:?}"),
AddedAnimationTargetRecordItem {
name: name.map(|v| v.to_string()),
target_type: SceneElement::Generic,
animation_target: *target,
},
)
})
.chain(q_cameras.iter().map(|(entity, name, target)| {
(
format!("{entity:?}"),
AddedAnimationTargetRecordItem {
name: name.map(|v| v.to_string()),
target_type: SceneElement::Camera,
animation_target: *target,
},
)
}))
.chain(
q_scene_elements
.iter()
.map(|(entity, name, element, target)| {
(
format!("{entity:?}"),
AddedAnimationTargetRecordItem {
name: name.map(|v| v.to_string()),
target_type: *element,
animation_target: *target,
},
)
}),
)
.collect::<HashMap<_, _>>();
let removed = removed.read().map(|v| format!("{v:?}")).collect::<Vec<_>>();
if added.is_empty() && removed.is_empty() {
return Ok(());
}
let event = UpdatedAnimationTargetsEvent { added, removed };
crate::wasm::dispatch_to_js(
window,
"update-animation-targets",
&serde_json::to_value(&event).map_err(|e| format!("{e}"))?,
)
}
pub(super) fn notify_keyframe_changes(
q_window: Query<&Window, With<PrimaryWindow>>,
q_transform_keyframes: Query<
(Entity, &Keyframe, &TransformKeyframe),
Or<(Changed<Keyframe>, Changed<TransformKeyframe>)>,
>,
q_clips: Query<
(Entity, &Keyframe, &TimedAnimationClipAsset),
Or<(Changed<Keyframe>, Changed<TimedAnimationClipAsset>)>,
>,
removed: Query<Entity, With<MarkedForDeletion>>,
) -> Result<(), String> {
let Ok(window) = q_window.get_single() else {
return Ok(());
};
let added = q_transform_keyframes
.iter()
.map(|(entity, keyframe, transform_keyframe)| {
match serde_json::to_value(TransformKeframeInfo {
entity: format!("{entity:?}"),
target: keyframe.target,
time: keyframe.time,
incoming_easing: transform_keyframe.incoming_easing,
affect_translation: transform_keyframe.affect_translation,
affect_rotation: transform_keyframe.affect_rotation,
affect_scale: transform_keyframe.affect_scale,
})
.map_err(|e| format!("{e}"))
{
Ok(value) => Ok((format!("{entity:?}"), value)),
Err(e) => Err(e),
}
})
.chain(q_clips.iter().map(|(entity, keyframe, clip)| {
match serde_json::to_value(TimedAnimationClipInfo {
entity: format!("{entity:?}"),
target: keyframe.target,
time: keyframe.time,
clip: clip.clip.id().to_string(),
duration: clip.duration,
speed: clip.speed,
})
.map_err(|e| format!("{e}"))
{
Ok(value) => Ok((format!("{entity:?}"), value)),
Err(e) => Err(e),
}
}))
.collect::<Result<HashMap<_, _>, String>>()?;
let removed = removed.iter().map(|v| format!("{v:?}")).collect::<Vec<_>>();
if added.is_empty() && removed.is_empty() {
return Ok(());
}
let event = UpdateKeyframes { added, removed };
info!("Updating keyframes: {event:?}");
crate::wasm::dispatch_to_js(
window,
"update-keyframes",
&serde_json::to_value(&event).map_err(|e| format!("{e}"))?,
)
}
pub(super) fn notify_seek_time_changes(
r_global_seek_time: Res<GlobalSeekTime>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if !r_global_seek_time.is_changed() {
return Ok(());
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
info!("Current Seek Time: {r_global_seek_time:?}");
crate::wasm::dispatch_to_js(
window,
"seek-time-changed",
&serde_json::to_value(r_global_seek_time.as_ref()).map_err(|e| format!("{e}"))?,
)
}
pub(super) fn notify_keying_mode_changed(
r_keying_mode: Res<State<KeyingMode>>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if !r_keying_mode.is_changed() {
return Ok(());
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
crate::wasm::dispatch_to_js(
window,
"keying-mode-changed",
&serde_json::to_value(UpdateKeyingMode {
automatic: match r_keying_mode.get() {
KeyingMode::Manual => false,
KeyingMode::Auto => true,
},
})
.map_err(|e| format!("{e}"))?,
)
}
}
+11 -6
View File
@@ -6,8 +6,8 @@ use bevy::{
};
use crate::{
input::{Action, CameraMotion, InputAction, Lifecycle, Selection},
interaction::Selected,
input::{self, Action, CameraMotion, InputAction, Lifecycle},
interaction::Selection,
math::{
Easing, InterpPosition, InterpVelocity, Interpolation, InterpolationSystem, NearlyEq,
NearlyZero, Velocity,
@@ -155,20 +155,25 @@ fn process_physical_inputs(
fn process_focus_inputs(
mut cmd: Commands,
r_selection: Option<Res<Selection>>,
mut input: EventReader<InputAction>,
mut q_camera: Query<(Entity, &CameraController, &mut Velocity)>,
q_interp_vel: Query<&InterpVelocity>,
mut q_interp_pos: Query<&mut InterpPosition>,
q_selected_xform: Query<&GlobalTransform, With<Selected>>,
q_global_xforms: Query<&GlobalTransform>,
) {
let Some(selection) = r_selection else {
return;
};
if input.read().any(|action| {
matches!(
action,
InputAction(Lifecycle::Stop, Action::Selection(Selection::Focus))
InputAction(Lifecycle::Stop, Action::Selection(input::Selection::Focus))
)
}) {
let Ok(target) = q_selected_xform
.get_single()
let Ok(target) = q_global_xforms
.get(**selection)
.map(|xform| xform.translation())
else {
return;
+48 -2
View File
@@ -5,15 +5,17 @@ use bevy::{
core_pipeline::Skybox,
prelude::*,
};
use bevy_web_asset::WebAssetPlugin;
use space_editor::prelude::{EditorEvent, EditorPrefabPath};
use wgpu::Extent3d;
use crate::{
anim::timeline_animation::{Keyframe, TimelineInteractionEvent},
bvh::{BvhAnimCompleteEvent, TestBvh},
scene::{SceneState, SkyboxHolder},
screenshot::{ScreenCapture, ScreenShotPlugin},
BvhPlugin, CommonPlugins, LoadLibraryObjectEvent, LoadSceneEvent, MainCamera,
MixamoImportEvent, MixamoPlugin, SkyboxId, StudioMode, WorldGridPlugin,
MixamoImportEvent, MixamoPlugin, RemoteAssetPlugin, SkyboxId, StudioMode, WorldGridPlugin,
};
#[derive(Debug, Resource, Clone)]
@@ -24,6 +26,14 @@ pub enum HeadlessMode {
None,
}
#[derive(Resource)]
pub enum CameraMotion {
Orbit(f32),
Pan(f32),
Zoom(f32),
Static,
}
pub fn start_headless(
mode: HeadlessMode,
fps: u32,
@@ -39,10 +49,14 @@ pub fn start_headless(
max_frames: frames_to_capture,
output_dims: image_res,
output_dir: outout_dir.to_string(),
fps: fps as f32,
frame_delta: 1.0 / (fps as f32),
});
app.add_plugins((
ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(1.0 / fps as f64)),
WebAssetPlugin,
RemoteAssetPlugin,
DefaultPlugins.set(WindowPlugin {
primary_window: None,
exit_condition: bevy::window::ExitCondition::DontExit,
@@ -84,7 +98,8 @@ pub fn start_headless(
ew.send(EditorEvent::Load(EditorPrefabPath::File(scene.clone())));
},
);
)
.add_systems(OnEnter(SceneState::Active), start_timeline_animation);
}
HeadlessMode::Mixamo(mixamo_path, skybox) => {
app.add_plugins(MixamoPlugin);
@@ -124,6 +139,8 @@ pub struct HeadlessSettings {
pub output_dims: Extent3d,
pub output_dir: String,
pub max_frames: u32,
pub fps: f32,
pub frame_delta: f32,
}
impl Default for HeadlessSettings {
@@ -136,6 +153,8 @@ impl Default for HeadlessSettings {
},
output_dir: "./screen_shots/".to_string(),
max_frames: u32::MAX,
fps: 24.0,
frame_delta: 1.0 / 24.0,
}
}
}
@@ -176,3 +195,30 @@ fn check_for_mixamo_capture_complete(
}
}
}
fn start_timeline_animation(
mut headless_settings: ResMut<HeadlessSettings>,
keyframes: Query<&Keyframe>,
camera: Query<Entity, With<MainCamera>>,
mut commands: Commands,
mut ev: EventWriter<TimelineInteractionEvent>,
) {
if keyframes.is_empty() {
return;
}
commands.remove_resource::<CameraMotion>();
let total_time = keyframes
.iter()
.fold(0f32, |previous, keyframe| previous.max(keyframe.time))
.max(3.0);
let frames = (total_time * headless_settings.fps).ceil() as u32;
headless_settings.max_frames = frames;
ev.send(TimelineInteractionEvent::Play);
let camera = camera.single();
commands.entity(camera).insert(
ScreenCapture::from_out(&headless_settings.output_dir)
//Part of quick hack, see screenshot.rs#261
.with_max_frames(headless_settings.max_frames + 3),
);
}
+1 -3
View File
@@ -35,9 +35,7 @@ pub(crate) fn nearest_common_ancestor(
}
// Find the lowest depth of all the entities
let Some(lowest_depth) = depths.values().copied().reduce(usize::min) else {
return None;
};
let lowest_depth = depths.values().copied().reduce(usize::min)?;
// Traverse up the tree from each entity until we find each one's ancestor
// at the level of lowest_depth
+67 -6
View File
@@ -39,27 +39,88 @@ impl Plugin for StudioInputPlugin {
.after(bevy::input::InputSystem)
.after(read_mouse_delta),
);
app.add_systems(
PostUpdate,
(apply_deferred, log_input_actions).after(StudioInputSystem),
);
}
}
#[derive(Event, Clone, Copy)]
/// Logs all `InputAction`s emitted, along with the input state at the time they
/// were fired, with `debug` verbosity.
fn log_input_actions(
ri_keyboard: Res<Input<KeyCode>>,
ri_mouse_buttons: Res<Input<MouseButton>>,
mut er_input_actions: EventReader<InputAction>,
) {
if er_input_actions.is_empty() {
return;
}
let lmb = if ri_mouse_buttons.pressed(MouseButton::Left) {
'X'
} else {
'-'
};
let mmb = if ri_mouse_buttons.pressed(MouseButton::Middle) {
'X'
} else {
'-'
};
let rmb = if ri_mouse_buttons.pressed(MouseButton::Right) {
'X'
} else {
'-'
};
use KeyCode::*;
let mod_keys = [AltLeft, AltRight, ShiftLeft, ControlLeft, ControlRight]
.into_iter()
.collect::<HashSet<_>>();
let action_keys = [W, A, S, D, E, Q, Escape, Grave, Delete, F]
.into_iter()
.collect::<HashSet<_>>();
let keys_pressed = ri_keyboard.get_pressed().copied().collect::<HashSet<_>>();
let keys_display = keys_pressed
.intersection(&mod_keys)
.map(|key| format!("{key:?}"))
.chain(
keys_pressed
.intersection(&action_keys)
.map(|key| format!("{key:?}")),
)
.collect::<Vec<_>>()
.join("+");
for action in er_input_actions.read() {
debug!(
"Mouse: [{lmb}{mmb}{rmb}] \
Keyboard: [{keys_display}] \
Action: [{action:.2?}]"
);
}
}
#[derive(Event, Clone, Copy, Debug)]
pub struct InputAction(pub Lifecycle, pub Action);
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
pub enum Lifecycle {
Start,
Ongoing,
Stop,
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
pub enum Action {
Camera(CameraMotion),
Selection(Selection),
ToggleDebug,
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
pub enum CameraMotion {
Translate(Vec3),
Pan(Vec2),
@@ -73,7 +134,7 @@ impl From<CameraMotion> for Action {
}
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
pub enum Selection {
/// Clear the current selection
Clear,
@@ -212,7 +273,7 @@ fn translate_dcc_camera_events(
CameraMotion::Rotate(Vec2::ZERO).into(),
));
}
// Alt Camera Rotation (designed for laptop)
// Alternative Camera Rotation (designed for laptop)
else if keyboard.pressed(KeyCode::ShiftLeft) {
let lifecycle = if keyboard.just_pressed(KeyCode::ShiftLeft) {
Lifecycle::Start
+100 -72
View File
@@ -27,6 +27,7 @@ impl Plugin for SelectionPlugin {
PostUpdate,
draw_selection_outline.run_if(resource_changed_or_removed::<Selection>()),
)
.add_systems(First, finalize_delete)
.add_systems(
OnEnter(SceneState::Active),
(apply_deferred, tag_scene_elements)
@@ -48,60 +49,73 @@ impl Plugin for SelectionPlugin {
.run_if(resource_changed_or_removed::<Selection>()),
);
app.add_systems(Update, sync_editor_selected);
app.add_systems(PostUpdate, propagate_space_editor_to_studio);
}
}
#[derive(Component, Clone, Copy)]
#[derive(Component, Clone, Copy, Reflect, Default)]
#[reflect(Component, Default)]
pub struct Selectable;
#[derive(Component, Clone, Copy)]
pub struct Selected;
#[derive(Resource, Deref, DerefMut, Debug)]
pub struct Selection(Entity);
fn sync_editor_selected(
fn propagate_space_editor_to_studio(
mut cmd: Commands,
q_new_selected: Query<Entity, (Added<Selected>, Without<space_editor::prelude::Selected>)>,
q_editor_selected: Query<Entity, With<space_editor::prelude::Selected>>,
q_selected: Query<Entity, With<Selected>>,
q_added_editor_selected: Query<
Entity,
(Without<Selected>, Added<space_editor::prelude::Selected>),
>,
r_selection: Option<Res<Selection>>,
q_new_selected: Query<Entity, Added<space_editor::prelude::Selected>>,
mut er_deselected: RemovedComponents<space_editor::prelude::Selected>,
q_parents: Query<&Parent>,
q_selectable: Query<&Selectable>,
) {
for ent in q_new_selected.iter() {
for ed_ent in q_editor_selected.iter() {
cmd.entity(ed_ent)
.remove::<space_editor::prelude::Selected>();
let studio_selection_deselected_in_space_editor = r_selection
.as_ref()
.map(|selection| {
er_deselected
.read()
.any(|deselected| deselected == ***selection)
})
.unwrap_or(false);
if q_new_selected.is_empty() && studio_selection_deselected_in_space_editor {
cmd.remove_resource::<Selection>();
} else {
let mut new_selected_iter = q_new_selected.iter();
let Some(selected) = new_selected_iter.next().and_then(|se_selected| {
nearest_selectable_parent(se_selected, &q_parents, &q_selectable)
}) else {
return;
};
if r_selection.is_none() || **r_selection.unwrap() != selected {
cmd.insert_resource(Selection(selected));
}
cmd.entity(ent).insert(space_editor::prelude::Selected);
}
for ent in q_added_editor_selected.iter() {
cmd.entity(ent).insert(Selected);
cmd.insert_resource(Selection(ent));
for ent in q_selected.iter() {
cmd.entity(ent).remove::<Selected>();
if new_selected_iter.next().is_some() {
warn!(
"Multiple new entities were selected in Space Editor, but \
Storyteller Studio doesn't currently support multiple selections"
);
}
}
}
fn tag_scene_elements(
mut cmd: Commands,
q_scene_elements: Query<(Entity, &Children), (With<SceneElement>, Without<Selectable>)>,
q_scene_elements: Query<(Entity, Option<&Children>), (With<SceneElement>, Without<Selectable>)>,
q_primitives: Query<&Handle<Mesh>>,
) {
for (ent, children) in q_scene_elements.iter() {
cmd.entity(ent).insert(Selectable);
cmd.entity(ent)
.insert((Selectable, PickableBundle::default()));
for &ent_child in children
.iter()
.filter(|child| q_primitives.contains(**child))
{
cmd.entity(ent_child).insert(PickableBundle::default());
if let Some(children) = children {
for &ent_child in children
.iter()
.filter(|child| q_primitives.contains(**child))
{
cmd.entity(ent_child).insert(PickableBundle::default());
}
}
}
}
@@ -116,31 +130,11 @@ fn update_selection(
mut ev_input: EventReader<InputAction>,
q_parents: Query<&Parent>,
q_selectable: Query<&Selectable>,
q_selected: Query<Entity, With<Selected>>,
) {
if let Some(event) = ev_select.read().last() {
// Clear any existing `Selected` components
for ent in q_selected.iter() {
cmd.entity(ent).remove::<Selected>();
}
// Find the nearest Selectable parent of the target entity
let mut ent = event.target;
let ent_selected = loop {
if q_selectable.get(ent).is_ok() {
break Some(ent);
}
if let Ok(parent) = q_parents.get(ent) {
ent = parent.get();
} else {
break None;
}
};
if let Some(ent) = ent_selected {
if let Some(ent) = nearest_selectable_parent(event.target, &q_parents, &q_selectable) {
cmd.insert_resource(Selection(ent));
cmd.entity(ent).insert(Selected);
} else {
cmd.remove_resource::<Selection>();
}
@@ -151,13 +145,30 @@ fn update_selection(
)
}) {
cmd.remove_resource::<Selection>();
}
}
for ent in q_selected.iter() {
cmd.entity(ent).remove::<Selected>();
fn nearest_selectable_parent(
mut target: Entity,
q_parents: &Query<&Parent>,
q_selectable: &Query<&Selectable>,
) -> Option<Entity> {
loop {
if q_selectable.get(target).is_ok() {
break Some(target);
}
if let Ok(parent) = q_parents.get(target) {
target = parent.get();
} else {
break None;
}
}
}
#[derive(Component)]
pub struct MarkedForDeletion;
fn process_delete_inputs(
mut cmd: Commands,
mut er_input: EventReader<InputAction>,
@@ -172,7 +183,7 @@ fn process_delete_inputs(
}) {
let entity = **r_selection;
cmd.entity(entity).despawn_recursive();
cmd.entity(entity).insert(MarkedForDeletion);
cmd.remove_resource::<Selection>();
ew_changes.send(NewChange {
change: Arc::new(RemovedEntity { entity }),
@@ -180,9 +191,16 @@ fn process_delete_inputs(
}
}
fn finalize_delete(mut cmd: Commands, q: Query<Entity, With<MarkedForDeletion>>) {
for entity in &q {
cmd.entity(entity).despawn_recursive();
}
}
fn draw_selection_outline(
mut cmd: Commands,
q_selected: Query<&Children, With<Selected>>,
r_selection: Option<Res<Selection>>,
q_children: Query<&Children>,
q_meshes: Query<
Entity,
(
@@ -199,19 +217,30 @@ fn draw_selection_outline(
cmd.entity(ent).remove::<OutlineBundle>();
}
for children in q_selected.iter() {
for &child in children.iter() {
if q_meshes.contains(child) {
cmd.entity(child).insert(OutlineBundle {
outline: OutlineVolume {
visible: true,
width: 4.,
colour: Color::YELLOW,
},
..default()
});
}
}
let Some(selection) = r_selection else {
return;
};
let target = if q_meshes.contains(**selection) {
Some(**selection)
} else {
q_children.get(**selection).ok().and_then(|children| {
children
.iter()
.copied()
.find(|&child| q_meshes.contains(child))
})
};
if let Some(target) = target {
cmd.entity(target).insert(OutlineBundle {
outline: OutlineVolume {
visible: true,
width: 4.,
colour: Color::YELLOW,
},
..default()
});
}
}
@@ -223,7 +252,7 @@ mod wasm {
};
use wasm_bindgen::prelude::*;
use super::{Selected, Selection};
use super::Selection;
use crate::wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer};
#[wasm_bindgen(typescript_custom_section)]
@@ -263,7 +292,6 @@ declare global {
pub(super) fn update_selection_from_frontend(mut cmd: Commands) {
if let Some(entity) = SELECTION_REQUEST.take_message() {
cmd.insert_resource(Selection(entity));
cmd.entity(entity).insert(Selected);
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ mod mixamo;
mod path;
mod persistance;
mod remote;
mod scene;
pub mod scene;
pub mod screenshot;
mod skybox;
mod ui;
+88 -32
View File
@@ -1,6 +1,9 @@
use bevy::{prelude::*, render::render_resource::Extent3d, transform::components::Transform};
use clap::{Args, Parser, Subcommand};
use studio::headless::{start_headless, HeadlessMode};
use studio::{
headless::{start_headless, CameraMotion, HeadlessMode},
scene::SceneState,
};
#[derive(Parser)]
#[command(name = "StorytellerStudioHeadless")]
@@ -20,11 +23,9 @@ enum Subcommands {
Scene {
scene_dir: String,
#[arg(long)]
orbit: bool,
#[arg(long, value_parser = clap::value_parser!(f32))]
orbit_radius: Option<f32>,
#[arg(long, value_parser = clap::value_parser!(f32))]
orbit_speed: Option<f32>,
camera_motion: Option<String>,
#[arg(long)]
camera_motion_speed: Option<f32>,
#[arg(long, short)]
output_dir: Option<String>,
#[arg(long)]
@@ -73,9 +74,8 @@ fn main() {
let mut app = match args.subcommand {
Subcommands::Scene {
scene_dir,
orbit,
orbit_speed,
orbit_radius,
camera_motion,
camera_motion_speed,
skybox,
output_dir,
output_resolution,
@@ -98,15 +98,29 @@ fn main() {
frames,
output_res,
);
let orbit_speed = orbit_speed.unwrap_or(1.0);
let orbit_radius = orbit_radius.unwrap_or(8.0);
if orbit {
app.insert_resource(OrbitSettings {
radius: orbit_radius,
speed: orbit_speed,
})
.add_systems(Update, orbit_scene);
if let Some(camera_motion) = camera_motion {
let camera_motion_speed = camera_motion_speed.unwrap_or(1.0);
match camera_motion.as_str() {
"orbit" => {
app.insert_resource(CameraMotion::Orbit(camera_motion_speed));
}
"pan" => {
app.insert_resource(CameraMotion::Pan(camera_motion_speed));
}
"zoom" => {
app.insert_resource(CameraMotion::Zoom(camera_motion_speed));
}
"static" => {
app.insert_resource(CameraMotion::Static);
}
_ => panic!("Unsupported camera motion type."),
}
app.add_systems(
Update,
update_camera_motion.run_if(in_state(SceneState::Active)),
);
}
app
@@ -166,27 +180,69 @@ fn main() {
app.run();
}
#[derive(Resource)]
struct OrbitSettings {
radius: f32,
speed: f32,
}
fn orbit_scene(
mut camera: Query<&mut Transform, With<Camera>>,
orbit_settings: Res<OrbitSettings>,
pub(crate) fn update_camera_motion(
mut camera: Query<&mut Transform, (With<Camera>, Without<Parent>)>,
camera_motion: Option<Res<CameraMotion>>,
time: Res<Time>,
) {
let Some(camera_motion) = camera_motion else {
return;
};
println!("{}", camera.iter().count());
let Ok(mut main_cam_transform) = camera.get_single_mut() else {
return;
};
main_cam_transform.translation = Vec3::new(
orbit_settings.radius * (time.elapsed_seconds() * orbit_settings.speed).cos(),
6.0,
orbit_settings.radius * (time.elapsed_seconds() * orbit_settings.speed).sin(),
);
main_cam_transform.look_at(Vec3::ZERO, Vec3::Y);
println!("Updating camera motion");
match *camera_motion {
CameraMotion::Orbit(speed) => {
orbit_camera(&mut main_cam_transform, &time, speed);
}
CameraMotion::Pan(speed) => {
pan_camera(&mut main_cam_transform, &time, speed);
}
CameraMotion::Zoom(speed) => {
zoom_camera(&mut main_cam_transform, &time, speed);
}
CameraMotion::Static => {
main_cam_transform.translation = Vec3::new(0.340, 1.1, -0.5);
main_cam_transform.rotation = Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0);
}
}
// main_cam_transform.look_at(Vec3::ZERO, Vec3::Y);
}
fn zoom_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed: f32) {
let scaled_time = time.elapsed().as_secs_f32() * speed;
let normalized_cos = (-scaled_time.cos() + 1.0) / 2.0;
let start_pos = Vec3::new(0.366, 1.1, 2.5);
let end_pos = Vec3::new(0.366, 1.1, -0.5);
main_cam_transform.translation = start_pos.lerp(end_pos, normalized_cos);
main_cam_transform.rotation = Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0);
}
fn pan_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed: f32) {
let scaled_time = time.elapsed().as_secs_f32() * speed;
let normalized_cos = (-scaled_time.cos() + 1.0) / 2.0;
let start_pos = Vec3::new(0.676, 1.1, 0.119);
let end_pos = Vec3::new(0.067, 1.1, 0.121);
main_cam_transform.translation = start_pos.lerp(end_pos, normalized_cos);
main_cam_transform.rotation = Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0);
}
fn orbit_camera(main_cam_transform: &mut Mut<'_, Transform>, time: &Res<'_, Time>, speed: f32) {
let scaled_time = time.elapsed().as_secs_f32() * speed;
let max_angle = std::f32::consts::PI / 2.0;
let normalized_cos = (scaled_time.cos() + 1.0) / 2.0;
let angle = max_angle * normalized_cos;
// let t = (time.elapsed().as_secs_f32() / 5.0) % 1.0;
main_cam_transform.translation = Vec3::new(-2.2 * angle.cos(), 1.0, 2.5 * angle.sin());
main_cam_transform.look_at(Vec3::new(0.347, 0.556, -1.0), Vec3::Y);
}
//==============================================================================
+3 -3
View File
@@ -3,7 +3,7 @@ use bevy::{
window::{close_on_esc, Window},
};
use clap::Parser;
use studio::{screenshot::ScreenShotPlugin, InitAsset, InitParams, StudioMode};
use studio::{InitAsset, InitParams, StudioMode};
#[derive(Parser)]
#[command(name = "StorytellerStudio")]
@@ -19,7 +19,7 @@ struct StudioArgs {
fn main() {
let args = StudioArgs::parse();
let object_id = args.model.unwrap_or("Mannequin.gltf".to_string());
let object_id = args.model.unwrap_or("sample-room.gltf".to_string());
let skybox_id = args.skybox.unwrap_or("gum_trees_4k".to_string());
// Edit the arguments here to change the default startup scene or skybox
@@ -37,7 +37,7 @@ fn main() {
// Add any additional systems, resources, etc. for local debugging
app.add_systems(Update, close_on_esc);
app.add_plugins(ScreenShotPlugin);
// app.add_plugins(ScreenShotPlugin);
app.run();
}
+73 -61
View File
@@ -1,5 +1,6 @@
use std::{io::ErrorKind, path::Path, sync::Arc};
use async_compat::Compat;
use bevy::{
asset::io::{AssetReader, AssetReaderError, PathStream, Reader, VecReader},
prelude::*,
@@ -15,6 +16,68 @@ pub struct RemoteAssetReader;
impl RemoteAssetReader {
const API_BASE_URL: &'static str = "https://api.fakeyou.com/v1/media_files/file";
const BUCKET_BASE_URL: &'static str = "https://storage.googleapis.com/vocodes-public";
async fn load<'a>(&'a self, path: &'a Path) -> Result<Box<Reader<'a>>, AssetReaderError> {
let token = path
.ext_file_prefix()
.ok_or_else(|| {
AssetReaderError::Io(std::io::Error::new(
ErrorKind::Other,
format!("Failed to parse token from '{path:?}'"),
))
})?
.to_str()
.unwrap();
info!("Loading asset from media token: '{token}'");
let response = reqwest::get(format!("{}/{token}", &Self::API_BASE_URL))
.await
.map_err(IntoError::<AssetReaderError>::into)?;
let response = match response.status() {
StatusCode::OK => response
.json::<GetMediaFileSuccessResponse>()
.await
.map_err(IntoError::<AssetReaderError>::into),
other => Err(AssetReaderError::Io(std::io::Error::new(
ErrorKind::Other,
format!("Status Code from endpoint: {other}"),
))),
}?;
let url = format!(
"{}{}",
&Self::BUCKET_BASE_URL,
&response.media_file.public_bucket_path
);
let response = reqwest::get(url)
.await
.map_err(IntoError::<AssetReaderError>::into)?;
let bytes = match response.status() {
// TODO: This is not thread-safe, but some variation of this
// would be better than the current solution once Bevy
// WASM becomes multi-threaded:
// StatusCode::OK => Ok(response.bytes_stream().map(|result| {
// result.map_err(|err| std::io::Error::new(ErrorKind::Other, format!("{err}")))
// })),
StatusCode::OK => Ok(response
.bytes()
.await
.map_err(IntoError::<AssetReaderError>::into)?
.into_iter()
.collect::<Vec<_>>()),
other => Err(AssetReaderError::Io(std::io::Error::new(
ErrorKind::Other,
format!("Status Code from bucket URL: {other}"),
))),
}?;
let reader: Box<Reader> = Box::new(VecReader::new(bytes));
Ok(reader)
}
}
impl AssetReader for RemoteAssetReader {
@@ -22,67 +85,16 @@ impl AssetReader for RemoteAssetReader {
&'a self,
path: &'a Path,
) -> BoxedFuture<'a, Result<Box<Reader<'a>>, AssetReaderError>> {
Box::pin(async move {
let token = path
.ext_file_prefix()
.ok_or_else(|| {
AssetReaderError::Io(Arc::new(std::io::Error::new(
ErrorKind::Other,
format!("Failed to parse token from '{path:?}'"),
)))
})?
.to_str()
.unwrap();
info!("Loading asset from media token: '{token}'");
let response = reqwest::get(format!("{}/{token}", &Self::API_BASE_URL))
.await
.map_err(IntoError::<AssetReaderError>::into)?;
let response = match response.status() {
StatusCode::OK => response
.json::<GetMediaFileSuccessResponse>()
.await
.map_err(IntoError::<AssetReaderError>::into),
other => Err(AssetReaderError::Io(Arc::new(std::io::Error::new(
ErrorKind::Other,
format!("Status Code from endpoint: {other}"),
)))),
}?;
let url = format!(
"{}{}",
&Self::BUCKET_BASE_URL,
&response.media_file.public_bucket_path
);
let response = reqwest::get(url)
.await
.map_err(IntoError::<AssetReaderError>::into)?;
let bytes = match response.status() {
// TODO: This is not thread-safe, but some variation of this
// would be better than the current solution once Bevy
// WASM becomes multi-threaded:
// StatusCode::OK => Ok(response.bytes_stream().map(|result| {
// result.map_err(|err| std::io::Error::new(ErrorKind::Other, format!("{err}")))
// })),
StatusCode::OK => Ok(response
.bytes()
.await
.map_err(IntoError::<AssetReaderError>::into)?
.into_iter()
.collect::<Vec<_>>()),
other => Err(AssetReaderError::Io(Arc::new(std::io::Error::new(
ErrorKind::Other,
format!("Status Code from bucket URL: {other}"),
)))),
}?;
let reader: Box<Reader> = Box::new(VecReader::new(bytes));
Ok(reader)
})
let unboxed = async move { self.load(path).await };
if cfg!(not(feature = "wasm")) {
info!("wasm mode not detected");
// NB: Headless mode (server rendering) is running without a Tokio runtime.
// The reqwest HTTP client requires async-compat to function at runtime.
Box::pin(Compat::new(unboxed))
} else {
info!("wasm mode");
Box::pin(unboxed)
}
}
fn read_meta<'a>(
+105 -15
View File
@@ -12,22 +12,28 @@ use bevy::{
prelude::*,
render::{camera::RenderTarget, primitives::Aabb},
scene::InstanceId,
utils::{EntityHashSet, HashSet},
};
#[cfg(feature = "wasm")]
use serde_repr::{Deserialize_repr, Serialize_repr};
use space_editor::{
prelude::{EditorEvent, EditorPrefabPath, PrefabMarker},
space_prefab::component::SceneAutoChild,
space_prefab::{
component::{GltfPrefab, SceneAutoChild},
editor_registry::EditorRegistryExt,
},
};
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::wasm_bindgen;
use wgpu::{TextureDescriptor, TextureUsages};
use crate::{
anim::timeline_animation::AnimationTarget,
camera_controller::{CameraController, CameraControllerBundle},
change_tracking::{ChangeTracker, ChangeTrackingPlugin},
debug::DebugPoint,
headless::{is_headless, is_not_headless, HeadlessSettings},
is_editor,
math::{Combinable, Extrema},
screenshot::ScreenCapture,
MainCamera,
@@ -57,21 +63,28 @@ impl Plugin for ScenePlugin {
)
.add_systems(
Update,
(init_object_loading, init_scene_import, init_scene_loading)
(
init_object_loading,
init_scene_import,
init_scene_loading,
start_capture.run_if(is_headless),
)
.in_set(SceneSpawnerSystem),
)
.add_systems(
OnEnter(SceneState::Active),
(
tag_scene_elements,
frame_scene_in_view,
start_capture.run_if(is_headless),
)
(tag_scene_elements, frame_scene_in_view)
.chain()
.in_set(SceneSpawnerSystem),
);
app.add_systems(Update, tag_scene_elements);
app.add_systems(
Update,
(
tag_scene_elements,
autoplay_animations.run_if(not(is_editor)),
),
);
app.init_resource::<SkyboxHolder>();
app.add_systems(Update, attach_skybox);
@@ -103,6 +116,8 @@ impl Plugin for ScenePlugin {
wasm::notify_loading_queue_changes.map(bevy::utils::error),
),
);
app.editor_registry::<SceneElement>();
}
}
@@ -126,6 +141,7 @@ fn attach_skybox(
fn minimal_scene_setup(mut cmd: Commands) {
cmd.spawn((
Name::new("Directional Light"),
AnimationTarget::new(),
DirectionalLightBundle {
directional_light: DirectionalLight {
shadows_enabled: false,
@@ -164,8 +180,19 @@ pub struct LoadingQueue {
fn check_loading(
mut loading_queue: ResMut<LoadingQueue>,
mut st_next: ResMut<NextState<SceneState>>,
r_scene_state: Res<State<SceneState>>,
asset_server: Res<AssetServer>,
) {
// Avoid transitioning from `SceneState::None` directly to `SceneState::Active`.
// We will always be initializing the scene with _something_, so if the
// `LoadingQueue` is empty when we're in `SceneState::None`, it just means
// our startup routine hasn't yet gotten to the point of processing any
// `Handle<T>`s.
if loading_queue.queue.is_empty() && **r_scene_state == SceneState::Loading {
st_next.set(SceneState::Active);
return;
}
if !loading_queue.queue.is_empty() {
//filter queue to only include notloaded assets
@@ -200,8 +227,6 @@ fn check_loading(
} else {
st_next.set(SceneState::Loading);
}
} else {
st_next.set(SceneState::Active);
}
}
@@ -256,9 +281,11 @@ pub struct ImportSceneEvent(pub String);
wasm_bindgen,
derive(Serialize_repr, Deserialize_repr)
)]
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Reflect, Default)]
#[reflect(Component, Default)]
#[allow(dead_code)] // Some variants only used in `wasm`, some planned for later use
pub enum SceneElement {
#[default]
Generic,
Mesh,
Skeleton,
@@ -283,6 +310,7 @@ fn initialize(mut cmd: Commands) {
cmd.spawn((
Name::new("Main Camera"),
MainCamera,
AnimationTarget::MainCamera,
ChangeTracker,
Camera3dBundle {
transform: camera_xform,
@@ -339,6 +367,7 @@ fn initialiaze_headless(
cmd.spawn((
Name::new("Main Camera"),
MainCamera,
AnimationTarget::MainCamera,
Camera3dBundle {
transform: camera_xform,
tonemapping: Tonemapping::BlenderFilmic,
@@ -350,8 +379,6 @@ fn initialiaze_headless(
..default()
},
CameraControllerBundle::new(focus, camera_xform.translation),
ScreenCapture::from_out(&headless_settings.output_dir)
.with_max_frames(headless_settings.max_frames),
));
}
@@ -371,7 +398,11 @@ fn init_object_loading(mut cmd: Commands, mut er: EventReader<LoadLibraryObjectE
}
}
fn init_scene_import(mut cmd: Commands, mut er: EventReader<ImportSceneEvent>) {
fn init_scene_import(
mut cmd: Commands,
r_assets: Res<AssetServer>,
mut er: EventReader<ImportSceneEvent>,
) {
if let Some(path) = er.read().last() {
info!("Importing asset: {}", path.0);
cmd.spawn((
@@ -381,6 +412,7 @@ fn init_scene_import(mut cmd: Commands, mut er: EventReader<ImportSceneEvent>) {
path: path.0.clone(),
scene: "Scene0".to_string(),
},
r_assets.load::<Gltf>(&path.0),
PrefabMarker,
SceneRoot,
));
@@ -426,6 +458,64 @@ fn tag_scene_elements(
}
}
fn autoplay_animations(
mut cmd: Commands,
mut l_done: Local<HashSet<String>>,
mut l_animating: Local<EntityHashSet<Entity>>,
ra_gltfs: Res<Assets<Gltf>>,
ra_anims: Res<Assets<AnimationClip>>,
q_prefabs: Query<(&GltfPrefab, &Handle<Gltf>)>,
q_named_ents: Query<(Entity, &Name)>,
mut q_players: Query<&mut AnimationPlayer>,
) {
for (prefab, handle) in q_prefabs.iter() {
if l_done.contains(&prefab.path) {
continue;
}
let Some(gltf) = ra_gltfs.get(handle) else {
continue;
};
let mut playing_anims = 0_usize;
for (anim_name, anim_handle) in gltf.named_animations.iter() {
let Some(anim) = ra_anims.get(anim_handle.clone()) else {
continue;
};
let Some((target, name)) = q_named_ents
.iter()
.find(|(_, name)| anim.compatible_with(name))
else {
continue;
};
if !l_animating.contains(&target) {
info!(
"Playing animation '{}' on entity {:?} '{}'",
anim_name, target, name,
);
if let Ok(mut player) = q_players.get_mut(target) {
if !player.is_playing_clip(anim_handle) {
player.play(anim_handle.clone()).repeat();
}
} else {
let mut player = AnimationPlayer::default();
player.play(anim_handle.clone()).repeat();
cmd.entity(target).insert(player);
}
l_animating.insert(target);
}
playing_anims += 1;
}
if playing_anims == gltf.named_animations.len() {
l_done.insert(prefab.path.clone());
}
}
}
fn frame_scene_in_view(
mut cmd: Commands,
q_aabbs: Query<(&Aabb, &GlobalTransform), With<SceneAutoChild>>,
@@ -529,7 +619,7 @@ fn frame_scene_in_view(
cam_xform.translation = cam.focus + cam_xform.back() * radius;
}
fn start_capture(mut captures: Query<&mut ScreenCapture>) {
fn start_capture(mut captures: Query<&mut ScreenCapture, Added<ScreenCapture>>) {
for mut capture in captures.iter_mut() {
capture.start_capturing();
}
+18
View File
@@ -147,7 +147,11 @@ fn update_screen_capture_on_extract_step(
fn extract_screen_capture_components(
mut commands: Commands,
screen_capure_comps: Extract<Query<(Entity, &ScreenCapture), With<Camera>>>,
scene_state: Extract<Res<State<SceneState>>>,
) {
if scene_state.get() != &SceneState::Active {
return;
};
for (entity, screen_capture) in screen_capure_comps.iter() {
if screen_capture.should_capture() {
info!(
@@ -249,10 +253,24 @@ impl ViewNode for ScreenCaptureNode {
//==============================================================================
fn save_buffer_to_file(
mut frames_to_skip: Local<Option<u32>>,
buffers: Query<(&ScreenCapture, &ScreenCaptureBuffers)>,
device: Res<RenderDevice>,
) {
for (screen_capture, buffers) in buffers.iter() {
// ALERT!! ALERT!! Filthy Hack in effect
// Engine does not load scene in time for the first 3 frames
// So we skip them. Oops :(
// TODO: Fix the Race Condition and properly take screenshots.
let frames_skipped = frames_to_skip.unwrap_or(3);
if frames_skipped > 0 {
println!("Skipping Frame");
*frames_to_skip = Some(frames_skipped - 1);
return;
}
// Filthy Hack Ends
if buffers.main_bytes_per_row != buffers.main_padded_bytes_per_row {
println!("These don't match!");
}
+2 -9
View File
@@ -1,7 +1,6 @@
use std::sync::Arc;
use bevy::prelude::*;
use bevy_mod_picking::PickableBundle;
use space_editor::prelude::{
space_undo::{AddedEntity, NewChange},
*,
@@ -9,7 +8,6 @@ use space_editor::prelude::{
use wasm_bindgen::prelude::*;
use crate::{
interaction::Selectable,
scene::SceneElement,
wasm::{StaticBuffer, WasmMessageBuffer},
};
@@ -27,13 +25,8 @@ fn read_command_system(mut commands: Commands, mut change_event: EventWriter<New
return;
};
let mut new_entity = commands.spawn((
SpatialBundle::default(),
PrefabMarker,
Selectable,
SceneElement::Mesh,
PickableBundle::default(),
));
let mut new_entity =
commands.spawn((SpatialBundle::default(), PrefabMarker, SceneElement::Mesh));
match cmd {
BlockingCommand::Cube => {
+356 -105
View File
@@ -8,18 +8,20 @@ use bevy::{
window::PrimaryWindow,
};
use serde::{Deserialize, Serialize};
use serde_json::*;
use serde_json::{self, json, Map as JsonMap, Value as JsonValue};
use space_editor::{
space_editor_ui::{
editor_tab::{EditorTab, EditorTabName},
ui_plugin::EditorUiAppExt,
EditorUiAppExt,
},
space_prefab::{
component::MeshPrimitivePrefab, editor_registry::EditorRegistryExt, save::SaveState,
},
space_prefab::component::MeshPrimitive3dPrefab,
};
use wasm_bindgen::prelude::*;
use crate::{
interaction::Selected,
interaction::Selection,
wasm::{self, StaticBuffer, WasmMessageBuffer},
};
@@ -48,7 +50,11 @@ impl Plugin for InspectorPlugin {
app.add_systems(
Update,
update_field_by_ui.in_set(InspectorSystem::WriteChanges),
(
update_field_by_ui.map(bevy::utils::error),
update_local_rotation_by_ui,
)
.in_set(InspectorSystem::WriteChanges),
);
app.editor_tab_by_trait(
@@ -60,16 +66,17 @@ impl Plugin for InspectorPlugin {
app.register_inspect::<Transform>();
app.register_inspect::<Name>();
app.register_inspect::<MeshPrimitive3dPrefab>();
app.register_inspect::<MeshPrimitivePrefab>();
//register standart material
{
app.register_type::<ShortMaterial>()
app.editor_registry::<ShortMaterial>()
.add_event::<UpdateInspect<Handle<StandardMaterial>>>()
.add_systems(
Update,
(
update_inspect_by_select_change::<Handle<StandardMaterial>>,
update_inspect_by_select_change::<Handle<StandardMaterial>>
.run_if(resource_changed_or_removed::<Selection>()),
update_inspect_by_remove::<Handle<StandardMaterial>>,
update_inspect_by_change::<Handle<StandardMaterial>>,
notify_asset_change_system::<StandardMaterial>,
@@ -81,32 +88,90 @@ impl Plugin for InspectorPlugin {
serialize_standart_materal.in_set(InspectorSystem::PerComponentCollect),
);
app.add_systems(
OnEnter(SaveState::Save),
(create_short_materials, apply_deferred)
.chain()
.before(space_editor::space_prefab::sub_scene::prepare_auto_scene),
);
app.add_systems(PostUpdate, apply_short_materials);
app.world.resource_mut::<InspectGetters>().getters.insert(
StandardMaterial::get_type_registration()
.type_info()
.type_path()
.to_string(),
Arc::new(|entity, path, world, f| unsafe {
let handle = entity.get::<Handle<StandardMaterial>>().unwrap();
let handle = entity.get::<Handle<StandardMaterial>>().ok_or_else(|| {
format!(
"Failed to get StandardMaterial for entity {:?}",
entity.id()
)
})?;
let mut assets = world
.get_resource_mut::<Assets<StandardMaterial>>()
.unwrap();
let material = assets.get_mut(handle).unwrap();
.ok_or_else(|| {
"Failed to get `Assets<StandadrMaterial>` resource".to_string()
})?;
let reflect_field = material.reflect_path_mut(path).unwrap();
(f)(reflect_field);
let material = assets.get_mut(handle).ok_or_else(|| {
format!("Failed to retrieve material from handle {handle:?}")
})?;
let reflect_field = material
.reflect_path_mut(path)
.map_err(|err| format!("{err}"))?;
(f)(reflect_field)
}),
);
}
}
}
fn create_short_materials(
mut commands: Commands,
q_mats: Query<(Entity, &Handle<StandardMaterial>)>,
materials: Res<Assets<StandardMaterial>>,
) {
for (entity, handle) in q_mats.iter() {
if let Some(material) = materials.get(handle) {
commands.entity(entity).insert(ShortMaterial::new(material));
}
}
}
fn apply_short_materials(
mut commands: Commands,
q_mats: Query<(Entity, &Handle<StandardMaterial>, &ShortMaterial)>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
for (entity, handle, short) in q_mats.iter() {
let Some(material) = materials.get_mut(handle) else {
continue;
};
material.base_color = short.base_color;
material.perceptual_roughness = short.perceptual_roughness;
material.metallic = short.metallic;
material.reflectance = short.reflectance;
commands.entity(entity).remove::<ShortMaterial>();
}
}
#[derive(Resource, Default)]
struct InspectGetters {
getters: HashMap<
String,
Arc<
dyn Fn(&mut EntityWorldMut, &str, &mut UnsafeWorldCell, &dyn Fn(&mut dyn Reflect))
dyn Fn(
&mut EntityWorldMut,
&str,
&mut UnsafeWorldCell,
&dyn Fn(&mut dyn Reflect) -> Result<(), String>,
) -> Result<(), String>
+ Send
+ Sync,
>,
@@ -125,7 +190,8 @@ impl AppInspectable for App {
.add_systems(
Update,
(
update_inspect_by_select_change::<T>,
update_inspect_by_select_change::<T>
.run_if(resource_changed_or_removed::<Selection>()),
update_inspect_by_remove::<T>,
update_inspect_by_change::<T>,
)
@@ -146,7 +212,7 @@ impl AppInspectable for App {
Arc::new(|entity, path, _, f| {
let mut component = entity.get_mut::<T>().unwrap();
let reflect_field = component.reflect_path_mut(path).unwrap();
(f)(reflect_field);
(f)(reflect_field)
}),
);
}
@@ -158,7 +224,8 @@ impl AppInspectable for App {
.add_systems(
Update,
(
update_inspect_by_select_change::<Handle<T>>,
update_inspect_by_select_change::<Handle<T>>
.run_if(resource_changed_or_removed::<Selection>()),
update_inspect_by_remove::<Handle<T>>,
update_inspect_by_change::<Handle<T>>,
notify_asset_change_system::<T>,
@@ -186,7 +253,7 @@ impl EditorTab for TestInspectorTab {
CHANGE_FROM_UI.write_message(UpdateField {
component: "bevy_transform::components::transform::Transform".to_string(),
reflect_path: ".translation.x".to_string(),
value: serde_json::Value::from(0.0),
value: JsonValue::from(0.0),
});
}
}
@@ -220,116 +287,181 @@ impl<T> Default for UpdateInspect<T> {
}
}
fn update_field_by_ui(world: &mut World) {
#[derive(Serialize, Deserialize)]
struct Rgba {
red: f32,
green: f32,
blue: f32,
alpha: f32,
}
fn update_field_by_ui(world: &mut World) -> Result<(), String> {
unsafe {
let mut unsafe_world = world.as_unsafe_world_cell();
let Some(msg) = CHANGE_FROM_UI.take_message() else {
return;
return Ok(());
};
info!("Processing update: {msg:?}");
let mut selected_query = unsafe_world
.world_mut()
.query_filtered::<Entity, With<Selected>>();
let Ok(entity) = selected_query.get_single(unsafe_world.world_mut()) else {
return;
let Some(selection) = unsafe_world.world_mut().get_resource::<Selection>() else {
return Ok(());
};
let entity = **selection;
let getters = unsafe_world.get_resource_mut::<InspectGetters>().unwrap();
let getter = getters
.getters
.get(&msg.component)
.ok_or_else(|| format!("Failed to find getter for component: {}", msg.component))?;
let mut entity_mut = unsafe_world.world_mut().entity_mut(entity);
// TODO: This is pretty ugly
let target = 'find_target: {
if &msg.component[..] == "bevy_pbr::pbr_material::StandardMaterial" {
let mut q_handles = unsafe_world
.world_mut()
.query::<&Handle<StandardMaterial>>();
let Some(getter) = getters.getters.get(&msg.component) else {
error!("Failed to find getter for component: {}", msg.component);
return;
if q_handles.get(unsafe_world.world(), entity).is_ok() {
break 'find_target Some(entity);
}
let mut q_children = unsafe_world.world_mut().query::<&Children>();
let Ok(ent_children) = q_children.get(unsafe_world.world(), entity) else {
break 'find_target None;
};
break 'find_target ent_children
.iter()
.copied()
.find(|&child| q_handles.get(unsafe_world.world(), child).is_ok());
} else {
break 'find_target Some(entity);
}
};
let Some(target) = target else {
return Err(format!(
"Failed to find target for component {}",
&msg.component
));
};
info!("Applying to entity: {target:?}");
let mut entity_mut = unsafe_world.world_mut().entity_mut(target);
getter.as_ref()(
&mut entity_mut,
&msg.reflect_path,
&mut unsafe_world,
&|reflect_field| {
let fmt_err = |err: serde_json::Error| {
format!(
"Failed to parse value {} for path {}: {err}",
msg.value, &msg.reflect_path,
)
};
if let Some(val) = reflect_field.downcast_mut::<f32>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<f64>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<u32>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<u64>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<i32>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<i64>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<bool>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<String>() {
*val = msg.value.as_str().unwrap().to_string();
let msg_value = msg
.value
.as_str()
.ok_or_else(|| format!("Failed to parse value as string: {}", msg.value))?;
*val = msg_value.to_string();
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<Vec3>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<Quat>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
*val = msg_value;
#[derive(Deserialize)]
struct JsonQuat {
w: f32,
x: f32,
y: f32,
z: f32,
}
let JsonQuat { w, x, y, z } =
serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = Quat::from_xyzw(x, y, z, w);
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<Color>() {
let Rgba {
red,
green,
blue,
alpha,
} = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
match *val {
Color::RgbaLinear { .. } => {
*val = Color::RgbaLinear {
red,
green,
blue,
alpha,
};
}
Color::Rgba { .. } => {
*val = Color::Rgba {
red,
green,
blue,
alpha,
};
}
other => {
return Err(format!("Unsupported color format: {other:?}"));
}
}
Ok(())
} else {
error!("Unsupported type {}!", &msg.reflect_path);
return;
Err(format!("Unsupported type {}!", &msg.reflect_path))
}
},
);
)
}
}
#[derive(Resource, Serialize, Deserialize, Default, Clone)]
pub struct EntityDescription {
pub components: HashMap<String, Value>,
pub components: HashMap<String, JsonValue>,
}
fn update_inspect_by_select_change<T: Component + Reflect + GetTypeRegistration>(
mut ev_inspect: EventWriter<UpdateInspect<T>>,
q_added: Query<(), Added<Selected>>,
q_removed: RemovedComponents<Selected>,
) {
if !q_added.is_empty() {
ev_inspect.send(UpdateInspect::<T>::default());
}
if !q_removed.is_empty() {
ev_inspect.send(UpdateInspect::<T>::default());
}
ev_inspect.send(UpdateInspect::<T>::default());
}
fn update_inspect_by_remove<T: Component + Reflect + GetTypeRegistration>(
@@ -342,16 +474,20 @@ fn update_inspect_by_remove<T: Component + Reflect + GetTypeRegistration>(
}
fn update_inspect_by_change<T: Component + Reflect + GetTypeRegistration>(
r_selection: Option<Res<Selection>>,
mut ev_inspect: EventWriter<UpdateInspect<T>>,
q_t_changed: Query<&T, (With<Selected>, Changed<T>)>,
q_t_changed: Query<&T, Changed<T>>,
) {
if q_t_changed.iter().next().is_some() {
ev_inspect.send(UpdateInspect::<T>::default());
if let Some(selection) = r_selection {
if q_t_changed.contains(**selection) {
ev_inspect.send(UpdateInspect::<T>::default());
}
}
}
fn auto_serialize_system<T: Component + Reflect + GetTypeRegistration>(
q_t: Query<&T, With<Selected>>,
r_selection: Option<Res<Selection>>,
q_t: Query<&T>,
mut description: ResMut<EntityDescription>,
registry: Res<AppTypeRegistry>,
mut ev_inspect: EventReader<UpdateInspect<T>>,
@@ -363,7 +499,7 @@ fn auto_serialize_system<T: Component + Reflect + GetTypeRegistration>(
ev_inspect.clear();
if let Some(t) = q_t.iter().next() {
if let Some(t) = r_selection.and_then(|selection| q_t.get(**selection).ok()) {
let registry = registry.read();
let json = serde_json::to_value(ReflectSerializer::new(t, &registry)).unwrap();
info!("json: {:?}", json.to_string());
@@ -384,12 +520,20 @@ fn auto_serialize_system<T: Component + Reflect + GetTypeRegistration>(
}
fn notify_asset_change_system<T: Asset>(
r_selection: Option<Res<Selection>>,
mut ev_inspect: EventWriter<UpdateInspect<Handle<T>>>,
mut asset_changing: EventReader<AssetEvent<T>>,
mut selected: Query<&Handle<T>, With<Selected>>,
q_handles: Query<&Handle<T>>,
q_children: Query<&Children>,
) {
for ev in asset_changing.read() {
for handle in selected.iter_mut() {
if let Some(handle) = r_selection
.as_ref()
.and_then(|selection| {
find_handle_in_self_or_children(***selection, &q_children, &q_handles)
})
.and_then(|target| q_handles.get(target).ok())
{
if ev.is_modified(handle.id()) {
ev_inspect.send(UpdateInspect::<Handle<T>>::default());
}
@@ -398,7 +542,9 @@ fn notify_asset_change_system<T: Asset>(
}
fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
q_t: Query<&Handle<T>, With<Selected>>,
r_selection: Option<Res<Selection>>,
q_t: Query<&Handle<T>>,
q_children: Query<&Children>,
mut description: ResMut<EntityDescription>,
registry: Res<AppTypeRegistry>,
mut ev_inspect: EventReader<UpdateInspect<Handle<T>>>,
@@ -411,7 +557,11 @@ fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
ev_inspect.clear();
if let Some(handle) = q_t.iter().next() {
if let Some(handle) = r_selection
.as_ref()
.and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_t))
.and_then(|target| q_t.get(target).ok())
{
if let Some(asset) = assets.get(handle) {
let registry = registry.read();
info!(
@@ -441,22 +591,30 @@ fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
ev_update_description.send(UpdateEntityDescription);
}
#[derive(Reflect, Default)]
#[reflect(Default)]
#[derive(Reflect, Default, Clone, Component)]
#[reflect(Default, Component)]
struct ShortMaterial {
pub base_color: Color,
pub perceptual_roughness: f32,
pub metallic: f32,
pub reflectance: f32,
}
impl ShortMaterial {
pub fn new(material: &StandardMaterial) -> Self {
Self {
base_color: material.base_color,
perceptual_roughness: material.perceptual_roughness,
metallic: material.metallic,
reflectance: material.reflectance,
}
}
}
fn serialize_standart_materal(
r_selection: Option<Res<Selection>>,
q_m: Query<&Handle<StandardMaterial>>,
q_children: Query<&Children>,
mut description: ResMut<EntityDescription>,
registry: Res<AppTypeRegistry>,
mut ev_inspect: EventReader<UpdateInspect<Handle<StandardMaterial>>>,
@@ -469,8 +627,12 @@ fn serialize_standart_materal(
ev_inspect.clear();
if let Some(handle) = q_m.iter().next() {
if let Some(material) = assets.get(handle) {
if let Some((target, handle)) = r_selection
.as_ref()
.and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_m))
.and_then(|target| q_m.get(target).ok().map(|h| (target, h.clone())))
{
if let Some(material) = assets.get(handle.clone()) {
let registry = registry.read();
let short_material = ShortMaterial::new(material);
let json =
@@ -522,11 +684,11 @@ fn update_description(
.components
.iter()
.map(|(name, v)| {
let mut value = Value::Object(Map::new());
let mut value = JsonValue::Object(JsonMap::new());
value
.as_object_mut()
.unwrap()
.insert("name".to_string(), Value::String(name.to_string()));
.insert("name".to_string(), JsonValue::String(name.to_string()));
value
.as_object_mut()
.unwrap()
@@ -543,25 +705,54 @@ fn update_description(
}
}
#[derive(Deserialize, Serialize)]
/// Find a `Handle<T>` component on this entity or one of its direct children
fn find_handle_in_self_or_children<T: Asset>(
target: Entity,
q_children: &Query<&Children>,
q_handles: &Query<&Handle<T>>,
) -> Option<Entity> {
if q_handles.contains(target) {
return Some(target);
}
let Ok(children) = q_children.get(target) else {
return None;
};
children
.iter()
.copied()
.find(|&child| q_handles.contains(child))
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UpdateField {
pub component: String,
pub reflect_path: String,
pub value: serde_json::Value,
pub value: JsonValue,
}
#[wasm_bindgen(typescript_custom_section)]
const EVENT_DECLARATION: &str = r#"
export interface InspectorChanged extends CustomEvent {
export interface InspectorChangedEvent extends CustomEvent {
detail: string | null;
}
declare global {
export interface HTMLElementEventMap {
"inspector-changed": InspectorChanged;
export interface HTMLElementEventMap {
"inspector-changed": InspectorChangedEvent;
}
}
export interface UpdateField<T> {
component: string;
reflectPath: string;
value: T;
}
export function updateComponentField<T>(params: UpdateField<T>): void;
"#;
pub(super) fn notify_changed(q_window: &Window, msg: String) {
@@ -573,11 +764,71 @@ pub(super) fn notify_changed(q_window: &Window, msg: String) {
static CHANGE_FROM_UI: WasmMessageBuffer<UpdateField> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = changeValue)]
pub fn change_value(msg: JsValue) {
let Ok(msg) = serde_wasm_bindgen::from_value(msg) else {
error!("Failed to parse message");
#[wasm_bindgen(js_name = updateComponentField, skip_typescript)]
pub fn update_component_field(msg: JsValue) -> Result<(), JsValue> {
let msg = serde_wasm_bindgen::from_value(msg)?;
info!("Received update: {msg:?}");
CHANGE_FROM_UI.write_message(msg);
Ok(())
}
/// Handle transform rotation updates from the UI as a special case.
///
/// It's impossible to reliably compute a new quaternion for a desired Euler-
/// axes or axis-angle rotation from the frontend, because the calculation is
/// dependent on the parent's orientation. To deal with this, we provide a
/// separate `rotate_local_transform` method for this operation and handle it
/// with a separate Bevy system.
static ROTATE_LOCAL_XFORM: WasmMessageBuffer<(RotationAxis, f32)> = WasmMessageBuffer::new();
#[wasm_bindgen]
pub enum RotationAxis {
X,
Y,
Z,
}
#[wasm_bindgen(js_name = rotateLocalTransform)]
pub fn rotate_local_transform(axis: RotationAxis, theta: f32) {
ROTATE_LOCAL_XFORM.write_message((axis, theta));
}
fn update_local_rotation_by_ui(
r_selection: Option<Res<Selection>>,
q_parents: Query<&Parent>,
q_global_xforms: Query<&GlobalTransform>,
mut q_xforms: Query<&mut Transform>,
) {
let Some((axis, theta)) = ROTATE_LOCAL_XFORM.take_message() else {
return;
};
CHANGE_FROM_UI.write_message(msg);
let Some(selection) = r_selection.map(|selection| **selection) else {
return;
};
let Ok(target_global_xform) = q_global_xforms.get(selection).map(|xform| xform.affine()) else {
return;
};
let parent_global_xform = q_parents
.get(selection)
.and_then(|parent| q_global_xforms.get(**parent))
.unwrap_or(&GlobalTransform::IDENTITY)
.affine();
let axis_ws = match axis {
RotationAxis::X => target_global_xform.matrix3.x_axis,
RotationAxis::Y => target_global_xform.matrix3.y_axis,
RotationAxis::Z => target_global_xform.matrix3.z_axis,
};
let axis_parent_space = parent_global_xform
.inverse()
.transform_vector3a(axis_ws)
.normalize();
if let Ok(mut xform) = q_xforms.get_mut(selection) {
xform.rotate_axis(axis_parent_space.into(), theta);
}
}
+7 -1
View File
@@ -10,7 +10,7 @@
"importHelpers": true,
"target": "es2015",
"module": "esnext",
"lib": ["es2020", "dom"],
"lib": ["es2021", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
@@ -19,16 +19,22 @@
"@storyteller/studio": ["dist/studio"],
"@storyteller/studio-ui": ["studio-ui/src/index.ts"],
"@storyteller/studio-ui/button": ["studio-ui/src/lib/button.element.ts"],
"@storyteller/studio-ui/dialog": ["studio-ui/src/lib/dialog/index.ts"],
"@storyteller/studio-ui/icon": ["studio-ui/src/lib/icon.element.ts"],
"@storyteller/studio-ui/menu": ["studio-ui/src/lib/menu.element.ts"],
"@storyteller/studio-ui/overlay-provider": ["studio-ui/src/lib/overlay-provider.element.ts"],
"@storyteller/studio-ui/progress": ["studio-ui/src/lib/progress.element.ts"],
"@storyteller/studio-ui/toolbar": ["studio-ui/src/lib/toolbar.element.ts"],
"@storyteller/studio-ui/tree": ["studio-ui/src/lib/tree.element.ts"],
"@storyteller/studio-ui/forms/text-field": ["studio-ui/src/lib/forms/text-field.element.ts"],
"@storyteller/studio-ui/forms/scalar-field": ["studio-ui/src/lib/forms/scalar-field.element.ts"],
"@storyteller/studio-web": ["studio-web/src/index.ts"],
"@storyteller/studio-web/add-to-scene-menu": ["studio-web/src/lib/add-to-scene-menu.element.ts"],
"@storyteller/studio-web/canvas-provider": ["studio-web/src/lib/canvas-provider.element.ts"],
"@storyteller/studio-web/entity-inspector": ["studio-web/src/lib/inspector/entity-inspector.element.ts"],
"@storyteller/studio-web/iframe-messaging-bridge": ["studio-web/src/lib/iframe-messaging-bridge.element.ts"],
"@storyteller/studio-web/loading": ["studio-web/src/lib/loading.element.ts"],
"@storyteller/studio-web/media-library-dialog": ["studio-web/src/lib/media-library-dialog.element.ts"],
"@storyteller/studio-web/scene-data-provider": ["studio-web/src/lib/scene-data-provider.element.ts"],
"@storyteller/studio-web/scene-hierarchy": ["studio-web/src/lib/scene-hierarchy.element.ts"],
"@storyteller/studio-web/studio": ["studio-web/src/lib/studio.element.ts"],
+2
View File
@@ -1,3 +1,5 @@
export * from "./lib/array";
export * from "./lib/assert";
export * from "./lib/exists";
export * from "./lib/match";
export * from "./lib/types";
+20
View File
@@ -0,0 +1,20 @@
export type ArrayOrSingle<T> = T | T[];
export function asArray<T>(value: ArrayOrSingle<T> | Iterable<T>): T[] {
if (Array.isArray(value))
return value;
if (isIterable<T>(value))
return Array.from(value);
return [value];
}
export function isIterable<T = unknown>(value: unknown): value is Iterable<T> {
return (
value != null
&& typeof value === "object"
&& Symbol.iterator in value
&& typeof value[Symbol.iterator] === "function"
)
}
+8
View File
@@ -0,0 +1,8 @@
export function assert(expression: unknown, message?: string): asserts expression {
if (!expression) {
let err = "Assertion failed";
if (message) err += `: ${message}`;
throw new Error(err);
}
}
+3
View File
@@ -16,6 +16,9 @@ export interface Method<T, Args extends any[] = any[], R = any> {
(this: T, ...args: Args): R;
}
export type Pred<T> = Fn<[T], boolean>;
export type IndexedPred<T> = Fn<[T, number], boolean>;
export type Opt<T> = T | null | undefined;
export type WithOpt<T, K extends keyof T>
+1
View File
@@ -46,6 +46,7 @@ const mimeTypes = {
".otf": "font/otf",
".png": "image/png",
".rar": "application/x-rar-compressed",
".ron": "text/ron",
".svg": "image/svg+xml",
".tiff": "image/tiff",
".ttf": "font/ttf",