diff --git a/Cargo.lock b/Cargo.lock index 2a4788a..0efdb6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/studio-ui/src/lib/forms/index.ts b/studio-ui/src/lib/forms/index.ts index 852be6d..ae82dc4 100644 --- a/studio-ui/src/lib/forms/index.ts +++ b/studio-ui/src/lib/forms/index.ts @@ -1 +1,2 @@ export * from "./text-field.element"; +export * from "./scalar-field.element"; diff --git a/studio-ui/src/lib/forms/scalar-field.element.scss b/studio-ui/src/lib/forms/scalar-field.element.scss new file mode 100644 index 0000000..6d20b1e --- /dev/null +++ b/studio-ui/src/lib/forms/scalar-field.element.scss @@ -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; + } +} diff --git a/studio-ui/src/lib/forms/scalar-field.element.ts b/studio-ui/src/lib/forms/scalar-field.element.ts new file mode 100644 index 0000000..06a592d --- /dev/null +++ b/studio-ui/src/lib/forms/scalar-field.element.ts @@ -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(); + #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): 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 { + 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` + { + if (event.key === "Enter") + this.onInputEnter(event); + }} + > + `} + `; +} diff --git a/studio-ui/src/lib/forms/text-field.element.scss b/studio-ui/src/lib/forms/text-field.element.scss index 0236a5a..cf29771 100644 --- a/studio-ui/src/lib/forms/text-field.element.scss +++ b/studio-ui/src/lib/forms/text-field.element.scss @@ -24,6 +24,10 @@ } } +*, *::before, *::after { + box-sizing: border-box; +} + [part^="icon-"] { color: #FFF6; } diff --git a/studio-ui/src/lib/forms/text-field.element.ts b/studio-ui/src/lib/forms/text-field.element.ts index 5df4053..e241c32 100644 --- a/studio-ui/src/lib/forms/text-field.element.ts +++ b/studio-ui/src/lib/forms/text-field.element.ts @@ -46,6 +46,7 @@ export class TextFieldElement extends FormFieldBaseElement { ` : nothing} { 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` `; - case "bevy_transform::components::transform::Transform": + } + case "bevy_transform::components::transform::Transform": { return html` `; - 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` -

Unknown Component: ${name}

-
${JSON.stringify({ name, value }, null, "   ")}
+ `; + } + default: { + return nothing; + // return html` + //

Unknown Component: ${name}

+ //
${JSON.stringify({ name, value }, null, "   ")}
+ // `; + } } }) } @@ -72,4 +96,5 @@ export class EntityInspectorElement extends LitElement { const COMPONENT_SORT_ORDER: Array = [ "bevy_core::name::Name", "bevy_transform::components::transform::Transform", + "bevy_pbr::pbr_material::StandardMaterial", ]; diff --git a/studio-web/src/lib/inspector/inspector.types.ts b/studio-web/src/lib/inspector/inspector.types.ts index 06e585c..d9f9fc4 100644 --- a/studio-web/src/lib/inspector/inspector.types.ts +++ b/studio-web/src/lib/inspector/inspector.types.ts @@ -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 { @@ -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; +} diff --git a/studio-web/src/lib/inspector/material-inspector.element.scss b/studio-web/src/lib/inspector/material-inspector.element.scss new file mode 100644 index 0000000..5e893b0 --- /dev/null +++ b/studio-web/src/lib/inspector/material-inspector.element.scss @@ -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%; +} diff --git a/studio-web/src/lib/inspector/material-inspector.element.ts b/studio-web/src/lib/inspector/material-inspector.element.ts new file mode 100644 index 0000000..77aa6f8 --- /dev/null +++ b/studio-web/src/lib/inspector/material-inspector.element.ts @@ -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) { + return ({ detail: value }: CustomEvent) => { + 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` +

Material

