mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +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:
Generated
+8
-8
@@ -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 +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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, ®istry)).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(())
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user