mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +00:00
Merge branch 'main' into update-version
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>>,
|
||||
|
||||
Reference in New Issue
Block a user