+ +
+ + + + + + + + + + + +
+ `; + } +} + +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 }; + } +} diff --git a/studio-web/src/lib/inspector/name-inspector.element.ts b/studio-web/src/lib/inspector/name-inspector.element.ts index aff85f5..65a0613 100644 --- a/studio-web/src/lib/inspector/name-inspector.element.ts +++ b/studio-web/src/lib/inspector/name-inspector.element.ts @@ -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): 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); } diff --git a/studio-web/src/lib/inspector/transform-inspector.element.scss b/studio-web/src/lib/inspector/transform-inspector.element.scss index c9afa4b..2ccd8a2 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.scss +++ b/studio-web/src/lib/inspector/transform-inspector.element.scss @@ -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; - } } } diff --git a/studio-web/src/lib/inspector/transform-inspector.element.ts b/studio-web/src/lib/inspector/transform-inspector.element.ts index 006c441..bb1e204 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.ts +++ b/studio-web/src/lib/inspector/transform-inspector.element.ts @@ -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) => { + 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` -

Transform

-
- Translation - - - -
-
- Rotation - - - +
+ + Position + + + + +
+
+ + Rotation + + + + + -
-
- Scale - - - -
+ +
+ + Scale + + + + +
`; } diff --git a/studio-web/src/lib/scene-data-provider.element.ts b/studio-web/src/lib/scene-data-provider.element.ts index ac1e1d8..1b5e034 100644 --- a/studio-web/src/lib/scene-data-provider.element.ts +++ b/studio-web/src/lib/scene-data-provider.element.ts @@ -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) : []; } diff --git a/studio/Cargo.toml b/studio/Cargo.toml index 2872673..b5d9bc2 100644 --- a/studio/Cargo.toml +++ b/studio/Cargo.toml @@ -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" diff --git a/studio/src/camera_controller.rs b/studio/src/camera_controller.rs index 66d4ae4..bee5881 100644 --- a/studio/src/camera_controller.rs +++ b/studio/src/camera_controller.rs @@ -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>, mut input: EventReader, 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>, + 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; diff --git a/studio/src/interaction/selection.rs b/studio/src/interaction/selection.rs index b37e881..405eb4c 100644 --- a/studio/src/interaction/selection.rs +++ b/studio/src/interaction/selection.rs @@ -49,60 +49,73 @@ impl Plugin for SelectionPlugin { .run_if(resource_changed_or_removed::()), ); - 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, Without)>, - q_editor_selected: Query>, - q_selected: Query>, - q_added_editor_selected: Query< - Entity, - (Without, Added), - >, + r_selection: Option>, + q_new_selected: Query>, + mut er_deselected: RemovedComponents, + 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::(); + 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::(); + } 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::(); + 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, Without)>, + q_scene_elements: Query<(Entity, Option<&Children>), (With, Without)>, q_primitives: Query<&Handle>, ) { 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, q_parents: Query<&Parent>, q_selectable: Query<&Selectable>, - q_selected: Query>, ) { if let Some(event) = ev_select.read().last() { - // Clear any existing `Selected` components - for ent in q_selected.iter() { - cmd.entity(ent).remove::(); - } - // 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::(); } @@ -152,9 +145,23 @@ fn update_selection( ) }) { cmd.remove_resource::(); + } +} - for ent in q_selected.iter() { - cmd.entity(ent).remove::(); +fn nearest_selectable_parent( + mut target: Entity, + q_parents: &Query<&Parent>, + q_selectable: &Query<&Selectable>, +) -> Option { + 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>) fn draw_selection_outline( mut cmd: Commands, - q_selected: Query<&Children, With>, + r_selection: Option>, + q_children: Query<&Children>, q_meshes: Query< Entity, ( @@ -209,19 +217,30 @@ fn draw_selection_outline( cmd.entity(ent).remove::(); } - 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); } } } diff --git a/studio/src/scene.rs b/studio/src/scene.rs index 3b51f3d..26652e1 100644 --- a/studio/src/scene.rs +++ b/studio/src/scene.rs @@ -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::(); } } @@ -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, diff --git a/studio/src/ui/blocking.rs b/studio/src/ui/blocking.rs index b32a766..68bb743 100644 --- a/studio/src/ui/blocking.rs +++ b/studio/src/ui/blocking.rs @@ -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 { diff --git a/studio/src/ui/inspector.rs b/studio/src/ui/inspector.rs index 851a4bf..45481ae 100644 --- a/studio/src/ui/inspector.rs +++ b/studio/src/ui/inspector.rs @@ -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::() + app.editor_registry::() .add_event::>>() .add_systems( Update, ( - update_inspect_by_select_change::>, + update_inspect_by_select_change::> + .run_if(resource_changed_or_removed::()), update_inspect_by_remove::>, update_inspect_by_change::>, notify_asset_change_system::, @@ -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::().getters.insert( StandardMaterial::get_type_registration() .type_info() .type_path() .to_string(), Arc::new(|entity, path, world, f| unsafe { - let handle = entity.get::>().unwrap(); + let handle = entity.get::>().ok_or_else(|| { + format!( + "Failed to get StandardMaterial for entity {:?}", + entity.id() + ) + })?; + let mut assets = world .get_resource_mut::>() - .unwrap(); - let material = assets.get_mut(handle).unwrap(); + .ok_or_else(|| { + "Failed to get `Assets` 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)>, + materials: Res>, +) { + 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, &ShortMaterial)>, + mut materials: ResMut>, +) { + 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::(); + } +} + #[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::, + update_inspect_by_select_change:: + .run_if(resource_changed_or_removed::()), update_inspect_by_remove::, update_inspect_by_change::, ) @@ -146,7 +210,7 @@ impl AppInspectable for App { Arc::new(|entity, path, _, f| { let mut component = entity.get_mut::().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::>, + update_inspect_by_select_change::> + .run_if(resource_changed_or_removed::()), update_inspect_by_remove::>, update_inspect_by_change::>, notify_asset_change_system::, @@ -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 Default for UpdateInspect { } } -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::>(); - let Ok(entity) = selected_query.get_single(unsafe_world.world_mut()) else { - return; + let Some(selection) = unsafe_world.world_mut().get_resource::() else { + return Ok(()); }; + let entity = **selection; let getters = unsafe_world.get_resource_mut::().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>(); - 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::() { - 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::() { - 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::() { - 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::() { - 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::() { - 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::() { - 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::() { - 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::() { - *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::() { - 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::() { - 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::() { + 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, + pub components: HashMap, } fn update_inspect_by_select_change( mut ev_inspect: EventWriter>, - q_added: Query<(), Added>, - q_removed: RemovedComponents, ) { - if !q_added.is_empty() { - ev_inspect.send(UpdateInspect::::default()); - } - - if !q_removed.is_empty() { - ev_inspect.send(UpdateInspect::::default()); - } + ev_inspect.send(UpdateInspect::::default()); } fn update_inspect_by_remove( @@ -342,16 +462,20 @@ fn update_inspect_by_remove( } fn update_inspect_by_change( + r_selection: Option>, mut ev_inspect: EventWriter>, - q_t_changed: Query<&T, (With, Changed)>, + q_t_changed: Query<&T, Changed>, ) { - if q_t_changed.iter().next().is_some() { - ev_inspect.send(UpdateInspect::::default()); + if let Some(selection) = r_selection { + if q_t_changed.contains(**selection) { + ev_inspect.send(UpdateInspect::::default()); + } } } fn auto_serialize_system( - q_t: Query<&T, With>, + r_selection: Option>, + q_t: Query<&T>, mut description: ResMut, registry: Res, mut ev_inspect: EventReader>, @@ -363,7 +487,7 @@ fn auto_serialize_system( 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( } fn notify_asset_change_system( + r_selection: Option>, mut ev_inspect: EventWriter>>, mut asset_changing: EventReader>, - mut selected: Query<&Handle, With>, + q_handles: Query<&Handle>, + 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::>::default()); } @@ -398,7 +530,9 @@ fn notify_asset_change_system( } fn auto_serialize_asset( - q_t: Query<&Handle, With>, + r_selection: Option>, + q_t: Query<&Handle>, + q_children: Query<&Children>, mut description: ResMut, registry: Res, mut ev_inspect: EventReader>>, @@ -411,7 +545,11 @@ fn auto_serialize_asset( 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( 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>, q_m: Query<&Handle>, + q_children: Query<&Children>, mut description: ResMut, registry: Res, mut ev_inspect: EventReader>>, @@ -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` component on this entity or one of its direct children +fn find_handle_in_self_or_children( + target: Entity, + q_children: &Query<&Children>, + q_handles: &Query<&Handle>, +) -> Option { + 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 { + component: string; + reflectPath: string; + value: T; } + +export function updateComponentField(params: UpdateField): 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 = 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(()) } diff --git a/tsconfig.base.json b/tsconfig.base.json index abe4e99..7b67ed2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],