mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-18 07:35:19 +00:00
Inspector editing (#96)
This PR adds Material component inspection to the frontend Entity Inspector, and adds functional editing capabilities for the Transform and Material inspectors. ### Changes * Added a `ScalarFieldElement` for numeric form fields, with a hybrid slider/text-input UX inspired by Blender, Unreal Engine, etc. * Added `MaterialInspectorElement` for inspecting the Bevy `StandardMaterial` component * Wired up the `TransformInspectorElement` and `MaterialInspectorElement` to apply changes made in the UI to the Bevy scene * Refactored the `studio/ui/inspector` module to output `Result::Err`s to the dev console * Refactored the `studio/interaction/selection` module to use only the `Selection` resource, removing the redundant `Selected` component which added some unnecessary complexity. This fixes #39 * Fixed primitive shapes not receiving a selection outline when selected ### To-Do These tasks will be addressed in a forthcoming PR: * The **Transform** > **Rotation** section in the inspector is still not displaying sensible values. Currently it's just displaying the raw quaternion `xyz` vector rather than displaying the orientation in human-readable terms (e.g., as Euler angles). * We're currently ignoring the Primitive Shape component, which would allow the user to configure some additional geometric properties of the shape or replace it with a different one. --------- Co-authored-by: rewin <rewin1996@gmail.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { inject } from "@storyteller/framework";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS, nothing } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "../scene-data-provider.element";
|
||||
import { type InspectorComponent } from "./inspector.types";
|
||||
import { isLinear, type InspectorComponent } from "./inspector.types";
|
||||
|
||||
import "../scene-data-provider.element";
|
||||
import "./material-inspector.element";
|
||||
import "./name-inspector.element";
|
||||
import "./transform-inspector.element";
|
||||
|
||||
@@ -40,17 +41,22 @@ export class EntityInspectorElement extends LitElement {
|
||||
.sort((a, b) => {
|
||||
const ordA = COMPONENT_SORT_ORDER.indexOf(a.name);
|
||||
const ordB = COMPONENT_SORT_ORDER.indexOf(b.name);
|
||||
|
||||
if (ordA === -1) return Infinity;
|
||||
if (ordB === -1) return -Infinity;
|
||||
|
||||
return ordA - ordB;
|
||||
})
|
||||
.map(({ name, value }) => {
|
||||
switch (name) {
|
||||
case "bevy_core::name::Name":
|
||||
case "bevy_core::name::Name": {
|
||||
return html`
|
||||
<sts-name-inspector
|
||||
.value=${value.name}
|
||||
></sts-name-inspector>
|
||||
`;
|
||||
case "bevy_transform::components::transform::Transform":
|
||||
}
|
||||
case "bevy_transform::components::transform::Transform": {
|
||||
return html`
|
||||
<sts-transform-inspector
|
||||
.translation=${value.translation}
|
||||
@@ -58,11 +64,29 @@ export class EntityInspectorElement extends LitElement {
|
||||
.scale=${value.scale}
|
||||
></sts-transform-inspector>
|
||||
`;
|
||||
default:
|
||||
}
|
||||
case "bevy_pbr::pbr_material::StandardMaterial": {
|
||||
const { colorSpace, baseColor } = isLinear(value.base_color)
|
||||
? { colorSpace: "linear", baseColor: value.base_color.RgbaLinear }
|
||||
: { colorSpace: "srgb", baseColor: value.base_color.Rgba };
|
||||
|
||||
return html`
|
||||
<h3>Unknown Component: ${name}</h3>
|
||||
<pre>${JSON.stringify({ name, value }, null, " ")}</pre>
|
||||
<sts-material-inspector
|
||||
.baseColor=${baseColor}
|
||||
.colorSpace=${colorSpace}
|
||||
.roughness=${value.perceptual_roughness}
|
||||
.metallic=${value.metallic}
|
||||
.reflectance=${value.reflectance}
|
||||
></sts-material-inspector>
|
||||
`;
|
||||
}
|
||||
default: {
|
||||
return nothing;
|
||||
// return html`
|
||||
// <h3>Unknown Component: ${name}</h3>
|
||||
// <pre>${JSON.stringify({ name, value }, null, " ")}</pre>
|
||||
// `;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -72,4 +96,5 @@ export class EntityInspectorElement extends LitElement {
|
||||
const COMPONENT_SORT_ORDER: Array<InspectorComponent["name"]> = [
|
||||
"bevy_core::name::Name",
|
||||
"bevy_transform::components::transform::Transform",
|
||||
"bevy_pbr::pbr_material::StandardMaterial",
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type InspectorComponent
|
||||
= BevyReflect<"bevy_transform::components::transform::Transform", BevyTransform>
|
||||
| BevyReflect<"bevy_core::name::Name", BevyName>
|
||||
| BevyReflect<"bevy_pbr::pbr_material::StandardMaterial", StandardMaterial>
|
||||
;
|
||||
|
||||
export interface BevyReflect<K extends string, T> {
|
||||
@@ -31,3 +32,34 @@ export interface Vec3 {
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface StandardMaterial {
|
||||
base_color: BevyColor;
|
||||
perceptual_roughness: number;
|
||||
metallic: number;
|
||||
reflectance: number;
|
||||
}
|
||||
|
||||
export type BevyColor
|
||||
= Color_RgbaLinear
|
||||
| Color_Rgba
|
||||
;
|
||||
|
||||
export interface Color_RgbaLinear {
|
||||
RgbaLinear: Rgba;
|
||||
}
|
||||
|
||||
export interface Color_Rgba {
|
||||
Rgba: Rgba;
|
||||
}
|
||||
|
||||
export interface Rgba {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
alpha: number;
|
||||
}
|
||||
|
||||
export function isLinear(color: BevyColor): color is Color_RgbaLinear {
|
||||
return "RgbaLinear" in color;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
@use "sass:math";
|
||||
|
||||
:host {
|
||||
color-scheme: dark;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
align-items: baseline;
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #FFF9;
|
||||
font-size: math.div(14, 16) * 1rem;
|
||||
margin-block-end: 0.333rem;
|
||||
}
|
||||
|
||||
sts-text-field::part(input) {
|
||||
flex: unset;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { match } from "@storyteller/utility";
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { StandardMaterial, type Rgba } from "./inspector.types";
|
||||
|
||||
import "@storyteller/studio-ui/forms/scalar-field";
|
||||
|
||||
import styles from "./material-inspector.element.scss?inline";
|
||||
|
||||
@customElement("sts-material-inspector")
|
||||
export class MaterialInspectorElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property({ attribute: false })
|
||||
baseColor: Rgba = { red: 1, green: 1, blue: 1, alpha: 1 };
|
||||
|
||||
@property()
|
||||
inputColorSpace: "linear"|"srgb" = "linear";
|
||||
|
||||
@property({ type: Number })
|
||||
roughness = 0.5;
|
||||
|
||||
@property({ type: Number })
|
||||
metallic = 0.0;
|
||||
|
||||
@property({ type: Number })
|
||||
reflectance = 0.5;
|
||||
|
||||
#baseColorId = uuid();
|
||||
#roughnessId = uuid();
|
||||
#metallicId = uuid();
|
||||
#reflectanceId = uuid();
|
||||
|
||||
onBaseColorInput(event: InputEvent): void {
|
||||
const hex = (event.target as HTMLInputElement).value;
|
||||
const value = match (this.inputColorSpace, {
|
||||
"linear": () => RgbaLinear.fromHex(hex, this.baseColor.alpha),
|
||||
"srgb": () => Srgba.fromHex(hex, this.baseColor.alpha),
|
||||
});
|
||||
|
||||
studio.updateComponentField({
|
||||
component: "bevy_pbr::pbr_material::StandardMaterial",
|
||||
reflectPath: "base_color",
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
updateField(reflectPath: Exclude<keyof StandardMaterial, "base_color">) {
|
||||
return ({ detail: value }: CustomEvent<number>) => {
|
||||
studio.updateComponentField({
|
||||
component: "bevy_pbr::pbr_material::StandardMaterial",
|
||||
reflectPath,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const [rgb, _] = match (this.inputColorSpace, {
|
||||
"linear": () => RgbaLinear.toHex(this.baseColor),
|
||||
"srgb": () => Srgba.toHex(this.baseColor),
|
||||
});
|
||||
|
||||
return html`
|
||||
<h4>Material</h4>
|
||||
|
||||
<div class="fields">
|
||||
<label for=${this.#baseColorId}>
|
||||
Tint
|
||||
</label>
|
||||
<input
|
||||
id=${this.#baseColorId}
|
||||
type="color"
|
||||
.value=${rgb}
|
||||
@input=${this.onBaseColorInput}
|
||||
/>
|
||||
|
||||
<label for=${this.#roughnessId}>
|
||||
Roughness
|
||||
</label>
|
||||
<sts-scalar-field
|
||||
id=${this.#roughnessId}
|
||||
.min=${0} .max=${1}
|
||||
.value=${this.roughness}
|
||||
@value-change=${this.updateField("perceptual_roughness")}
|
||||
></sts-scalar-field>
|
||||
|
||||
<label for=${this.#metallicId}>
|
||||
Metallic
|
||||
</label>
|
||||
<sts-scalar-field
|
||||
id=${this.#metallicId}
|
||||
.min=${0} .max=${1}
|
||||
.value=${this.metallic}
|
||||
@value-change=${this.updateField("metallic")}
|
||||
></sts-scalar-field>
|
||||
|
||||
<label for=${this.#reflectanceId}>
|
||||
Reflectance
|
||||
</label>
|
||||
<sts-scalar-field
|
||||
id=${this.#reflectanceId}
|
||||
.min=${0} .max=${1}
|
||||
.value=${this.reflectance}
|
||||
@value-change=${this.updateField("reflectance")}
|
||||
></sts-scalar-field>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const HEX_PATTERN = /#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?/i;
|
||||
const HEX_BASE = 16;
|
||||
const U8_MAX = 255;
|
||||
|
||||
function channelToHex(gamma: number) {
|
||||
return (channel: number): string => {
|
||||
return Math
|
||||
.round(Math.pow(channel, gamma) * U8_MAX)
|
||||
.toString(HEX_BASE)
|
||||
.padStart(2, "0");
|
||||
}
|
||||
}
|
||||
|
||||
function channelFromHex(gamma: number) {
|
||||
return (channel: string): number => {
|
||||
return Math.pow(parseInt(channel, HEX_BASE) / U8_MAX, gamma);
|
||||
}
|
||||
}
|
||||
|
||||
namespace RgbaLinear {
|
||||
export function toHex({ red, green, blue, alpha }: Rgba): [string, number] {
|
||||
const hexRgb = [red, green, blue]
|
||||
.map(channelToHex(1 / 2.2))
|
||||
.join("");
|
||||
|
||||
return [`#${hexRgb}`, alpha];
|
||||
}
|
||||
|
||||
export function fromHex(hex: string, alpha = 1): Rgba {
|
||||
const [red, green, blue] = hex
|
||||
.match(HEX_PATTERN)!
|
||||
.slice(1)
|
||||
.map(channelFromHex(2.2));
|
||||
|
||||
return { red, green, blue, alpha };
|
||||
}
|
||||
}
|
||||
|
||||
namespace Srgba {
|
||||
export function toHex({ red, green, blue, alpha }: Rgba): [string, number] {
|
||||
const hexRgb = [red, green, blue]
|
||||
.map(channelToHex(2.2))
|
||||
.join("");
|
||||
|
||||
return [`#${hexRgb}`, alpha];
|
||||
}
|
||||
|
||||
export function fromHex(hex: string, alpha = 1): Rgba {
|
||||
const [red, green, blue] = hex
|
||||
.match(HEX_PATTERN)!
|
||||
.slice(1)
|
||||
.map(channelFromHex(1 / 2.2));
|
||||
|
||||
return { red, green, blue, alpha };
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { bind } from "@storyteller/framework";
|
||||
import { LitElement, PropertyValues, type TemplateResult, html, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@@ -13,8 +14,20 @@ export class NameInspectorElement extends LitElement {
|
||||
@property() value = "";
|
||||
|
||||
protected override updated(changes: PropertyValues<this>): void {
|
||||
if (changes.has("value"))
|
||||
console.log("New value:", this.value);
|
||||
// FIXME: `Name` component needs special handling due to:
|
||||
// 1. Its `hash` field, which doesn't get updated by just directly
|
||||
// mutating the `name` field
|
||||
// 2. Its use in our hierarchy. We'll need to add some logic to check
|
||||
// for changed names on existing entities and notify the frontend so
|
||||
// the hierarchy can reflect the new value.
|
||||
|
||||
// if (changes.has("value")) {
|
||||
// studio.updateComponentField({
|
||||
// component: "bevy_core::name::Name",
|
||||
// reflectPath: "name",
|
||||
// value: this.value,
|
||||
// });
|
||||
// }
|
||||
|
||||
super.updated(changes);
|
||||
}
|
||||
|
||||
@@ -17,48 +17,29 @@ h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
.fieldset {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
padding: 0 {
|
||||
block-start: math.div(14, 16) + 0.333rem !important;
|
||||
}
|
||||
margin: 0;
|
||||
gap: 0.25rem;
|
||||
border: none;
|
||||
|
||||
legend {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
margin-block-end: 0.333rem;
|
||||
color: #FFF9;
|
||||
font-size: math.div(14, 16) * 1rem;
|
||||
margin-block-end: 0.333rem;
|
||||
}
|
||||
|
||||
input {
|
||||
appearance: textfield;
|
||||
width: math.div(1, 3) * 100%;
|
||||
.input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
background: #0004;
|
||||
border: 1px solid #FFF2 {
|
||||
left: 4px solid #FFF2;
|
||||
}
|
||||
color: #FFFC;
|
||||
transition: {
|
||||
property: background-color, border-color, color;
|
||||
duration: 33.333ms;
|
||||
timing-function: linear;
|
||||
}
|
||||
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
size: math.div(15, 16) * 1rem;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #FFF4;
|
||||
background: #0006;
|
||||
color: #FFFF;
|
||||
}
|
||||
width: math.div(1, 3) * 100%;
|
||||
border-left-width: 4px;
|
||||
|
||||
&:nth-of-type(1) {
|
||||
border-left-color: rgb(220, 53, 69);
|
||||
@@ -78,11 +59,5 @@ fieldset {
|
||||
border-left-color: rgb(47, 131, 255);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as studio from "@storyteller/studio"
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { type Quat, type Vec3 } from "./inspector.types";
|
||||
|
||||
import "@storyteller/studio-ui/forms/scalar-field";
|
||||
|
||||
import styles from "./transform-inspector.element.scss?inline";
|
||||
|
||||
@customElement("sts-transform-inspector")
|
||||
@@ -17,27 +22,107 @@ export class TransformInspectorElement extends LitElement {
|
||||
@property({ attribute: false })
|
||||
scale: Vec3 = { x: 1, y: 1, z: 1 };
|
||||
|
||||
#positionLabelId = uuid();
|
||||
#rotationLabelId = uuid();
|
||||
#scaleLabelId = uuid();
|
||||
|
||||
updateField(reflectPath: `${"translation"|"rotation"|"scale"}.${"x"|"y"|"z"}`) {
|
||||
return ({ detail }: CustomEvent<string>) => {
|
||||
const value = parseFloat(detail);
|
||||
if (Number.isFinite(value) && !Number.isNaN(value)) {
|
||||
studio.updateComponentField({
|
||||
component: "bevy_transform::components::transform::Transform",
|
||||
reflectPath,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<!-- TODO -->
|
||||
<h4>Transform</h4>
|
||||
<fieldset>
|
||||
<legend>Translation</legend>
|
||||
<input type="number" value=${this.translation.x.toFixed(3)} />
|
||||
<input type="number" value=${this.translation.y.toFixed(3)} />
|
||||
<input type="number" value=${this.translation.z.toFixed(3)} />
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Rotation</legend>
|
||||
<input type="number" value=${this.rotation.x.toFixed(3)} />
|
||||
<input type="number" value=${this.rotation.y.toFixed(3)} />
|
||||
<input type="number" value=${this.rotation.z.toFixed(3)} />
|
||||
<div
|
||||
class="fieldset"
|
||||
role="group"
|
||||
aria-labelledby=${this.#positionLabelId}
|
||||
>
|
||||
<legend id=${this.#positionLabelId}>
|
||||
Position
|
||||
</legend>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="X"
|
||||
.value=${this.translation.x}
|
||||
@value-change=${this.updateField("translation.x")}
|
||||
></sts-scalar-field>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="Y"
|
||||
.value=${this.translation.y}
|
||||
@value-change=${this.updateField("translation.y")}
|
||||
></sts-scalar-field>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="Z"
|
||||
.value=${this.translation.z}
|
||||
@value-change=${this.updateField("translation.z")}
|
||||
></sts-scalar-field>
|
||||
</div>
|
||||
<div
|
||||
class="fieldset"
|
||||
role="group"
|
||||
aria-labelledby=${this.#rotationLabelId}
|
||||
>
|
||||
<legend id=${this.#rotationLabelId}>
|
||||
Rotation
|
||||
</legend>
|
||||
<!-- TODO: This is not how quaternions work lol -->
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="X"
|
||||
.value=${this.rotation.x}
|
||||
@value-change=${this.updateField("rotation.x")}
|
||||
></sts-scalar-field>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="Y"
|
||||
.value=${this.rotation.y}
|
||||
@value-change=${this.updateField("rotation.y")}
|
||||
></sts-scalar-field>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="Z"
|
||||
.value=${this.rotation.z}
|
||||
@value-change=${this.updateField("rotation.z")}
|
||||
></sts-scalar-field>
|
||||
<!-- <input type="number" value=${this.rotation.w.toFixed(3)} /> -->
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Scale</legend>
|
||||
<input type="number" value=${this.scale.x.toFixed(3)} />
|
||||
<input type="number" value=${this.scale.y.toFixed(3)} />
|
||||
<input type="number" value=${this.scale.z.toFixed(3)} />
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
class="fieldset"
|
||||
role="group"
|
||||
aria-labelledby=${this.#scaleLabelId}
|
||||
>
|
||||
<legend id=${this.#scaleLabelId}>
|
||||
Scale
|
||||
</legend>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="X"
|
||||
.value=${this.scale.x}
|
||||
@value-change=${this.updateField("scale.x")}
|
||||
></sts-scalar-field>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="Y"
|
||||
.value=${this.scale.y}
|
||||
@value-change=${this.updateField("scale.y")}
|
||||
></sts-scalar-field>
|
||||
<sts-scalar-field
|
||||
class="input"
|
||||
aria-label="Z"
|
||||
.value=${this.scale.z}
|
||||
@value-change=${this.updateField("scale.z")}
|
||||
></sts-scalar-field>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type EntityMultiSpawnEvent,
|
||||
type EntitySelectEvent,
|
||||
type EntitySpawnEvent,
|
||||
type InspectorChanged,
|
||||
type InspectorChangedEvent,
|
||||
} from "@storyteller/studio";
|
||||
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
|
||||
import { match } from "@storyteller/utility";
|
||||
@@ -109,7 +109,7 @@ export class SceneDataProviderElement
|
||||
}
|
||||
|
||||
@on("window:inspector-changed")
|
||||
onInspectorChanged({ detail: json }: InspectorChanged): void {
|
||||
onInspectorChanged({ detail: json }: InspectorChangedEvent): void {
|
||||
this._components = json ? JSON.parse(json) : [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user