Inspector frontend UI (#68)

This PR begins fleshing out the front-end UI for inspecting the selected
entity's components.

### Implemented
* Support for the `Name` component
* Placeholder/WIP support for the `Transform` component

### To-do
* Support for sending changes made in the inspector back to Bevy
* A slider/field combo widget for numeric values (can be adapted from
  [this StackBlitz](https://stackblitz.com/edit/angular-wfikuy?file=src%2Ffloat-input%2Ffloat-input.component.ts))
* Quat -> Euler angles conversion ([example math here](https://github.com/dannymcgee/3DGameMath/blob/5cb7a8ff9310565329e57751cd8b9c33e9bd4cb7/libs/Math/include/math/quat.inl.hpp#L56-L78),
  or use a 3rd-party math library)

### Known issues
* Selecting entities from the hierarchy doesn't always update the
  components list. I suspect this is the same issue as #39
This commit is contained in:
Danny McGee
2024-03-04 15:13:03 -05:00
committed by GitHub
parent 9875ea7cca
commit c9904a5293
26 changed files with 1093 additions and 174 deletions
+3
View File
@@ -1,5 +1,8 @@
export * from "./lib/canvas-provider.element";
export * from "./lib/inspector/entity-inspector.element";
export * from "./lib/inspector/inspector.types";
export * from "./lib/loading.element";
export * from "./lib/scene-data-provider.element";
export * from "./lib/scene-hierarchy.element";
export * from "./lib/studio.element";
export * from "./lib/transform-toolbar.element";
@@ -1,4 +1,4 @@
import { Token, UniqueToken, on, provide } from "@storyteller/framework";
import { UniqueToken, on, provide } from "@storyteller/framework";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
@@ -35,7 +35,7 @@ export interface CanvasProvider {
returnCanvas(canvas: HTMLCanvasElement): void;
}
export const CANVAS_PROVIDER: Token<CanvasProvider> = new UniqueToken("CanvasProvider");
export const CANVAS_PROVIDER = UniqueToken.create<CanvasProvider>();
@customElement("sts-canvas-provider")
@provide(CANVAS_PROVIDER)
@@ -0,0 +1,33 @@
:host {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
overflow: auto;
color: #FFFC;
&::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
&-thumb {
border-radius: 2px;
background: #FFF2;
&:hover {
background: #FFF3;
}
}
&-corner {
background: transparent;
}
}
}
*, *::before, *::after {
box-sizing: border-box;
}
@@ -0,0 +1,75 @@
import { inject } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "../scene-data-provider.element";
import { type InspectorComponent } from "./inspector.types";
import "../scene-data-provider.element";
import "./name-inspector.element";
import "./transform-inspector.element";
import styles from "./entity-inspector.element.scss?inline";
@customElement("sts-entity-inspector")
export class EntityInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@inject(SCENE_DATA_PROVIDER)
_sceneData?: SceneDataProvider;
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this.onProviderChanges);
super.connectedCallback();
}
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("selectionComponents"))
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);
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>
`;
default:
return html`
<h3>Unknown Component: ${name}</h3>
<pre>${JSON.stringify({ name, value }, null, " ")}</pre>
`;
}
})
}
`;
}
const COMPONENT_SORT_ORDER: Array<InspectorComponent["name"]> = [
"bevy_core::name::Name",
"bevy_transform::components::transform::Transform",
];
@@ -0,0 +1,33 @@
export type InspectorComponent
= BevyReflect<"bevy_transform::components::transform::Transform", BevyTransform>
| BevyReflect<"bevy_core::name::Name", BevyName>
;
export interface BevyReflect<K extends string, T> {
name: K;
value: T;
}
export interface BevyTransform {
rotation: Quat;
scale: Vec3;
translation: Vec3;
}
export interface BevyName {
hash: number;
name: string;
}
export interface Quat {
w: number;
x: number;
y: number;
z: number;
}
export interface Vec3 {
x: number;
y: number;
z: number;
}
@@ -0,0 +1,3 @@
:host {
color-scheme: dark;
}
@@ -0,0 +1,25 @@
import { bind } from "@storyteller/framework";
import { LitElement, PropertyValues, type TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import "@storyteller/studio-ui/forms/text-field";
import styles from "./name-inspector.element.scss?inline";
@customElement("sts-name-inspector")
export class NameInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@property() value = "";
protected override updated(changes: PropertyValues<this>): void {
if (changes.has("value"))
console.log("New value:", this.value);
super.updated(changes);
}
protected override render = (): TemplateResult => html`
<sts-text-field .value=${bind(this, "value")}></sts-text-field>
`;
}
@@ -0,0 +1,88 @@
@use "sass:math";
:host {
display: flex;
flex-direction: column;
gap: 0.75rem;
color-scheme: dark;
font-family: "Fira Sans", sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
}
h4 {
font-weight: 500;
margin: 0 0 0.25rem;
}
fieldset {
display: flex;
width: 100%;
padding: 0;
margin: 0;
gap: 0.25rem;
border: none;
legend {
color: #FFF9;
font-size: math.div(14, 16) * 1rem;
margin-block-end: 0.333rem;
}
input {
appearance: textfield;
width: math.div(1, 3) * 100%;
padding: 0.5rem;
border-radius: 0.25rem;
background: #0004;
border: 1px solid #FFF2 {
left: 4px solid #FFF2;
}
color: #FFFC;
transition: {
property: background-color, border-color, color;
duration: 33.333ms;
timing-function: linear;
}
font: {
family: "Fira Sans", sans-serif;
size: math.div(15, 16) * 1rem;
}
&:focus {
outline: none;
border-color: #FFF4;
background: #0006;
color: #FFFF;
}
&:nth-of-type(1) {
border-left-color: rgb(220, 53, 69);
&:focus {
border-left-color: rgb(255, 48, 68);
}
}
&:nth-of-type(2) {
border-left-color: rgb(25, 135, 84);
&:focus {
border-left-color: rgb(0, 187, 100);
}
}
&:nth-of-type(3) {
border-left-color: rgb(13, 110, 253);
&:focus {
border-left-color: rgb(47, 131, 255);
}
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
}
@@ -0,0 +1,43 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { type Quat, type Vec3 } from "./inspector.types";
import styles from "./transform-inspector.element.scss?inline";
@customElement("sts-transform-inspector")
export class TransformInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ attribute: false })
translation: Vec3 = { x: 0, y: 0, z: 0 };
@property({ attribute: false })
rotation: Quat = { w: 1, x: 0, y: 0, z: 0 };
@property({ attribute: false })
scale: Vec3 = { x: 1, y: 1, z: 1 };
protected override render = () => html`
<!-- TODO -->
<h4>Transform</h4>
<fieldset>
<legend>Translation</legend>
<input type="number" value=${this.translation.x.toFixed(3)} />
<input type="number" value=${this.translation.y.toFixed(3)} />
<input type="number" value=${this.translation.z.toFixed(3)} />
</fieldset>
<fieldset>
<legend>Rotation</legend>
<input type="number" value=${this.rotation.x.toFixed(3)} />
<input type="number" value=${this.rotation.y.toFixed(3)} />
<input type="number" value=${this.rotation.z.toFixed(3)} />
<!-- <input type="number" value=${this.rotation.w.toFixed(3)} /> -->
</fieldset>
<fieldset>
<legend>Scale</legend>
<input type="number" value=${this.scale.x.toFixed(3)} />
<input type="number" value=${this.scale.y.toFixed(3)} />
<input type="number" value=${this.scale.z.toFixed(3)} />
</fieldset>
`;
}
@@ -0,0 +1,156 @@
import { DynamicProvider, UniqueToken, on, provide } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
SceneElement,
type EntityDespawnEvent,
type EntityMultiSpawnEvent,
type EntitySelectEvent,
type EntitySpawnEvent,
type InspectorChanged,
} from "@storyteller/studio";
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
import { match } from "@storyteller/utility";
import { LitElement, type PropertyValues, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { InspectorComponent } from "./inspector/inspector.types";
export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
readonly tree: readonly SceneObject[];
readonly entityMap: ReadonlyMap<string, SceneObject>;
readonly selectedEntity: string | null;
readonly selectedObject: SceneObject | null;
readonly selectionComponents: readonly InspectorComponent[];
}
export const SCENE_DATA_PROVIDER = UniqueToken.create<SceneDataProvider>();
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
icon?: string;
children: SceneObject[];
}
@customElement("sts-scene-data-provider")
@provide(SCENE_DATA_PROVIDER)
@provide(TREE_SELECTION_PROVIDER)
export class SceneDataProviderElement
extends LitElement
implements SceneDataProvider, TreeSelectionProvider
{
get tree() { return this._tree; }
get entityMap() { return this.#entityMap; }
get selectedEntity() { return this._selection; }
get selectedId() { return this._selection; }
get selectedObject() {
if (!this._selection) return null;
return this.#entityMap.get(this._selection) ?? null;
}
get selectionComponents() { return this._components; }
@state() _tree: SceneObject[] = [];
#sceneObjectsFlat: studio.SceneObject[] = [];
#entityMap = new Map<string, SceneObject>();
@state() _selection: string | null = null;
@state() _components: InspectorComponent[] = [];
// TODO: This workflow allows dependents of this provider to listen for the
// "property-changes" event and manually trigger updates when relevant
// properties change, but it's pretty fiddly to set up. Probably not
// worth fixing before we migrate over to Bevy-native UI.
protected override update(changes: PropertyValues<this>): void {
const downstreamChanges: PropertyValues<SceneDataProvider & TreeSelectionProvider>
= new Map();
if (changes.has("_tree")) {
downstreamChanges.set("tree", changes.get("_tree"));
downstreamChanges.set("entityMap", new Map());
}
if (changes.has("_selection")) {
const prevSelection = changes.get("_selection");
downstreamChanges.set("selectedEntity", prevSelection);
downstreamChanges.set("selectedId", prevSelection);
downstreamChanges.set(
"selectedObject",
prevSelection
? this.#entityMap.get(prevSelection) ?? null
: null
);
}
if (changes.has("_components"))
downstreamChanges.set("selectionComponents", changes.get("_components"));
this.dispatchEvent(new CustomEvent("property-changes", { detail: downstreamChanges }));
super.update(changes);
}
@on("window:entity-spawn")
@on("window:entity-multi-spawn")
_onEntitiesSpawned({ detail: entities }: EntitySpawnEvent | EntityMultiSpawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.concat(entities);
this.#rebuildTree();
}
@on("window:entity-despawn")
_onEntityDespawn({ detail: entity }: EntityDespawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.filter(({ id }) => id !== entity);
this.#rebuildTree();
}
@on("window:entity-select")
onEntitySelect({ detail: entity }: EntitySelectEvent): void {
this._selection = entity;
}
@on("window:inspector-changed")
onInspectorChanged({ detail: json }: InspectorChanged): void {
this._components = json ? JSON.parse(json) : [];
}
#rebuildTree(): void {
this.#entityMap.clear();
for (let obj of this.#sceneObjectsFlat) {
this.#entityMap.set(obj.id, {
...obj,
icon: match (obj.type, {
[SceneElement.Generic]: () => "cube",
[SceneElement.Mesh]: () => "cube", // TODO ?
[SceneElement.Skeleton]: () => "skeleton",
[SceneElement.Bone]: () => "bone",
[SceneElement.Light]: () => "light",
[SceneElement.Camera]: () => "video",
}),
children: []
});
}
for (let obj of this.#entityMap.values()) {
if (obj.parent != null) {
const parent = this.#entityMap.get(obj.parent);
if (!parent)
throw new Error(`Missing parent: ${obj.parent}`);
parent.children.push(obj);
}
}
for (let obj of this.#entityMap.values())
obj.children.sort((a, b) => a.name.localeCompare(b.name));
this._tree = Array
.from(this.#entityMap.values())
.filter(obj => obj.parent == null)
.sort((a, b) => a.name.localeCompare(b.name))
}
protected override render = () => html`
<slot></slot>
`;
}
@@ -3,14 +3,9 @@
padding: 0.5rem 1rem;
overflow: auto;
background: #0006;
backdrop-filter: blur(16px);
border: 8px solid transparent;
border-radius: 0.5rem;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 0.5rem;
height: 0.5rem;
&-thumb {
border-radius: 2px;
+18 -66
View File
@@ -1,92 +1,44 @@
import { on } from "@storyteller/framework";
import { inject } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
SceneElement,
type EntityDespawnEvent,
type EntityMultiSpawnEvent,
type EntitySpawnEvent,
} from "@storyteller/studio";
import { match } from "@storyteller/utility";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import styles from "./scene-hierarchy.element.scss?inline";
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "./scene-data-provider.element";
import "@storyteller/studio-ui/tree";
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
icon?: string;
children: SceneObject[];
}
import styles from "./scene-hierarchy.element.scss?inline";
@customElement("sts-scene-hierarchy")
export class SceneHierarchyElement extends LitElement {
static override styles = unsafeCSS(styles);
get tree() { return this._tree as readonly SceneObject[]; }
get entityMap() { return this.#entityMap as ReadonlyMap<string, SceneObject>; }
@inject(SCENE_DATA_PROVIDER)
_sceneData?: SceneDataProvider;
@state() private _tree: SceneObject[] = [];
#sceneObjectsFlat: studio.SceneObject[] = [];
#entityMap = new Map<string, SceneObject>();
@on("window:entity-spawn")
@on("window:entity-multi-spawn")
_onEntitiesSpawned({ detail: entities }: EntitySpawnEvent | EntityMultiSpawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.concat(entities);
this.#rebuildTree();
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this.onProviderChanges);
super.connectedCallback();
}
@on("window:entity-despawn")
_onEntityDespawn({ detail: entity }: EntityDespawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.filter(({ id }) => id !== entity);
this.#rebuildTree();
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
#rebuildTree(): void {
this.#entityMap.clear();
for (let obj of this.#sceneObjectsFlat) {
this.#entityMap.set(obj.id, {
...obj,
icon: match (obj.type, {
[SceneElement.Generic]: () => "cube",
[SceneElement.Mesh]: () => "cube", // TODO ?
[SceneElement.Skeleton]: () => "skeleton",
[SceneElement.Bone]: () => "bone",
[SceneElement.Light]: () => "light",
[SceneElement.Camera]: () => "video",
}),
children: []
});
}
for (let obj of this.#entityMap.values()) {
if (obj.parent != null) {
const parent = this.#entityMap.get(obj.parent);
if (!parent)
throw new Error(`Missing parent: ${obj.parent}`);
parent.children.push(obj);
}
}
for (let obj of this.#entityMap.values())
obj.children.sort((a, b) => a.name.localeCompare(b.name));
this._tree = Array
.from(this.#entityMap.values())
.filter(obj => obj.parent == null)
.sort((a, b) => a.name.localeCompare(b.name))
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("tree"))
this.requestUpdate();
}
protected override render = () => html`
${this.tree.map(obj => html`
${this._sceneData?.tree.map(obj => html`
<sts-tree
id=${obj.id}
.name=${obj.name}
.icon=${obj.icon}
.treeChildren=${obj.children}
@tree-select=${(event: CustomEvent<string>) => studio.select(event.detail)}
></sts-tree>
`)}
`