Merge branch 'main' into update-version

This commit is contained in:
rewin
2024-03-29 20:07:42 +03:00
31 changed files with 1197 additions and 464 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ import { getMethodDescriptor, prependRoutine } from "./internal";
* The event name can be prefixed with `window:` or `document:` to add the
* listener to one of those targets instead of the custom element itself.
*/
export function on(eventSelector: string) {
export function on(eventSelector: string, options?: AddEventListenerOptions) {
const [targetName, eventName] = eventSelector.includes(":")
? eventSelector.split(":")
: [null, eventSelector];
@@ -34,9 +34,9 @@ export function on(eventSelector: string) {
this[$handler] ??= handler.bind(this);
if (target) {
target.addEventListener(eventName, this[$handler]);
target.addEventListener(eventName, this[$handler], options);
} else {
this.addEventListener(eventName, this[$handler]);
this.addEventListener(eventName, this[$handler], options);
}
});
@@ -1,8 +1,30 @@
:host {
display: grid;
grid-template-rows: subgrid;
grid-template-columns: subgrid;
}
.timeline-controls {
align-self: end;
justify-self: center;
display: flex;
justify-content: center;
grid-column: center-toolbar;
grid-row: bottom-toolbar;
background-color: var(--color-toolbar-background);
padding: var(--padding);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: var(--corner-radius);
border-top-left-radius: var(--corner-radius);
}
.timeline-wrapper {
grid-area: timeline;
display: grid;
border-radius: 8px;
background: #0006;
backdrop-filter: blur(16px);
background: var(--color-panel-background);
grid-template-columns:
[sidebar-start] min-content [sidebar-end
divider-start] 8px [divider-end
+11 -7
View File
@@ -323,14 +323,9 @@ export class AnimEditorElement extends LitElement
const none_selected = !selected;
return html`
<div class="sidebar"
style=${styleMap({
width: `${this.sidebarWidth}px`,
...timeline_css_variables
})}
>
<div class="timeline-controls">
<sts-toolbar>
<sts-toolbar-group>
<sts-toolbar-group>
${this.keyingMode === "automatic" ? html`
<sts-button class="btn-autokey active"
icon="record"
@@ -387,6 +382,14 @@ export class AnimEditorElement extends LitElement
></sts-button>
</sts-toolbar-group>
</sts-toolbar>
</div>
<div class="timeline-wrapper">
<div class="sidebar"
style=${styleMap({
width: `${this.sidebarWidth}px`,
...timeline_css_variables
})}
>
<div class="tracklist" role="list">
${tracklist}
</div>
@@ -409,6 +412,7 @@ export class AnimEditorElement extends LitElement
.track_ordered=${track_order_hashed}
.track_animation_target_ordered=${track_animation_target_order_hashed}
></sts-anim-timeline>
</div>
`
};
};
@@ -4,6 +4,7 @@
display: grid;
grid-template-rows: subgrid;
grid-row: 1 / span var(--row-count);
pointer-events: all;
}
*:focus-visible {
+141 -102
View File
@@ -5,120 +5,157 @@ $hierarchy-width: 290px;
.app-root {
position: fixed;
inset: 0;
--sidebar-width: 300px;
--timeline-height: max-content;
}
// Layout utilities
.v-start {
position: absolute;
top: 16px;
}
.v-end {
position: absolute;
bottom: 16px;
}
.v-after-toolbar {
position: absolute;
top: 60px;
}
.h-start { left: 16px }
.h-end { right: 16px }
.h-center {
left: 50%;
transform: translateX(-50%);
.wrapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
pointer-events: none;
grid-template-columns: [timeline-start screen-start] 2rem [left-toolbar-start help-start] max-content [left-toolbar-end] auto [center-toolbar-start] max-content [center-toolbar-end] auto [right-toolbar-start] max-content [right-toolbar-end] 2rem [screen-end help-end sidebar-start] var(--sidebar-width) [sidebar-end timeline-end];
grid-template-rows: [top-toolbar-start sidebar-start screen-start] min-content [top-toolbar-end help-start sidebar-view-start] max-content [help-end] auto [bottom-toolbar-start] max-content [bottom-toolbar-end sidebar-end sidebar-view-end screen-end timeline-start] var(--timeline-height) [timeline-end];
.studio-scene {
position: fixed;
bottom: 0;
left: 0;
right: 0;
pointer-events: all;
}
.action-toolbar {
grid-column: left-toolbar;
grid-row: top-toolbar;
padding-top: var(--padding);
sts-toolbar-group {
gap: var(--padding);
}
sts-button {
background-color: var(--color-toolbar-background);
}
z-index: 1;
}
.timeline {
grid-row-start: bottom-toolbar-start;
grid-row-end: timeline-end;
grid-column: timeline;
z-index: 1;
border-radius: 0;
}
.sidebar {
display: grid;
grid-area: sidebar;
grid-template-rows: subgrid;
z-index: 1;
background-color: var(--color-panel-background);
border-radius: 0;
position: relative;
padding: var(--padding);
padding-left: 0.5rem;
.resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.5rem;
background: #0004;
transition: 66.666ms background linear;
&:hover {
background: #FFF1;
}
}
.sidebar-header {
grid-row: top-toolbar;
display: flex;
flex-direction: row;
justify-content: stretch;
gap: 0;
.sidebar-tab {
border: none;
background: none;
border-bottom: 2px var(--color-toolbar-background) solid;
flex-grow: 1;
border-radius: 0;
&.current-tab {
color: var(--color-text-bright);
border-bottom-color: var(--color-accent);
}
}
}
.sidebar-view {
grid-row: sidebar-view;
}
}
.help {
grid-area: help;
z-index: 1;
display: flex;
flex-direction: row;
justify-content: start;
}
.entity-inspector {
grid-column: left-toolbar;
grid-row: bottom-toolbar;
z-index: 1;
padding-bottom: var(--padding);
& > * {
pointer-events: all;
}
}
.transform-toolbar {
grid-column: center-toolbar;
grid-row: top-toolbar;
background-color: var(--color-toolbar-background);
z-index: 1;
border-top-right-radius: 0;
border-top-left-radius: 0;
border-bottom-right-radius: var(--corner-radius);
border-bottom-left-radius: var(--corner-radius);
padding: var(--padding);
display: flex;
flex-direction: row;
gap: 1rem;
.add-to-scene-dropdown {
background-color: var(--color-toolbar-background-light);
font-size: 16px;
padding: 10px 12px;
}
}
}
.studio-scene.block-pointer-events::part(canvas) {
pointer-events: none;
}
sts-button.active {
background: #7A2C34E3;
color: #FFF;
}
// TODO: Probably move this
.inspector-open {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 24px;
appearance: none;
border: none;
background: #0006;
backdrop-filter: blur(16px);
color: #FFFC;
transition: {
property: background-color, color;
duration: 100ms;
timing-function: linear;
}
cursor: pointer;
font-size: 20px;
&:hover {
background: #0008;
color: #FFFF;
}
}
.inspector {
display: flex;
flex-direction: column;
border-radius: 0.5rem;
border: 0.5rem solid #0004 {
block-start: none;
inline-start: none;
}
padding: {
block-start: 2rem;
inline-start: 0.5rem;
}
background: #0006;
backdrop-filter: blur(16px);
&__hierarchy {
flex: 0 0 calc(50% - 0.25rem);
}
&__entity {
flex: 1 0 0;
height: 0;
}
.resize-handle {
background: #0004;
transition: 66.666ms background linear;
&:hover {
background: #FFF1;
}
&--h-start {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0.5rem;
cursor: ew-resize;
}
&--v-center {
height: 0.5rem;
cursor: ns-resize
}
}
}
.inspector-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 2rem;
padding: 0 {
inline-start: 0.75rem;
}
@@ -133,6 +170,7 @@ sts-button.active {
&__label {
margin: 0;
color: #FFF9;
font: {
family: "Fira Sans", sans-serif;
size: math.div(15, 16) * 1rem;
@@ -151,6 +189,7 @@ sts-button.active {
background: transparent;
border: none;
color: #FFFC;
transition: {
property: color, background-color;
duration: 66.666ms;
@@ -170,4 +209,4 @@ sts-button.active {
.menu-button-icon {
font-size: 12px;
opacity: 0.75;
}
}
+75 -120
View File
@@ -14,7 +14,7 @@ import {
StudioURLParams,
} from "@storyteller/studio-web";
import { match } from "@storyteller/utility";
import { LitElement, html, nothing } from "lit";
import { LitElement, TemplateResult, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref } from "lit/directives/ref.js";
@@ -48,14 +48,15 @@ import { mock_save_load } from "./util";
})
export class AppElement extends LitElement {
@state() showHelp = false;
@state() showInspector = false;
@state() loading = true;
@state() resizingPanel = false;
@state() inspectorWidth = 320;
@state() hierarchyHeight?: number;
#enableAnimTimeline = true; // TODO
@state() sidebars: Array<{ title: string, name: string}> = [{title: 'Hierarchy', name: 'hierarchy'}];
@state() currentSidebar : string = 'hierarchy';
private api = new StorytellerApi();
#addToSceneButtonRef = createRef<ButtonElement>();
@@ -75,9 +76,9 @@ export class AppElement extends LitElement {
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
// 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
this for your testing. We probably just need a local definition file so the
builds are happy.
let handle = await window.showSaveFilePicker({
@@ -120,7 +121,7 @@ export class AppElement extends LitElement {
onSceneStateChange({ detail: state }: SceneStateEvent): void {
console.log("scene state change:", SceneState[state]);
this.loading = match (state, {
this.loading = match(state, {
[SceneState.Active]: () => false,
_: () => true,
});
@@ -139,41 +140,43 @@ export class AppElement extends LitElement {
this.inspectorWidth = Math.max(Math.min(updated, 1024), 128);
}
resizeHierarchyHeight(event: PointerEvent): void {
let availableHeight = this.#inspectorRef.value?.clientHeight;
if (availableHeight == null) return;
// Account for:
// - `section.inspector` padding (2rem ~= 32px)
// - resize-handle (0.5rem ~= 8px)
// - minimal space for `sts-entity-inspector` (2rem ~= 32px)
availableHeight -= 72;
const height = this.hierarchyHeight ?? this.#hierarchyRef.value?.offsetHeight;
if (height == null) return;
const updated = height + event.movementY;
this.hierarchyHeight = Math.max(Math.min(updated, availableHeight), 8);
}
minimizeInspector(): void {
this.showInspector = false;
}
openInspector(): void {
this.showInspector = true;
}
toggleHelp(): void {
this.showHelp = !this.showHelp;
}
renderSidebar(): TemplateResult<1> {
if (this.currentSidebar === 'hierarchy') {
return html`<sts-scene-hierarchy
${ref(this.#hierarchyRef)}
class="hierarchy sidebar-view"
></sts-scene-hierarchy>`;
}
return html`<></>`;
}
protected override render() {
return html`
<sts-overlay-provider>
<sts-canvas-provider class="app-root">
<sts-scene-data-provider>
<sts-overlay-provider>
<sts-iframe-messaging-bridge></sts-iframe-messaging-bridge>
${this.#params.mode === StudioMode.Editor ? html`
<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"
nonBlocking
></sts-dialog>
` : html`<></>`}
<div class="wrapper" style=${styleMap({ "--sidebar-width": `${this.inspectorWidth}px` })}>
<!-- Scene view -->
<sts-studio
class=${classMap({
@@ -190,29 +193,17 @@ export class AppElement extends LitElement {
></sts-studio>
<!-- Actions toolbar -->
<sts-toolbar class="v-start h-start">
<sts-toolbar class="action-toolbar">
<sts-toolbar-group>
${this.#params.mode === StudioMode.Editor ? html`
<sts-button
icon="floppy-disk"
@click=${() => studio.saveScene()}
>
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>
Save Scene
</sts-button>
` : nothing}
<sts-button
class=${classMap({ active: this.showHelp })}
icon="question"
@click=${this.toggleHelp}
>
Help
@@ -222,105 +213,69 @@ export class AppElement extends LitElement {
<!-- Studio-mode-only elements -->
${this.#params.mode === StudioMode.Editor ? html`
<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>
<div class="transform-toolbar">
<sts-button
${ref(this.#addToSceneButtonRef)}
icon="plus-large"
class="add-to-scene-dropdown"
>
</sts-button>
<sts-transform-toolbar></sts-transform-toolbar>
</div>
<sts-transform-toolbar class="v-start h-center"></sts-transform-toolbar>
${this.showInspector ? html`
<section
${ref(this.#inspectorRef)}
class="inspector h-end v-start v-end"
class="sidebar"
style=${styleMap({
width: `${this.inspectorWidth}px`,
})}
aria-labelledby="inspector-heading"
width: `${this.inspectorWidth}px`,
})}
aria-labelledby="sidebar-heading"
>
<header class="inspector-header">
<h3 class="inspector-header__label"
id="inspector-heading"
>
Inspector
</h3>
<button class="inspector-header__minimize"
aria-label="Minimize inspector"
title="Minimize inspector"
@click=${this.minimizeInspector}
>
<sts-icon icon="minus"></sts-icon>
</button>
</header>
<div class="sidebar-header">
${this.sidebars.map(({title, name}) => {
return html`<sts-button class=${classMap({"current-tab": name === this.currentSidebar, "sidebar-tab": true})}>${title}</sts-button>`
})}
</div>
<sts-scene-hierarchy
${ref(this.#hierarchyRef)}
class="inspector__hierarchy"
style=${styleMap({
flex: this.hierarchyHeight != null
? `0 0 ${this.hierarchyHeight}px`
: null,
})}
></sts-scene-hierarchy>
${this.renderSidebar()}
<div
class="resize-handle resize-handle--v-center"
class="resize-handle"
${drag({
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeHierarchyHeight,
})}
></div>
<sts-entity-inspector class="inspector__entity"></sts-entity-inspector>
<div
class="resize-handle resize-handle--h-start"
${drag({
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeInspectorWidth,
})}
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeInspectorWidth,
})}
></div>
</section>
` : html`
<button
class="inspector-open h-end v-start"
aria-label="Open scene inspector"
title="Open scene inspector"
@click=${this.openInspector}
>
<sts-icon icon="bars"></sts-icon>
</button>
`}
${this.#enableAnimTimeline ? html`
<div class="entity-inspector">
<sts-entity-inspector></sts-entity-inspector>
</div>
<sts-anim-editor
class="timeline v-end h-start"
class="timeline"
style=${styleMap({
right: `${this.inspectorWidth + 32}px`
})}
right: `${this.inspectorWidth + 32}px`
})}
></sts-anim-editor>
` : nothing}
` : nothing}
<!-- Dialogs and modals -->
${this.showHelp ? html`
<sts-help class="help v-after-toolbar h-start"></sts-help>
<div class="help">
<sts-help></sts-help>
</div>
` : nothing}
</div>
${this.loading ? html`
<sts-loading></sts-loading>
` : nothing}
</sts-overlay-provider>
</sts-scene-data-provider>
</sts-canvas-provider>
</sts-overlay-provider>
`;
}
}
+13
View File
@@ -7,4 +7,17 @@ html, body {
height: 100%;
margin: 0;
overflow: hidden;
--color-panel-background: #1e1e2c;
--color-toolbar-background: #2c2c3b;
--color-toolbar-background-light: #595970;
--color-panel-item-name-background: #39394d;
--color-panel-item-background: #55556b;
--color-accent: #fc6b68;
--color-text: #dbdbdf;
--color-text-bright: #ffffff;
--color-text-disabled: #7c7c85;
--corner-radius: 0.75rem;
--padding: 0.4rem;
}
@@ -10,8 +10,9 @@
background: #0006;
color: #FFFC;
backdrop-filter: blur(32px);
border-radius: 0.5rem;
border-radius: 1rem;
overflow: hidden;
pointer-events: auto;
}
.main {
@@ -22,13 +23,23 @@
.content {
display: flex;
flex-direction: column;
flex: 0 0 auto;
flex: 1 0 0;
gap: 1rem;
border: {
inline: 0.5rem solid #0004;
block-end: 0.5rem solid #0004;
bottom-right-radius: 1rem;
bottom-left-radius: 1rem;
}
padding: 1rem;
&.with-footer {
border: {
block-end: none;
bottom-right-radius: 0;
bottom-left-radius: 0;
}
}
}
[part="sidebar"]:not(:empty) {
@@ -36,6 +47,9 @@
background: #0004;
+ .content {
border-inline-start: none;
border: {
inline-start: none;
bottom-left-radius: 0;
}
}
}
@@ -1,6 +1,7 @@
import { on } from "@storyteller/framework";
import { LitElement, TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property, queryAssignedElements } from "lit/decorators.js";
import { LitElement, PropertyValues, TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property, queryAssignedElements, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { v4 as uuid } from "uuid";
import { type DialogHeaderElement } from "./dialog-header.element";
@@ -62,14 +63,37 @@ export class DialogBaseElement extends LitElement {
override ariaModal = "true";
override tabIndex = -1;
@state() _hasFooter = false;
@queryAssignedElements({ slot: "dialog-header" })
dialogHeaders!: Element[];
@queryAssignedElements({ slot: "footer" })
dialogFooters!: Element[];
@state() _translateX?: number;
@state() _translateY?: number;
override connectedCallback(): void {
this.focus();
super.connectedCallback();
}
protected override update(changes: PropertyValues<this>): void {
if (
changes.has("_translateX") || changes.has("_translateY")
&& (this._translateX != null || this._translateY != null)
) {
this.style.setProperty(
"transform",
`translateX(${this._translateX ?? 0}px) ` +
`translateY(${this._translateY ?? 0}px)`
);
}
super.update(changes);
}
@on("keydown")
onKeydown(event: KeyboardEvent): void {
if (/^(Escape|Shift)$/.test(event.key))
@@ -79,6 +103,12 @@ export class DialogBaseElement extends LitElement {
this.dispatchEvent(new CustomEvent("close"));
}
@on("translate", { capture: true })
onTranslate({ detail: { x, y }}: CustomEvent<{ x: number; y: number }>): void {
this._translateX = x;
this._translateY = y;
}
onDialogHeaderChanged(): void {
if (this.dialogHeaders.length && !this.ariaLabel) {
const header = this.dialogHeaders[0];
@@ -90,6 +120,10 @@ export class DialogBaseElement extends LitElement {
}
}
onFooterChanged(): void {
this._hasFooter = this.dialogFooters.length > 0;
}
protected override render(): TemplateResult {
return html`
<slot
@@ -107,12 +141,21 @@ export class DialogBaseElement extends LitElement {
<section class="main" part="main">
<slot name="sidebar" part="sidebar"></slot>
<section class="content" part="content">
<section
class=${classMap({
"content": true,
"with-footer": this._hasFooter,
})}
part="content"
>
<slot></slot>
</section>
</section>
<slot name="footer"></slot>
<slot
name="footer"
@slotchange=${this.onFooterChanged}
></slot>
`;
}
}
@@ -0,0 +1,13 @@
:host {
box-sizing: border-box;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0 1rem 1rem;
border: {
inline: 0.5rem solid #0004;
block-end: 0.5rem solid #0004;
bottom-right-radius: 1rem;
bottom-left-radius: 1rem;
}
}
@@ -0,0 +1,16 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import styles from "./dialog-footer.element.scss?inline";
@customElement("sts-dialog-footer")
export class DialogFooterElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ reflect: true })
override slot = "footer";
protected override render = () => html`
<slot></slot>
`;
}
@@ -3,18 +3,18 @@
:host {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem;
padding: 0 {
inline-start: 1rem;
}
background: #0006;
}
.title {
flex: 1 0 0;
display: flex;
height: 3rem;
align-items: center;
gap: math.div(12, 16) * 1rem;
padding-inline-start: 1rem;
user-select: none;
&__icon {
color: #FFFC;
@@ -1,3 +1,4 @@
import { drag } from "@storyteller/framework";
import { LitElement, html, nothing, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { v4 as uuid } from "uuid";
@@ -53,6 +54,9 @@ export class DialogHeaderElement extends LitElement {
@property() icon?: string;
#translateX?: number;
#translateY?: number;
onClose(): void {
this.dispatchEvent(new CustomEvent("close", {
bubbles: true,
@@ -60,10 +64,30 @@ export class DialogHeaderElement extends LitElement {
}));
}
onDrag(event: PointerEvent): void {
this.#translateX ??= 0;
this.#translateY ??= 0;
this.#translateX += event.movementX;
this.#translateY += event.movementY;
this.dispatchEvent(new CustomEvent("translate", {
detail: {
x: this.#translateX,
y: this.#translateY,
},
bubbles: true,
composed: true,
}));
}
protected override render = () => html`
<div
class="title"
part="title"
${drag({
move: this.onDrag,
})}
>
${this.icon ? html`
<sts-icon
+18 -3
View File
@@ -4,6 +4,7 @@ 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 { StyleInfo } from "lit/directives/style-map.js";
import { Ref } from "lit/directives/ref.js";
import {
@@ -293,6 +294,14 @@ export class DialogElement extends LitElement {
@property({ type: Boolean })
ignoreBackdropClick = false;
/**
* If this property is `true`, the dialog backdrop will be completely
* transparent and allow the user to interact with background elements while
* the dialog is still present.
*/
@property({ type: Boolean })
nonBlocking = false;
/**
* ARIA role to pass to the custom element defined by `tag`.
*
@@ -318,12 +327,18 @@ export class DialogElement extends LitElement {
if (!this.tag && !this.template)
return;
const containerStyles = {
const containerStyles: StyleInfo = {
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#1A1A2766",
};
if (this.nonBlocking) {
containerStyles["pointer-events"] = "none";
} else {
containerStyles["background"] = "#1A1A2766";
}
const onBackdropClick = this.onBackdropClick;
if (this.template) {
@@ -393,7 +408,7 @@ export class DialogElement extends LitElement {
}
onBackdropClick = (): void => {
if (!this.ignoreBackdropClick)
if (!this.ignoreBackdropClick && !this.nonBlocking)
this.close();
}
}
+2
View File
@@ -1,7 +1,9 @@
import "./dialog-base.element";
import "./dialog-footer.element";
import "./dialog-header.element";
import "./dialog.element";
export * from "./dialog.element";
export * from "./dialog-base.element";
export * from "./dialog-footer.element";
export * from "./dialog-header.element";
@@ -1,5 +1,5 @@
import { on } from "@storyteller/framework";
import { html, nothing, unsafeCSS } from "lit";
import { PropertyValues, html, nothing, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -27,6 +27,13 @@ export class TextFieldElement extends FormFieldBaseElement {
@property({ attribute: "icon-pre" }) iconPre?: string;
@property({ attribute: "icon-post" }) iconPost?: string;
protected override update(changes: PropertyValues<this>): void {
if (changes.has("value"))
this.internals.setFormValue(this.value);
super.update(changes);
}
@on("keydown")
@on("keyup")
blockKeypressBubbling(event: KeyboardEvent): void {
+3 -1
View File
@@ -44,6 +44,7 @@ import farArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/ar
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 fasUpload from "@fortawesome/fontawesome-pro/svgs/solid/upload.svg?raw";
import customCylinder from "../assets/icons/cylinder.svg?raw";
import customSphere from "../assets/icons/sphere.svg?raw";
@@ -105,6 +106,7 @@ function renderSvg(iconId: string) {
"pause": () => fasPause,
"plus-large": () => fasPlusLarge,
"question": () => fasQuestion,
"record": () => fasRecord,
"rotate": () => farRotate,
"scale": () => farScale,
"scene": () => farMountainSun,
@@ -113,10 +115,10 @@ function renderSvg(iconId: string) {
"sphere": () => customSphere,
"torus": () => customTorus,
"up-down-left-right": () => farUpDownLeftRight,
"upload": () => fasUpload,
"video": () => farVideo,
"volume": () => farVolume,
"xmark": () => fasXmark,
"record": () => fasRecord,
_: () => null,
});
+1 -1
View File
@@ -4,7 +4,7 @@
}
::slotted(*) {
border-radius: 0;
border-radius: var(--corner-radius);
}
::slotted(:first-child) {
@@ -0,0 +1,60 @@
@use "sass:math";
*, *::before, *::after {
box-sizing: border-box;
}
:host {
display: contents;
}
.dialog {
min-width: 480px;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.label {
color: #FFF9;
font-size: math.div(15, 16) * 1rem;
}
// TODO: Make a component for this
select.input {
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: #0004;
border: 1px solid #FFF2;
color: #FFFC;
transition: {
property: background-color, border-color, color;
duration: 33.333ms;
timing-function: linear;
}
&:focus {
outline: none;
border-color: #FFF4;
background: #0006;
color: #FFFF;
}
}
option {
color-scheme: dark !important;
background-color: #000000;
color: #FFFC;
&:disabled {
color: #FFF6;
}
}
@@ -0,0 +1,231 @@
import { bind } from "@storyteller/framework";
import { LitElement, PropertyValues, TemplateResult, html, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import { MediaFileClass, MediaFileSubtype } from "./storyteller-api";
import "@storyteller/studio-ui/dialog";
import styles from "./asset-import-dialog.element.scss?inline";
export interface AssetImport {
type: MediaFileClass;
title: string;
animationSkeleton?: AnimationSkeleton;
}
export type AnimationSkeleton
= MediaFileSubtype.Mixamo
| MediaFileSubtype.MocapNet
| MediaFileSubtype.AnimationOnly
;
@customElement("sts-asset-import-dialog")
export class AssetImportDialogElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ attribute: false })
file?: File;
@property()
assetType?: MediaFileClass;
@state() _title = "";
@state() _animationSkeleton?: AnimationSkeleton;
@state() _formValid = false;
#formRef = createRef<HTMLFormElement>();
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("file") && !this._title) {
this._title = this.file?.name.match(/^(.+?)\./)?.[1] ?? "";
}
super.willUpdate(changes);
}
protected override updated(changes: PropertyValues<this>): void {
this._formValid = this.#formRef.value?.checkValidity() ?? false;
super.updated(changes);
}
onFormSubmit(event: SubmitEvent): void {
console.log("AssetImportDialog.onFormSubmit", { ...event });
event.preventDefault();
event.stopPropagation();
const data = new FormData(event.target as HTMLFormElement);
const dataObject = Object.fromEntries(data.entries()) as AssetImport;
this.dispatchEvent(new CustomEvent<AssetImport>("import-asset", { detail: dataObject }));
}
onAssetTypeChange(event: Event): void {
const input = event.target as HTMLSelectElement;
if (!input.value) {
this.assetType = undefined;
} else {
this.assetType = input.value as MediaFileClass;
}
}
onAnimationSkeletonChange(event: Event): void {
const input = event.target as HTMLSelectElement;
if (!input.value) {
this._animationSkeleton = undefined;
} else {
this._animationSkeleton = input.value as AnimationSkeleton;
}
}
protected override render = (): TemplateResult => html`
<sts-dialog-base class="dialog">
<sts-dialog-header icon="import">
Import Asset
</sts-dialog-header>
<form
${ref(this.#formRef)}
class="form"
action=""
@submit=${this.onFormSubmit}
>
<div class="form-field">
<label class="label" for="import-asset-title">
Name
</label>
<sts-text-field
class="input"
id="import-asset-title"
autofocus
name="title"
.value=${bind(this, "_title")}
></sts-text-field>
</div>
<div class="form-field">
<label class="label" for="import-asset-type">
Asset Type
</label>
<select
class="input"
id="import-asset-type"
name="type"
required
@change=${this.onAssetTypeChange}
>
<option
value=""
?selected=${this.assetType == null}
></option>
${ASSET_TYPES.map(({ type, label, icon, disabled, tooltip }) => html`
<option
value=${type}
?selected=${this.assetType === type}
?disabled=${disabled}
title=${ifDefined(tooltip)}
>
<sts-icon .icon=${icon}></sts-icon>
${label}
</option>
`)}
</select>
</div>
${this.assetType === MediaFileClass.Animation ? html`
<label class="label" for="import-animation-skeleton">
Skeleton Type
</label>
<select
class="input"
id="import-animation-skeleton"
name="animationSkeleton"
required
@change=${this.onAnimationSkeletonChange}
>
<option value="" selected></option>
<option
value=${MediaFileSubtype.Mixamo}
title="For animations downloaded from Adobe Mixamo"
>
Mixamo
</option>
<option
value=${MediaFileSubtype.MocapNet}
title="For motion capture data produced by MocapNET"
>
MocapNET
</option>
<option
value=${MediaFileSubtype.AnimationOnly}
title=${"For animations that are designed to play on a Rigify "
+ "skeleton (deformation bones only)"}
>
Rigify (Advanced)
</option>
</select>
` : nothing}
</form>
<sts-dialog-footer>
<sts-button
icon="upload"
?disabled=${!this._formValid}
@click=${() => {
this.#formRef.value?.requestSubmit();
}}
>
Upload
</sts-button>
</sts-dialog-footer>
</sts-dialog-base>
`;
}
interface AssetType {
type: MediaFileClass;
label: string;
icon: string;
disabled?: boolean;
tooltip?: string;
}
const ASSET_TYPES: readonly AssetType[] = [
{
type: MediaFileClass.Animation,
label: "Animation",
icon: "film",
},
{
type: MediaFileClass.Audio,
label: "Audio",
icon: "volume",
disabled: true,
tooltip: "Coming soon!",
},
{
type: MediaFileClass.Character,
label: "Character",
icon: "figure",
},
{
type: MediaFileClass.Prop,
label: "Prop",
icon: "cube",
},
{
type: MediaFileClass.Scene,
label: "Scene",
icon: "scene",
},
];
declare global {
interface FormData {
// I have no idea why this method is not included in the TS DOM library,
// but it isn't.
entries(): ReturnType<typeof Object.entries>
}
}
@@ -3,10 +3,11 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
gap: var(--padding);
overflow: auto;
--background-color: var(--color-panel-background);
color: #FFFC;
&::-webkit-scrollbar {
@@ -31,3 +32,13 @@
*, *::before, *::after {
box-sizing: border-box;
}
.container {
background-color: var(--background-color);
border-radius: var(--corner-radius);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--padding);
gap: var(--padding);
}
@@ -34,63 +34,71 @@ export class EntityInspectorElement extends LitElement {
this.requestUpdate();
}
protected override render = () => html`
${this._sceneData
?.selectionComponents
.slice()
.sort((a, b) => {
const ordA = COMPONENT_SORT_ORDER.indexOf(a.name);
const ordB = COMPONENT_SORT_ORDER.indexOf(b.name);
protected override render () {
let components = this._sceneData
?.selectionComponents
.slice()
.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;
if (ordA === -1) return Infinity;
if (ordB === -1) return -Infinity;
return ordA - ordB;
})
.map(({ name, value }) => {
switch (name) {
case "bevy_core::name::Name": {
return html`
<sts-name-inspector
.value=${value.name}
></sts-name-inspector>
`;
}
case "bevy_transform::components::transform::Transform": {
return html`
<sts-transform-inspector
.translation=${value.translation}
.rotation=${value.rotation}
.scale=${value.scale}
></sts-transform-inspector>
`;
}
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`
<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>
// `;
}
return ordA - ordB;
})
.map(({ name, value }) => {
switch (name) {
case "bevy_core::name::Name": {
return html`
<sts-name-inspector
.value=${value.name}
></sts-name-inspector>
`;
}
})
case "bevy_transform::components::transform::Transform": {
return html`
<sts-transform-inspector
.translation=${value.translation}
.rotation=${value.rotation}
.scale=${value.scale}
></sts-transform-inspector>
`;
}
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`
<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>
// `;
}
}
}).filter((value) => value !== nothing) || [];
if (components?.length === 0) {
return nothing;
}
return html`
<div class="container">
${components
}
</div>
`;
}
}
const COMPONENT_SORT_ORDER: Array<InspectorComponent["name"]> = [
@@ -1,6 +1,7 @@
import * as studio from "@storyteller/studio";
import { SceneElement } from "@storyteller/studio";
import { bind, inject } from "@storyteller/framework";
import { ButtonElement } from "@storyteller/studio-ui/button";
import { DialogBaseElement } from "@storyteller/studio-ui/dialog";
import { DialogBaseElement, DialogElement } from "@storyteller/studio-ui/dialog";
import {
type MediaFile as IMediaFile,
MediaFileClass,
@@ -9,8 +10,11 @@ import {
MediaFileType,
StorytellerApi,
Visibility,
UploadMediaFileOptions,
SCENE_DATA_PROVIDER,
type SceneDataProvider,
} from "@storyteller/studio-web";
import { match, type Pred } from "@storyteller/utility";
import { exists, match, type Pred } from "@storyteller/utility";
import {
html,
nothing,
@@ -19,13 +23,17 @@ import {
unsafeCSS,
css,
} from "lit";
import { customElement, queryAll, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import { AssetImport } from "./asset-import-dialog.element";
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/icon";
import "@storyteller/studio-ui/forms/text-field";
import "./asset-import-dialog.element";
import styles from "./media-library-dialog.element.scss?inline";
@@ -38,7 +46,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
override heading = "Media Library";
@state() _mediaFiles?: MediaFile[];
@state() _mediaFiles?: Map<MediaFileToken, MediaFile>;
@state() _searchTerm = "";
@state() _selected: MediaFileToken[] = [];
@state() _activeCategory: MediaFileCategory = MEDIA_FILE_CATEGORIES[0];
@@ -48,13 +56,20 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
@inject(StorytellerApi)
_api!: StorytellerApi;
@queryAll("[data-import-trigger]")
importTriggers!: ButtonElement[];
@inject(SCENE_DATA_PROVIDER)
_sceneData!: SceneDataProvider;
#dateFmt = new Intl.DateTimeFormat("en-us");
#importDialogRef = createRef<DialogElement>();
#transientFileInput?: HTMLInputElement;
@state() _uploadedFile?: File;
get #mediaFiles() {
return this._mediaFiles?.filter(it =>
if (!this._mediaFiles)
return null;
return Array.from(this._mediaFiles.values()).filter(it =>
this._filters.every(pred => pred(it))
);
}
@@ -70,11 +85,41 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
return ResultsStatus.NonEmpty;
}
get #canAddToScene() {
const noAnimations = this._selected.every(token => {
const mediaFile = this._mediaFiles?.get(token);
if (!mediaFile) return false;
return mediaFile.mediaClass !== MediaFileClass.Animation;
});
return noAnimations || this.#canAddAnimation;
}
get #canAddAnimation() {
const oneAnimationSelected = this._selected.length === 1
&& this._mediaFiles?.get(this._selected[0])?.mediaClass === MediaFileClass.Animation;
const skeletonSelected = this._sceneData.selectedObject?.type === SceneElement.Skeleton;
return oneAnimationSelected && skeletonSelected;
}
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this.onProviderChanges);
this.loadResults();
super.connectedCallback();
}
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("selectedObject"))
this.requestUpdate();
}
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("_activeCategory") || changes.has("_searchTerm")) {
this._filters = [
@@ -108,8 +153,11 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
const results = await Promise.all(
response.results.map(({ token }) => this._api.mediaFile(token))
);
console.log("Results:", results);
this._mediaFiles = results.map(info => new MediaFile(info));
this._mediaFiles = new Map(
results.map(info => [info.token, new MediaFile(info)])
);
}
catch (err) {
if (typeof err === "object" && err !== null && err instanceof Error) {
@@ -146,14 +194,116 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
this._selected = [];
}
get importDialogTemplate() {
return html`
<sts-dialog-header icon="import">
Import Asset
</sts-dialog-header>
promptForFileUpload(): void {
this.#transientFileInput = document.createElement("input");
this.#transientFileInput.type = "file";
this.#transientFileInput.accept = match (this._activeCategory.mediaClass, {
[MediaFileClass.Animation]: () => ".bvh,.fbx,.gltf",
[MediaFileClass.Audio]: () => ".wav,.mp3",
[MediaFileClass.Character]: () => ".fbx,.gltf",
[MediaFileClass.Prop]: () => ".fbx,.gltf",
[MediaFileClass.Scene]: () => ".fbx,.gltf,.scn.ron",
_: () => "",
});
this.#transientFileInput.multiple = false;
<p>Lorem ipsum dolor sit amet</p>
`;
this.#transientFileInput.addEventListener("input", this.onLocalFileLoaded.bind(this), {
once: true,
});
this.#transientFileInput.click();
}
onLocalFileLoaded(): void {
console.log("files:", this.#transientFileInput?.files);
const file = this.#transientFileInput?.files?.[0];
if (file != null) {
this._uploadedFile = file;
setTimeout(() => {
this.#importDialogRef.value?.open();
});
}
}
async onImportAsset({ detail: data}: CustomEvent<AssetImport>): Promise<void> {
console.log("MediaLibrary.onImportAsset", data);
this._mediaFiles = undefined;
this.#importDialogRef.value?.close();
if (!this._uploadedFile)
throw new Error("Expected `_uploadedFile` to be non-null!");
try {
const options: UploadMediaFileOptions = {
mediaClass: data.type,
};
if (data.type === MediaFileClass.Animation)
options.subtype = data.animationSkeleton;
const token = await this._api.uploadMediaFile(this._uploadedFile, options);
await this._api.renameMediaFile(token, data.title);
this._selected.push(token);
}
catch (err) {
if (typeof err === "object" && err !== null && err instanceof Error) {
this._apiError = err;
} else {
this._apiError = new Error(String(err));
}
}
this.loadResults();
}
onAddToScene(): void {
if (this.#canAddAnimation) {
this.#addAnimation();
} else {
this.#spawnSelectedScenes();
}
}
#addAnimation(): void {
// TODO: Make this actually work
console.warn("Adding animations not yet implemented!");
this.dispatchEvent(new CustomEvent("close"));
}
#spawnSelectedScenes(): void {
const assets = this._selected
.map(token => {
if (!this._mediaFiles) {
console.error("Expected `_mediaFiles` map to be present when spawning assets");
return null;
}
const mediaFile = this._mediaFiles.get(token);
if (!mediaFile) {
console.error(`Attempted to spawn an invalid token: "${token}"`);
return null;
}
const ext = mediaFile.extension;
if (!ext) {
console.error("Expected media file to have a non-null `extension` field");
return null;
}
return {
path: `remote://${token}${ext}`,
name: mediaFile.displayName,
};
})
.filter(exists);
setTimeout(() => {
studio.spawnAssets(assets);
});
this.dispatchEvent(new CustomEvent("close"));
}
protected override render = (): TemplateResult => html`
@@ -164,7 +314,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<section class="main">
<nav class="sidebar" part="sidebar">
<ul class="sidebar__list">
${MEDIA_FILE_CATEGORIES.map(cat => html`
${MEDIA_FILE_CATEGORIES.map((cat, idx) => html`
<li class="sidebar__listitem">
<sts-button
class=${classMap({
@@ -172,6 +322,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
})}
.icon=${cat.icon}
title=${ifDefined(cat.tooltip)}
?autofocus=${idx === 0}
?disabled=${cat.disabled}
@click=${() => {
if (!cat.disabled)
@@ -189,7 +340,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button
class="btn-secondary"
icon="import"
data-import-trigger
@click=${this.promptForFileUpload}
>
Import...
</sts-button>
@@ -211,6 +362,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
>
${match (this.#resultsStatus, {
[ResultsStatus.Loading]: () => html`
<!-- TODO: Spinner or something -->
<span>Loading...</span>
`,
[ResultsStatus.Empty]: () => html`
@@ -220,7 +372,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button
class="btn-secondary"
icon="import"
data-import-trigger
@click=${this.promptForFileUpload}
>
Import...
</sts-button>
@@ -262,6 +414,8 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button
class="btn-primary content__add-to-scene"
icon="plus-large"
?disabled=${!this.#canAddToScene}
@click=${this.onAddToScene}
>
Add to Scene
</sts-button>
@@ -270,8 +424,16 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
</section>
<sts-dialog
.trigger=${this.importTriggers}
.template=${[this, this.importDialogTemplate] as const}
${ref(this.#importDialogRef)}
skipWrapper
.template=${[this, html`
<sts-asset-import-dialog
.file=${this._uploadedFile}
.assetType=${this._activeCategory.mediaClass}
@import-asset=${this.onImportAsset}
@close=${() => this.#importDialogRef.value?.close()}
></sts-asset-import-dialog>
`] as const}
></sts-dialog>
`;
}
@@ -281,6 +443,7 @@ interface MediaFileCategory {
icon: string;
disabled?: boolean;
tooltip?: string;
mediaClass?: MediaFileClass;
predicate: Pred<MediaFile>;
}
@@ -293,6 +456,7 @@ const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [
{
name: "Animation",
icon: "film",
mediaClass: MediaFileClass.Animation,
predicate: it => it.mediaClass === MediaFileClass.Animation,
},
{
@@ -300,21 +464,25 @@ const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [
icon: "volume",
disabled: true,
tooltip: "Coming soon!",
mediaClass: MediaFileClass.Audio,
predicate: it => it.mediaClass === MediaFileClass.Audio,
},
{
name: "Characters",
icon: "figure",
mediaClass: MediaFileClass.Character,
predicate: it => it.mediaClass === MediaFileClass.Character,
},
{
name: "Props",
icon: "cube",
mediaClass: MediaFileClass.Prop,
predicate: it => it.mediaClass === MediaFileClass.Prop,
},
{
name: "Scenes",
icon: "scene",
mediaClass: MediaFileClass.Scene,
predicate: it => it.mediaClass === MediaFileClass.Scene,
},
];
@@ -335,6 +503,7 @@ class MediaFile {
title?: string;
filename?: string;
extension?: string;
visibility: Visibility;
bucketPath: string;
@@ -354,6 +523,9 @@ class MediaFile {
this.title = info.maybe_title ?? undefined;
this.filename = info.maybe_original_filename ?? undefined;
this.extension = info.maybe_engine_extension
?? this.filename?.match(/\..+/)?.[0]
?? undefined;
this.visibility = info.creator_set_visibility;
this.bucketPath = info.public_bucket_path;
+12 -1
View File
@@ -74,6 +74,16 @@ export class StorytellerApi {
return response.data.media_file_token;
}
async renameMediaFile(token: MediaFileToken, name: string): Promise<void> {
const response = await this.#api.post(`v1/media_files/rename/${token}`, { name });
this.#validateResponse(response);
}
// TODO
async convertFbxToGltf(token: MediaFileToken): Promise<void> {
}
async mediaFile(token: MediaFileToken): Promise<MediaFile> {
const response = await this.#api.get<MediaFileResult>(`v1/media_files/file/${token}`);
this.#validateResponse(response);
@@ -116,7 +126,7 @@ export interface ListMediaFilesOptions {
}
export interface UploadMediaFileOptions {
subtype: MediaFileSubtype;
subtype?: MediaFileSubtype;
mediaClass: MediaFileClass;
}
@@ -152,6 +162,7 @@ export interface MediaFileResult {
export interface MediaFile {
token: MediaFileToken;
media_type: MediaFileType;
maybe_engine_extension?: string;
maybe_media_subtype?: MediaFileSubtype;
media_class: MediaFileClass;
maybe_batch_token?: unknown;
@@ -8,7 +8,7 @@
pointer-events: auto;
// TODO: Copypasta
background: #0006;
background: var(--color-toolbar-background);
color: #FFFC;
backdrop-filter: blur(16px);
transition: {
@@ -16,6 +16,7 @@
duration: 0.1s;
timing-function: linear;
}
border-radius: var(--corner-radius);
&:hover {
background: #0008;
@@ -28,7 +29,9 @@
}
&:has(:checked) {
background: #7A2C34E3;
border-color: var(--color-accent);
border-width: 2px;
border-style: solid;
color: #FFF;
&:hover {
+7 -1
View File
@@ -24,6 +24,7 @@ mod hierarchy;
mod input;
mod inspector;
mod interaction;
pub mod material;
pub mod math;
mod mixamo;
mod path;
@@ -36,6 +37,7 @@ mod ui;
pub mod viewport;
mod world_grid;
use crate::material::MaterialPlugin;
pub use crate::{
anim::AnimPlugin,
bvh::BvhPlugin,
@@ -89,6 +91,7 @@ impl PluginGroup for CommonPlugins {
.add(InspectorPlugin)
.add(InterpolationPlugin)
.add(ScenePlugin)
.add(MaterialPlugin)
// .add(SkyboxPlugin)
.add(StudioInputPlugin)
.add(UiLayerPlugin)
@@ -231,7 +234,10 @@ fn init_from_asset(app: &mut App, asset: InitAsset, skybox_id: String) {
}
InitAsset::ImportedScene(scene) => {
app.add_systems(Startup, move |mut ew: EventWriter<ImportSceneEvent>| {
ew.send(ImportSceneEvent(scene.clone()));
ew.send(ImportSceneEvent {
path: scene.clone(),
name: None,
});
});
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::material::short_material::ShortMaterial;
use bevy::app::{App, Plugin, PostUpdate};
use bevy::asset::{Assets, Handle};
use bevy::pbr::StandardMaterial;
use bevy::prelude::{Commands, Entity, Query, ResMut};
use space_editor::prelude::EditorRegistryExt;
pub mod short_material;
pub struct MaterialPlugin;
impl Plugin for MaterialPlugin {
fn build(&self, app: &mut App) {
app.register_type::<ShortMaterial>()
.editor_registry::<ShortMaterial>();
app.add_systems(PostUpdate, apply_short_materials);
}
}
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>();
}
}
+25
View File
@@ -0,0 +1,25 @@
use bevy::pbr::StandardMaterial;
use bevy::prelude::{Color, Component, Reflect};
use bevy::ecs::reflect::ReflectComponent;
use bevy::reflect::std_traits::ReflectDefault;
#[derive(Reflect, Default, Clone, Component)]
#[reflect(Default, Component)]
pub 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,
}
}
}
+21 -94
View File
@@ -39,6 +39,9 @@ use crate::{
MainCamera,
};
#[cfg(feature = "wasm")]
mod wasm;
pub struct ScenePlugin;
#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -113,7 +116,7 @@ impl Plugin for ScenePlugin {
app.add_systems(
Update,
(
wasm::dispatch_object_load_request,
wasm::handle_spawn_asset_messages,
wasm::notify_state_changes
.map(bevy::utils::error)
.run_if(resource_changed::<State<SceneState>>),
@@ -276,8 +279,11 @@ pub struct LoadSceneEvent(pub String);
/// Request the application to import and process a glTF scene from an arbitrary
/// asset source (e.g., an http path or a Storyteller.ai media token).
#[derive(Event, Deref)]
pub struct ImportSceneEvent(pub String);
#[derive(Event)]
pub struct ImportSceneEvent {
pub path: String,
pub name: Option<String>,
}
/// A tag that identifies its entity as a user-facing scene element like a prop,
/// a light, or a camera.
@@ -409,16 +415,23 @@ fn init_scene_import(
r_assets: Res<AssetServer>,
mut er: EventReader<ImportSceneEvent>,
) {
if let Some(path) = er.read().last() {
info!("Importing asset: {}", path.0);
for event in er.read() {
info!("Importing asset: {}", event.path);
let name = event
.name
.as_ref()
.map(|name| Name::new(name.clone()))
.unwrap_or_else(|| Name::new("Scene"));
cmd.spawn((
Name::new("Scene"), // TODO: Maybe use the "basename" part of the asset path?
name,
SpatialBundle::default(),
space_editor::prelude::GltfPrefab {
path: path.0.clone(),
path: event.path.clone(),
scene: "Scene0".to_string(),
},
r_assets.load::<Gltf>(&path.0),
r_assets.load::<Gltf>(&event.path),
PrefabMarker,
SceneRoot,
));
@@ -630,89 +643,3 @@ fn start_capture(mut captures: Query<&mut ScreenCapture, Added<ScreenCapture>>)
capture.start_capturing();
}
}
#[cfg(feature = "wasm")]
mod wasm {
use bevy::{prelude::*, window::PrimaryWindow};
use wasm_bindgen::prelude::*;
use super::{LoadLibraryObjectEvent, LoadingQueue, SceneState};
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
static OBJECT_LOAD_REQUEST: WasmMessageBuffer<String> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = loadObject)]
pub fn load_object(id: &str) {
OBJECT_LOAD_REQUEST.write_message(id.into());
}
// FIXME: Is it an object or a scene? Inconsistent terminology is
// unnecessarily confusing
pub(super) fn dispatch_object_load_request(
mut ew_load_scene: EventWriter<LoadLibraryObjectEvent>,
) {
if let Some(id) = OBJECT_LOAD_REQUEST.take_message() {
ew_load_scene.send(LoadLibraryObjectEvent(id));
}
}
#[wasm_bindgen(typescript_custom_section)]
const SCENE_STATE_EVENT: &str = r#"
export interface SceneStateEvent extends CustomEvent {
type: "scene-state";
detail: SceneState;
}
declare global {
export interface GlobalEventHandlersEventMap {
"scene-state": SceneStateEvent;
}
}
"#;
pub(super) fn notify_state_changes(
r_state: Res<State<SceneState>>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if let Ok(window) = q_window.get_single() {
wasm::dispatch_to_js(window, "scene-state", &(**r_state as u32))?;
}
Ok(())
}
#[wasm_bindgen(typescript_custom_section)]
const LOADING_QUEUE_EVENT: &str = r#"
export interface LoadingQueueEvent extends CustomEvent {
type: "loading-queue";
detail: number;
}
declare global {
export interface GlobalEventHandlersEventMap {
"loading-queue": LoadingQueueEvent;
}
}
"#;
pub(super) fn notify_loading_queue_changes(
mut l_last_sent: Local<Option<usize>>,
r_loading: Res<LoadingQueue>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
let queue_len = r_loading.queue.len();
if let Some(last_sent) = *l_last_sent {
if last_sent == queue_len {
return Ok(());
}
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
wasm::dispatch_to_js(window, "loading-queue", &queue_len)?;
*l_last_sent = Some(queue_len);
Ok(())
}
}
+111
View File
@@ -0,0 +1,111 @@
use bevy::{prelude::*, window::PrimaryWindow};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use super::{ImportSceneEvent, LoadingQueue, SceneState};
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
static SPAWN_ASSETS: WasmMessageBuffer<Vec<AssetImport>> = WasmMessageBuffer::new();
#[wasm_bindgen(typescript_custom_section)]
const ASSET_IMPORT_TS: &str = r#"
export interface AssetImport {
path: string;
name?: string;
}
"#;
#[derive(Deserialize, Serialize)]
struct AssetImport {
path: String,
name: Option<String>,
}
#[wasm_bindgen(typescript_custom_section)]
const SPAWN_ASSET_TS: &str = r#"
export function spawnAsset(asset: AssetImport): void;
"#;
#[wasm_bindgen(js_name = spawnAsset, skip_typescript)]
pub fn spawn_asset(asset: JsValue) -> Result<(), JsValue> {
let asset: AssetImport = serde_wasm_bindgen::from_value(asset)?;
SPAWN_ASSETS.write_message(vec![asset]);
Ok(())
}
#[wasm_bindgen(typescript_custom_section)]
const SPAWN_ASSETS_TS: &str = r#"
export function spawnAssets(assets: AssetImport[]): void;
"#;
#[wasm_bindgen(js_name = spawnAssets, skip_typescript)]
pub fn spawn_assets(assets: JsValue) -> Result<(), JsValue> {
let assets: Vec<AssetImport> = serde_wasm_bindgen::from_value(assets)?;
SPAWN_ASSETS.write_message(assets);
Ok(())
}
pub(super) fn handle_spawn_asset_messages(mut ew_import_scene: EventWriter<ImportSceneEvent>) {
if let Some(assets) = SPAWN_ASSETS.take_message() {
for AssetImport { path, name } in assets {
ew_import_scene.send(ImportSceneEvent { path, name });
}
}
}
#[wasm_bindgen(typescript_custom_section)]
const SCENE_STATE_EVENT: &str = r#"
export interface SceneStateEvent extends CustomEvent {
type: "scene-state";
detail: SceneState;
}
declare global {
export interface GlobalEventHandlersEventMap {
"scene-state": SceneStateEvent;
}
}
"#;
pub(super) fn notify_state_changes(
r_state: Res<State<SceneState>>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
if let Ok(window) = q_window.get_single() {
wasm::dispatch_to_js(window, "scene-state", &(**r_state as u32))?;
}
Ok(())
}
#[wasm_bindgen(typescript_custom_section)]
const LOADING_QUEUE_EVENT: &str = r#"
export interface LoadingQueueEvent extends CustomEvent {
type: "loading-queue";
detail: number;
}
declare global {
export interface GlobalEventHandlersEventMap {
"loading-queue": LoadingQueueEvent;
}
}
"#;
pub(super) fn notify_loading_queue_changes(
mut l_last_sent: Local<Option<usize>>,
r_loading: Res<LoadingQueue>,
q_window: Query<&Window, With<PrimaryWindow>>,
) -> Result<(), String> {
let queue_len = r_loading.queue.len();
if let Some(last_sent) = *l_last_sent {
if last_sent == queue_len {
return Ok(());
}
}
let Ok(window) = q_window.get_single() else {
return Ok(());
};
wasm::dispatch_to_js(window, "loading-queue", &queue_len)?;
*l_last_sent = Some(queue_len);
Ok(())
}
+1 -40
View File
@@ -17,6 +17,7 @@ use space_editor::{
};
use wasm_bindgen::prelude::*;
use crate::material::short_material::ShortMaterial;
use crate::{
interaction::Selection,
wasm::{self, StaticBuffer, WasmMessageBuffer},
@@ -91,7 +92,6 @@ impl Plugin for InspectorPlugin {
.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()
@@ -139,25 +139,6 @@ fn create_short_materials(
}
}
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<
@@ -588,26 +569,6 @@ fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
ev_update_description.send(UpdateEntityDescription);
}
#[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>>,