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:
Danny McGee
2024-03-22 18:53:28 -04:00
committed by GitHub
parent c890665e16
commit 0d4072c806
21 changed files with 1065 additions and 266 deletions
Generated
+8 -8
View File
@@ -4685,7 +4685,7 @@ dependencies = [
[[package]]
name = "space_bevy_xpbd_plugin"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"bevy_xpbd_3d",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "space_editor"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"space_bevy_xpbd_plugin",
@@ -4706,7 +4706,7 @@ dependencies = [
[[package]]
name = "space_editor_core"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"space_persistence",
@@ -4718,7 +4718,7 @@ dependencies = [
[[package]]
name = "space_editor_ui"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"bevy_debug_grid",
@@ -4739,7 +4739,7 @@ dependencies = [
[[package]]
name = "space_persistence"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"ron",
@@ -4749,7 +4749,7 @@ dependencies = [
[[package]]
name = "space_prefab"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"bevy-inspector-egui",
@@ -4763,7 +4763,7 @@ dependencies = [
[[package]]
name = "space_shared"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"bevy-inspector-egui",
@@ -4773,7 +4773,7 @@ dependencies = [
[[package]]
name = "space_undo"
version = "0.3.1"
source = "git+https://github.com/rewin123/space_editor?rev=ce83f86dc902787ab16213f3209276a784654df6#ce83f86dc902787ab16213f3209276a784654df6"
source = "git+https://github.com/rewin123/space_editor?rev=0f2439ba8cd38ebf2db8d90ea6de736992bcb56e#0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"
dependencies = [
"bevy",
"pretty-type-name",
+1
View File
@@ -1 +1,2 @@
export * from "./text-field.element";
export * from "./scalar-field.element";
@@ -0,0 +1,48 @@
@use "sass:math";
:host {
box-sizing: border-box;
display: block;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: #0004;
border: 1px solid #FFF2;
color: #FFFC;
outline: none !important;
transition: {
property: background-color, border-color, color;
duration: 33.333ms;
timing-function: linear;
}
font: {
family: "Fira Sans", sans-serif;
size: math.div(15, 16) * 1rem;
}
}
:host(:focus-within) {
border-color: #FFF4;
background: #0006;
color: #FFFF;
}
:host(:not(.editing)) {
cursor: ew-resize;
}
:host(:disabled) {
pointer-events: none !important;
}
.input {
width: 100%;
height: 100%;
border: none;
outline: none !important;
&:first-line {
display: inline-block;
}
}
@@ -0,0 +1,209 @@
import { on } from "@storyteller/framework";
import { PropertyValues, html, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { FormFieldBaseElement } from "./form-field-base.element";
import styles from "./scalar-field.element.scss?inline";
const VALID_INPUT_PATTERN = /[-+*\/.0-9 ]/;
// NOTE: This code was adapted from here:
// https://stackblitz.com/edit/angular-wfikuy?file=src%2Ffloat-input%2Ffloat-input.component.ts
@customElement("sts-scalar-field")
export class ScalarFieldElement extends FormFieldBaseElement {
static override styles = unsafeCSS(styles);
@property({ reflect: true })
override role = "slider";
@property({
type: Number,
attribute: "aria-valuenow",
reflect: true,
})
value = 0;
@property({
type: Number,
attribute: "aria-valuemax",
reflect: true,
})
max = Infinity;
@property({
type: Number,
attribute: "aria-valuemin",
reflect: true,
})
min = -Infinity;
@property({ type: Number })
sensitivity = 0.01;
@property({
type: Number,
attribute: "tabindex",
reflect: true,
})
override tabIndex = 0;
@state() _editing = false;
#inputRef = createRef<HTMLSpanElement>();
#abortControllers = {
"pointermove": null as AbortController | null,
"pointerup": null as AbortController | null,
};
#fmtDecimal = new Intl.NumberFormat("en-US", {
style: "decimal",
minimumIntegerDigits: 1,
minimumFractionDigits: 1,
maximumFractionDigits: 3,
});
protected override update(changes: PropertyValues<this>): void {
if (changes.has("_editing"))
this.classList.toggle("editing", this._editing);
super.update(changes);
}
override disconnectedCallback(): void {
this.#abortControllers.pointermove?.abort();
this.#abortControllers.pointerup?.abort();
super.disconnectedCallback();
}
@on("pointerdown")
onPointerDown(event: PointerEvent): void {
if (this._editing || event.button !== 0)
return;
event.preventDefault();
this.requestPointerLock();
this.#abortControllers.pointermove?.abort();
this.#abortControllers.pointermove = new AbortController();
this.#abortControllers.pointerup?.abort();
this.#abortControllers.pointerup = new AbortController();
const pointerMoveAbortSignal = this.#abortControllers.pointermove.signal;
let totalMovement = 0;
this.addEventListener("pointermove", event => {
totalMovement += event.movementX;
this.#emitValue(this.value + event.movementX * this.sensitivity);
}, {
signal: pointerMoveAbortSignal,
});
pointerMoveAbortSignal.addEventListener("abort", () => {
document.exitPointerLock();
// If no actual dragging occurred between pointerdown and pointerup,
// treat this like a click
if (totalMovement === 0)
this.#activateDirectInput();
});
this.addEventListener("pointerup", () => {
this.#abortControllers.pointermove?.abort();
}, {
once: true,
signal: this.#abortControllers.pointerup.signal,
});
}
@on("focus")
onFocus(): void {
this.#activateDirectInput();
}
onInputEnter(event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
const input = this.#inputRef.value;
if (!input) return;
input.textContent = this.#fmtDecimal.format(this.value);
}
onInputChange(event: InputEvent): void {
try {
const input = this.#inputRef.value!;
const textContent = input.textContent?.trim() ?? "";
if (event.data && !VALID_INPUT_PATTERN.test(event.data)) {
input.textContent = this.value.toString(10);
} else {
const value = parseFloat(textContent);
this.#emitValue(value);
}
} catch {}
}
async #activateDirectInput(): Promise<void> {
this._editing = true;
this.tabIndex = -1;
this.requestUpdate();
await this.updateComplete;
const input = this.#inputRef.value;
if (!input)
throw new Error("Expected input to be available in editing mode");
input.addEventListener("blur", () => {
this._editing = false;
this.tabIndex = 0;
}, {
once: true,
});
input.focus();
input.textContent = this.#fmtDecimal.format(this.value);
const range = document.createRange();
range.selectNodeContents(input);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
#emitValue(value: number): void {
if (this.max != null)
value = Math.min(value, this.max);
if (this.min != null)
value = Math.max(value, this.min);
this.dispatchEvent(new CustomEvent("value-change", {
detail: value,
}));
}
protected override render = () => html`
${!this._editing ? html`
${this.#fmtDecimal.format(this.value)}
` : html`
<span ${ref(this.#inputRef)}
class="input"
part="input"
contenteditable
inputmode="numeric"
@input=${this.onInputChange}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter")
this.onInputEnter(event);
}}
></span>
`}
`;
}
@@ -24,6 +24,10 @@
}
}
*, *::before, *::after {
box-sizing: border-box;
}
[part^="icon-"] {
color: #FFF6;
}
@@ -46,6 +46,7 @@ export class TextFieldElement extends FormFieldBaseElement {
` : nothing}
<input
class="input"
part="input"
.type=${this.fieldType}
placeholder=${ifDefined(this.placeholder)}
.value=${this.value}
@@ -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) : [];
}
+1 -1
View File
@@ -46,7 +46,7 @@ futures-lite = "2.2.0"
futures = "0.3.30"
image = "0.24.8"
bytemuck = "1.14.1"
space_editor = {git = "https://github.com/rewin123/space_editor", rev = "ce83f86dc902787ab16213f3209276a784654df6"}
space_editor = {git = "https://github.com/rewin123/space_editor", rev = "0f2439ba8cd38ebf2db8d90ea6de736992bcb56e"}
anyhow.workspace = true
serde_repr = "0.1.18"
bevy_tweening = "0.9"
+11 -6
View File
@@ -6,8 +6,8 @@ use bevy::{
};
use crate::{
input::{Action, CameraMotion, InputAction, Lifecycle, Selection},
interaction::Selected,
input::{self, Action, CameraMotion, InputAction, Lifecycle},
interaction::Selection,
math::{
Easing, InterpPosition, InterpVelocity, Interpolation, InterpolationSystem, NearlyEq,
NearlyZero, Velocity,
@@ -155,20 +155,25 @@ fn process_physical_inputs(
fn process_focus_inputs(
mut cmd: Commands,
r_selection: Option<Res<Selection>>,
mut input: EventReader<InputAction>,
mut q_camera: Query<(Entity, &CameraController, &mut Velocity)>,
q_interp_vel: Query<&InterpVelocity>,
mut q_interp_pos: Query<&mut InterpPosition>,
q_selected_xform: Query<&GlobalTransform, With<Selected>>,
q_global_xforms: Query<&GlobalTransform>,
) {
let Some(selection) = r_selection else {
return;
};
if input.read().any(|action| {
matches!(
action,
InputAction(Lifecycle::Stop, Action::Selection(Selection::Focus))
InputAction(Lifecycle::Stop, Action::Selection(input::Selection::Focus))
)
}) {
let Ok(target) = q_selected_xform
.get_single()
let Ok(target) = q_global_xforms
.get(**selection)
.map(|xform| xform.translation())
else {
return;
+89 -71
View File
@@ -49,60 +49,73 @@ impl Plugin for SelectionPlugin {
.run_if(resource_changed_or_removed::<Selection>()),
);
app.add_systems(Update, sync_editor_selected);
app.add_systems(PostUpdate, propagate_space_editor_to_studio);
}
}
#[derive(Component, Clone, Copy)]
#[derive(Component, Clone, Copy, Reflect, Default)]
#[reflect(Component, Default)]
pub struct Selectable;
#[derive(Component, Clone, Copy)]
pub struct Selected;
#[derive(Resource, Deref, DerefMut, Debug)]
pub struct Selection(Entity);
fn sync_editor_selected(
fn propagate_space_editor_to_studio(
mut cmd: Commands,
q_new_selected: Query<Entity, (Added<Selected>, Without<space_editor::prelude::Selected>)>,
q_editor_selected: Query<Entity, With<space_editor::prelude::Selected>>,
q_selected: Query<Entity, With<Selected>>,
q_added_editor_selected: Query<
Entity,
(Without<Selected>, Added<space_editor::prelude::Selected>),
>,
r_selection: Option<Res<Selection>>,
q_new_selected: Query<Entity, Added<space_editor::prelude::Selected>>,
mut er_deselected: RemovedComponents<space_editor::prelude::Selected>,
q_parents: Query<&Parent>,
q_selectable: Query<&Selectable>,
) {
for ent in q_new_selected.iter() {
for ed_ent in q_editor_selected.iter() {
cmd.entity(ed_ent)
.remove::<space_editor::prelude::Selected>();
let studio_selection_deselected_in_space_editor = r_selection
.as_ref()
.map(|selection| {
er_deselected
.read()
.any(|deselected| deselected == ***selection)
})
.unwrap_or(false);
if q_new_selected.is_empty() && studio_selection_deselected_in_space_editor {
cmd.remove_resource::<Selection>();
} else {
let mut new_selected_iter = q_new_selected.iter();
let Some(selected) = new_selected_iter.next().and_then(|se_selected| {
nearest_selectable_parent(se_selected, &q_parents, &q_selectable)
}) else {
return;
};
if r_selection.is_none() || **r_selection.unwrap() != selected {
cmd.insert_resource(Selection(selected));
}
cmd.entity(ent).insert(space_editor::prelude::Selected);
}
for ent in q_added_editor_selected.iter() {
cmd.entity(ent).insert(Selected);
cmd.insert_resource(Selection(ent));
for ent in q_selected.iter() {
cmd.entity(ent).remove::<Selected>();
if new_selected_iter.next().is_some() {
warn!(
"Multiple new entities were selected in Space Editor, but \
Storyteller Studio doesn't currently support multiple selections"
);
}
}
}
fn tag_scene_elements(
mut cmd: Commands,
q_scene_elements: Query<(Entity, &Children), (With<SceneElement>, Without<Selectable>)>,
q_scene_elements: Query<(Entity, Option<&Children>), (With<SceneElement>, Without<Selectable>)>,
q_primitives: Query<&Handle<Mesh>>,
) {
for (ent, children) in q_scene_elements.iter() {
cmd.entity(ent).insert(Selectable);
cmd.entity(ent)
.insert((Selectable, PickableBundle::default()));
for &ent_child in children
.iter()
.filter(|child| q_primitives.contains(**child))
{
cmd.entity(ent_child).insert(PickableBundle::default());
if let Some(children) = children {
for &ent_child in children
.iter()
.filter(|child| q_primitives.contains(**child))
{
cmd.entity(ent_child).insert(PickableBundle::default());
}
}
}
}
@@ -117,31 +130,11 @@ fn update_selection(
mut ev_input: EventReader<InputAction>,
q_parents: Query<&Parent>,
q_selectable: Query<&Selectable>,
q_selected: Query<Entity, With<Selected>>,
) {
if let Some(event) = ev_select.read().last() {
// Clear any existing `Selected` components
for ent in q_selected.iter() {
cmd.entity(ent).remove::<Selected>();
}
// Find the nearest Selectable parent of the target entity
let mut ent = event.target;
let ent_selected = loop {
if q_selectable.get(ent).is_ok() {
break Some(ent);
}
if let Ok(parent) = q_parents.get(ent) {
ent = parent.get();
} else {
break None;
}
};
if let Some(ent) = ent_selected {
if let Some(ent) = nearest_selectable_parent(event.target, &q_parents, &q_selectable) {
cmd.insert_resource(Selection(ent));
cmd.entity(ent).insert(Selected);
} else {
cmd.remove_resource::<Selection>();
}
@@ -152,9 +145,23 @@ fn update_selection(
)
}) {
cmd.remove_resource::<Selection>();
}
}
for ent in q_selected.iter() {
cmd.entity(ent).remove::<Selected>();
fn nearest_selectable_parent(
mut target: Entity,
q_parents: &Query<&Parent>,
q_selectable: &Query<&Selectable>,
) -> Option<Entity> {
loop {
if q_selectable.get(target).is_ok() {
break Some(target);
}
if let Ok(parent) = q_parents.get(target) {
target = parent.get();
} else {
break None;
}
}
}
@@ -192,7 +199,8 @@ fn finalize_delete(mut cmd: Commands, q: Query<Entity, With<MarkedForDeletion>>)
fn draw_selection_outline(
mut cmd: Commands,
q_selected: Query<&Children, With<Selected>>,
r_selection: Option<Res<Selection>>,
q_children: Query<&Children>,
q_meshes: Query<
Entity,
(
@@ -209,19 +217,30 @@ fn draw_selection_outline(
cmd.entity(ent).remove::<OutlineBundle>();
}
for children in q_selected.iter() {
for &child in children.iter() {
if q_meshes.contains(child) {
cmd.entity(child).insert(OutlineBundle {
outline: OutlineVolume {
visible: true,
width: 4.,
colour: Color::YELLOW,
},
..default()
});
}
}
let Some(selection) = r_selection else {
return;
};
let target = if q_meshes.contains(**selection) {
Some(**selection)
} else {
q_children.get(**selection).ok().and_then(|children| {
children
.iter()
.copied()
.find(|&child| q_meshes.contains(child))
})
};
if let Some(target) = target {
cmd.entity(target).insert(OutlineBundle {
outline: OutlineVolume {
visible: true,
width: 4.,
colour: Color::YELLOW,
},
..default()
});
}
}
@@ -233,7 +252,7 @@ mod wasm {
};
use wasm_bindgen::prelude::*;
use super::{Selected, Selection};
use super::Selection;
use crate::wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer};
#[wasm_bindgen(typescript_custom_section)]
@@ -273,7 +292,6 @@ declare global {
pub(super) fn update_selection_from_frontend(mut cmd: Commands) {
if let Some(entity) = SELECTION_REQUEST.take_message() {
cmd.insert_resource(Selection(entity));
cmd.entity(entity).insert(Selected);
}
}
}
+9 -2
View File
@@ -17,7 +17,10 @@ use bevy::{
use serde_repr::{Deserialize_repr, Serialize_repr};
use space_editor::{
prelude::{EditorEvent, EditorPrefabPath, PrefabMarker},
space_prefab::component::{GltfPrefab, SceneAutoChild},
space_prefab::{
component::{GltfPrefab, SceneAutoChild},
editor_registry::EditorRegistryExt,
},
};
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::wasm_bindgen;
@@ -109,6 +112,8 @@ impl Plugin for ScenePlugin {
wasm::notify_loading_queue_changes.map(bevy::utils::error),
),
);
app.editor_registry::<SceneElement>();
}
}
@@ -253,9 +258,11 @@ pub struct ImportSceneEvent(pub String);
wasm_bindgen,
derive(Serialize_repr, Deserialize_repr)
)]
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Reflect, Default)]
#[reflect(Component, Default)]
#[allow(dead_code)] // Some variants only used in `wasm`, some planned for later use
pub enum SceneElement {
#[default]
Generic,
Mesh,
Skeleton,
+2 -9
View File
@@ -1,7 +1,6 @@
use std::sync::Arc;
use bevy::prelude::*;
use bevy_mod_picking::PickableBundle;
use space_editor::prelude::{
space_undo::{AddedEntity, NewChange},
*,
@@ -9,7 +8,6 @@ use space_editor::prelude::{
use wasm_bindgen::prelude::*;
use crate::{
interaction::Selectable,
scene::SceneElement,
wasm::{StaticBuffer, WasmMessageBuffer},
};
@@ -27,13 +25,8 @@ fn read_command_system(mut commands: Commands, mut change_event: EventWriter<New
return;
};
let mut new_entity = commands.spawn((
SpatialBundle::default(),
PrefabMarker,
Selectable,
SceneElement::Mesh,
PickableBundle::default(),
));
let mut new_entity =
commands.spawn((SpatialBundle::default(), PrefabMarker, SceneElement::Mesh));
match cmd {
BlockingCommand::Cube => {
+281 -103
View File
@@ -8,18 +8,20 @@ use bevy::{
window::PrimaryWindow,
};
use serde::{Deserialize, Serialize};
use serde_json::*;
use serde_json::{self, json, Map as JsonMap, Value as JsonValue};
use space_editor::{
space_editor_ui::{
editor_tab::{EditorTab, EditorTabName},
EditorUiAppExt,
},
space_prefab::component::MeshPrimitivePrefab,
space_prefab::{
component::MeshPrimitivePrefab, editor_registry::EditorRegistryExt, save::SaveState,
},
};
use wasm_bindgen::prelude::*;
use crate::{
interaction::Selected,
interaction::Selection,
wasm::{self, StaticBuffer, WasmMessageBuffer},
};
@@ -48,7 +50,9 @@ impl Plugin for InspectorPlugin {
app.add_systems(
Update,
update_field_by_ui.in_set(InspectorSystem::WriteChanges),
update_field_by_ui
.map(bevy::utils::error)
.in_set(InspectorSystem::WriteChanges),
);
app.editor_tab_by_trait(
@@ -64,12 +68,13 @@ impl Plugin for InspectorPlugin {
//register standart material
{
app.register_type::<ShortMaterial>()
app.editor_registry::<ShortMaterial>()
.add_event::<UpdateInspect<Handle<StandardMaterial>>>()
.add_systems(
Update,
(
update_inspect_by_select_change::<Handle<StandardMaterial>>,
update_inspect_by_select_change::<Handle<StandardMaterial>>
.run_if(resource_changed_or_removed::<Selection>()),
update_inspect_by_remove::<Handle<StandardMaterial>>,
update_inspect_by_change::<Handle<StandardMaterial>>,
notify_asset_change_system::<StandardMaterial>,
@@ -81,32 +86,90 @@ impl Plugin for InspectorPlugin {
serialize_standart_materal.in_set(InspectorSystem::PerComponentCollect),
);
app.add_systems(
OnEnter(SaveState::Save),
(create_short_materials, apply_deferred)
.chain()
.before(space_editor::space_prefab::sub_scene::prepare_auto_scene),
);
app.add_systems(PostUpdate, apply_short_materials);
app.world.resource_mut::<InspectGetters>().getters.insert(
StandardMaterial::get_type_registration()
.type_info()
.type_path()
.to_string(),
Arc::new(|entity, path, world, f| unsafe {
let handle = entity.get::<Handle<StandardMaterial>>().unwrap();
let handle = entity.get::<Handle<StandardMaterial>>().ok_or_else(|| {
format!(
"Failed to get StandardMaterial for entity {:?}",
entity.id()
)
})?;
let mut assets = world
.get_resource_mut::<Assets<StandardMaterial>>()
.unwrap();
let material = assets.get_mut(handle).unwrap();
.ok_or_else(|| {
"Failed to get `Assets<StandadrMaterial>` resource".to_string()
})?;
let reflect_field = material.reflect_path_mut(path).unwrap();
(f)(reflect_field);
let material = assets.get_mut(handle).ok_or_else(|| {
format!("Failed to retrieve material from handle {handle:?}")
})?;
let reflect_field = material
.reflect_path_mut(path)
.map_err(|err| format!("{err}"))?;
(f)(reflect_field)
}),
);
}
}
}
fn create_short_materials(
mut commands: Commands,
q_mats: Query<(Entity, &Handle<StandardMaterial>)>,
materials: Res<Assets<StandardMaterial>>,
) {
for (entity, handle) in q_mats.iter() {
if let Some(material) = materials.get(handle) {
commands.entity(entity).insert(ShortMaterial::new(material));
}
}
}
fn apply_short_materials(
mut commands: Commands,
q_mats: Query<(Entity, &Handle<StandardMaterial>, &ShortMaterial)>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
for (entity, handle, short) in q_mats.iter() {
let Some(material) = materials.get_mut(handle) else {
continue;
};
material.base_color = short.base_color;
material.perceptual_roughness = short.perceptual_roughness;
material.metallic = short.metallic;
material.reflectance = short.reflectance;
commands.entity(entity).remove::<ShortMaterial>();
}
}
#[derive(Resource, Default)]
struct InspectGetters {
getters: HashMap<
String,
Arc<
dyn Fn(&mut EntityWorldMut, &str, &mut UnsafeWorldCell, &dyn Fn(&mut dyn Reflect))
dyn Fn(
&mut EntityWorldMut,
&str,
&mut UnsafeWorldCell,
&dyn Fn(&mut dyn Reflect) -> Result<(), String>,
) -> Result<(), String>
+ Send
+ Sync,
>,
@@ -125,7 +188,8 @@ impl AppInspectable for App {
.add_systems(
Update,
(
update_inspect_by_select_change::<T>,
update_inspect_by_select_change::<T>
.run_if(resource_changed_or_removed::<Selection>()),
update_inspect_by_remove::<T>,
update_inspect_by_change::<T>,
)
@@ -146,7 +210,7 @@ impl AppInspectable for App {
Arc::new(|entity, path, _, f| {
let mut component = entity.get_mut::<T>().unwrap();
let reflect_field = component.reflect_path_mut(path).unwrap();
(f)(reflect_field);
(f)(reflect_field)
}),
);
}
@@ -158,7 +222,8 @@ impl AppInspectable for App {
.add_systems(
Update,
(
update_inspect_by_select_change::<Handle<T>>,
update_inspect_by_select_change::<Handle<T>>
.run_if(resource_changed_or_removed::<Selection>()),
update_inspect_by_remove::<Handle<T>>,
update_inspect_by_change::<Handle<T>>,
notify_asset_change_system::<T>,
@@ -186,7 +251,7 @@ impl EditorTab for TestInspectorTab {
CHANGE_FROM_UI.write_message(UpdateField {
component: "bevy_transform::components::transform::Transform".to_string(),
reflect_path: ".translation.x".to_string(),
value: serde_json::Value::from(0.0),
value: JsonValue::from(0.0),
});
}
}
@@ -220,116 +285,171 @@ impl<T> Default for UpdateInspect<T> {
}
}
fn update_field_by_ui(world: &mut World) {
#[derive(Serialize, Deserialize)]
struct Rgba {
red: f32,
green: f32,
blue: f32,
alpha: f32,
}
fn update_field_by_ui(world: &mut World) -> Result<(), String> {
unsafe {
let mut unsafe_world = world.as_unsafe_world_cell();
let Some(msg) = CHANGE_FROM_UI.take_message() else {
return;
return Ok(());
};
info!("Processing update: {msg:?}");
let mut selected_query = unsafe_world
.world_mut()
.query_filtered::<Entity, With<Selected>>();
let Ok(entity) = selected_query.get_single(unsafe_world.world_mut()) else {
return;
let Some(selection) = unsafe_world.world_mut().get_resource::<Selection>() else {
return Ok(());
};
let entity = **selection;
let getters = unsafe_world.get_resource_mut::<InspectGetters>().unwrap();
let getter = getters
.getters
.get(&msg.component)
.ok_or_else(|| format!("Failed to find getter for component: {}", msg.component))?;
let mut entity_mut = unsafe_world.world_mut().entity_mut(entity);
// TODO: This is pretty ugly
let target = 'find_target: {
if &msg.component[..] == "bevy_pbr::pbr_material::StandardMaterial" {
let mut q_handles = unsafe_world
.world_mut()
.query::<&Handle<StandardMaterial>>();
let Some(getter) = getters.getters.get(&msg.component) else {
error!("Failed to find getter for component: {}", msg.component);
return;
if q_handles.get(unsafe_world.world(), entity).is_ok() {
break 'find_target Some(entity);
}
let mut q_children = unsafe_world.world_mut().query::<&Children>();
let Ok(ent_children) = q_children.get(unsafe_world.world(), entity) else {
break 'find_target None;
};
break 'find_target ent_children
.iter()
.copied()
.find(|&child| q_handles.get(unsafe_world.world(), child).is_ok());
} else {
break 'find_target Some(entity);
}
};
let Some(target) = target else {
return Err(format!(
"Failed to find target for component {}",
&msg.component
));
};
info!("Applying to entity: {target:?}");
let mut entity_mut = unsafe_world.world_mut().entity_mut(target);
getter.as_ref()(
&mut entity_mut,
&msg.reflect_path,
&mut unsafe_world,
&|reflect_field| {
let fmt_err = |err: serde_json::Error| {
format!(
"Failed to parse value {} for path {}: {err}",
msg.value, &msg.reflect_path,
)
};
if let Some(val) = reflect_field.downcast_mut::<f32>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<f64>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<u32>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<u64>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<i32>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<i64>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<bool>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<String>() {
*val = msg.value.as_str().unwrap().to_string();
let msg_value = msg
.value
.as_str()
.ok_or_else(|| format!("Failed to parse value as string: {}", msg.value))?;
*val = msg_value.to_string();
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<Vec3>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<Quat>() {
let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else {
error!("Failed to parse value: {}", msg.value);
return;
};
let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
*val = msg_value;
Ok(())
} else if let Some(val) = reflect_field.downcast_mut::<Color>() {
let Rgba {
red,
green,
blue,
alpha,
} = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?;
match *val {
Color::RgbaLinear { .. } => {
*val = Color::RgbaLinear {
red,
green,
blue,
alpha,
};
}
Color::Rgba { .. } => {
*val = Color::Rgba {
red,
green,
blue,
alpha,
};
}
other => {
return Err(format!("Unsupported color format: {other:?}"));
}
}
Ok(())
} else {
error!("Unsupported type {}!", &msg.reflect_path);
return;
Err(format!("Unsupported type {}!", &msg.reflect_path))
}
},
);
)
}
}
#[derive(Resource, Serialize, Deserialize, Default, Clone)]
pub struct EntityDescription {
pub components: HashMap<String, Value>,
pub components: HashMap<String, JsonValue>,
}
fn update_inspect_by_select_change<T: Component + Reflect + GetTypeRegistration>(
mut ev_inspect: EventWriter<UpdateInspect<T>>,
q_added: Query<(), Added<Selected>>,
q_removed: RemovedComponents<Selected>,
) {
if !q_added.is_empty() {
ev_inspect.send(UpdateInspect::<T>::default());
}
if !q_removed.is_empty() {
ev_inspect.send(UpdateInspect::<T>::default());
}
ev_inspect.send(UpdateInspect::<T>::default());
}
fn update_inspect_by_remove<T: Component + Reflect + GetTypeRegistration>(
@@ -342,16 +462,20 @@ fn update_inspect_by_remove<T: Component + Reflect + GetTypeRegistration>(
}
fn update_inspect_by_change<T: Component + Reflect + GetTypeRegistration>(
r_selection: Option<Res<Selection>>,
mut ev_inspect: EventWriter<UpdateInspect<T>>,
q_t_changed: Query<&T, (With<Selected>, Changed<T>)>,
q_t_changed: Query<&T, Changed<T>>,
) {
if q_t_changed.iter().next().is_some() {
ev_inspect.send(UpdateInspect::<T>::default());
if let Some(selection) = r_selection {
if q_t_changed.contains(**selection) {
ev_inspect.send(UpdateInspect::<T>::default());
}
}
}
fn auto_serialize_system<T: Component + Reflect + GetTypeRegistration>(
q_t: Query<&T, With<Selected>>,
r_selection: Option<Res<Selection>>,
q_t: Query<&T>,
mut description: ResMut<EntityDescription>,
registry: Res<AppTypeRegistry>,
mut ev_inspect: EventReader<UpdateInspect<T>>,
@@ -363,7 +487,7 @@ fn auto_serialize_system<T: Component + Reflect + GetTypeRegistration>(
ev_inspect.clear();
if let Some(t) = q_t.iter().next() {
if let Some(t) = r_selection.and_then(|selection| q_t.get(**selection).ok()) {
let registry = registry.read();
let json = serde_json::to_value(ReflectSerializer::new(t, &registry)).unwrap();
info!("json: {:?}", json.to_string());
@@ -384,12 +508,20 @@ fn auto_serialize_system<T: Component + Reflect + GetTypeRegistration>(
}
fn notify_asset_change_system<T: Asset>(
r_selection: Option<Res<Selection>>,
mut ev_inspect: EventWriter<UpdateInspect<Handle<T>>>,
mut asset_changing: EventReader<AssetEvent<T>>,
mut selected: Query<&Handle<T>, With<Selected>>,
q_handles: Query<&Handle<T>>,
q_children: Query<&Children>,
) {
for ev in asset_changing.read() {
for handle in selected.iter_mut() {
if let Some(handle) = r_selection
.as_ref()
.and_then(|selection| {
find_handle_in_self_or_children(***selection, &q_children, &q_handles)
})
.and_then(|target| q_handles.get(target).ok())
{
if ev.is_modified(handle.id()) {
ev_inspect.send(UpdateInspect::<Handle<T>>::default());
}
@@ -398,7 +530,9 @@ fn notify_asset_change_system<T: Asset>(
}
fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
q_t: Query<&Handle<T>, With<Selected>>,
r_selection: Option<Res<Selection>>,
q_t: Query<&Handle<T>>,
q_children: Query<&Children>,
mut description: ResMut<EntityDescription>,
registry: Res<AppTypeRegistry>,
mut ev_inspect: EventReader<UpdateInspect<Handle<T>>>,
@@ -411,7 +545,11 @@ fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
ev_inspect.clear();
if let Some(handle) = q_t.iter().next() {
if let Some(handle) = r_selection
.as_ref()
.and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_t))
.and_then(|target| q_t.get(target).ok())
{
if let Some(asset) = assets.get(handle) {
let registry = registry.read();
info!(
@@ -441,22 +579,30 @@ fn auto_serialize_asset<T: Asset + Reflect + GetTypeRegistration>(
ev_update_description.send(UpdateEntityDescription);
}
#[derive(Reflect, Default)]
#[reflect(Default)]
#[derive(Reflect, Default, Clone, Component)]
#[reflect(Default, Component)]
struct ShortMaterial {
pub base_color: Color,
pub perceptual_roughness: f32,
pub metallic: f32,
pub reflectance: f32,
}
impl ShortMaterial {
pub fn new(material: &StandardMaterial) -> Self {
Self {
base_color: material.base_color,
perceptual_roughness: material.perceptual_roughness,
metallic: material.metallic,
reflectance: material.reflectance,
}
}
}
fn serialize_standart_materal(
r_selection: Option<Res<Selection>>,
q_m: Query<&Handle<StandardMaterial>>,
q_children: Query<&Children>,
mut description: ResMut<EntityDescription>,
registry: Res<AppTypeRegistry>,
mut ev_inspect: EventReader<UpdateInspect<Handle<StandardMaterial>>>,
@@ -469,8 +615,12 @@ fn serialize_standart_materal(
ev_inspect.clear();
if let Some(handle) = q_m.iter().next() {
if let Some(material) = assets.get(handle) {
if let Some((target, handle)) = r_selection
.as_ref()
.and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_m))
.and_then(|target| q_m.get(target).ok().map(|h| (target, h.clone())))
{
if let Some(material) = assets.get(handle.clone()) {
let registry = registry.read();
let short_material = ShortMaterial::new(material);
let json =
@@ -522,11 +672,11 @@ fn update_description(
.components
.iter()
.map(|(name, v)| {
let mut value = Value::Object(Map::new());
let mut value = JsonValue::Object(JsonMap::new());
value
.as_object_mut()
.unwrap()
.insert("name".to_string(), Value::String(name.to_string()));
.insert("name".to_string(), JsonValue::String(name.to_string()));
value
.as_object_mut()
.unwrap()
@@ -543,25 +693,54 @@ fn update_description(
}
}
#[derive(Deserialize, Serialize)]
/// Find a `Handle<T>` component on this entity or one of its direct children
fn find_handle_in_self_or_children<T: Asset>(
target: Entity,
q_children: &Query<&Children>,
q_handles: &Query<&Handle<T>>,
) -> Option<Entity> {
if q_handles.contains(target) {
return Some(target);
}
let Ok(children) = q_children.get(target) else {
return None;
};
children
.iter()
.copied()
.find(|&child| q_handles.contains(child))
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UpdateField {
pub component: String,
pub reflect_path: String,
pub value: serde_json::Value,
pub value: JsonValue,
}
#[wasm_bindgen(typescript_custom_section)]
const EVENT_DECLARATION: &str = r#"
export interface InspectorChanged extends CustomEvent {
export interface InspectorChangedEvent extends CustomEvent {
detail: string | null;
}
declare global {
export interface HTMLElementEventMap {
"inspector-changed": InspectorChanged;
export interface HTMLElementEventMap {
"inspector-changed": InspectorChangedEvent;
}
}
export interface UpdateField<T> {
component: string;
reflectPath: string;
value: T;
}
export function updateComponentField<T>(params: UpdateField<T>): void;
"#;
pub(super) fn notify_changed(q_window: &Window, msg: String) {
@@ -573,11 +752,10 @@ pub(super) fn notify_changed(q_window: &Window, msg: String) {
static CHANGE_FROM_UI: WasmMessageBuffer<UpdateField> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = changeValue)]
pub fn change_value(msg: JsValue) {
let Ok(msg) = serde_wasm_bindgen::from_value(msg) else {
error!("Failed to parse message");
return;
};
#[wasm_bindgen(js_name = updateComponentField, skip_typescript)]
pub fn update_component_field(msg: JsValue) -> Result<(), JsValue> {
let msg = serde_wasm_bindgen::from_value(msg)?;
info!("Received update: {msg:?}");
CHANGE_FROM_UI.write_message(msg);
Ok(())
}
+1
View File
@@ -27,6 +27,7 @@
"@storyteller/studio-ui/toolbar": ["studio-ui/src/lib/toolbar.element.ts"],
"@storyteller/studio-ui/tree": ["studio-ui/src/lib/tree.element.ts"],
"@storyteller/studio-ui/forms/text-field": ["studio-ui/src/lib/forms/text-field.element.ts"],
"@storyteller/studio-ui/forms/scalar-field": ["studio-ui/src/lib/forms/scalar-field.element.ts"],
"@storyteller/studio-web": ["studio-web/src/index.ts"],
"@storyteller/studio-web/add-to-scene-menu": ["studio-web/src/lib/add-to-scene-menu.element.ts"],
"@storyteller/studio-web/canvas-provider": ["studio-web/src/lib/canvas-provider.element.ts"],