Skip to content

Commit 2e5e7f7

Browse files
authored
[Inspectorv2] Add metadata properties (#16975)
1 parent 29d429a commit 2e5e7f7

File tree

5 files changed

+366
-1
lines changed

5 files changed

+366
-1
lines changed
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import type { Nullable } from "core/types";
2+
import type { FunctionComponent } from "react";
3+
4+
import { Observable } from "core/Misc/observable";
5+
import { ButtonLine } from "shared-ui-components/fluent/hoc/buttonLine";
6+
import { SwitchPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/switchPropertyLine";
7+
import { TextPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/textPropertyLine";
8+
import { Textarea } from "shared-ui-components/fluent/primitives/textarea";
9+
import { useObservableState } from "../../hooks/observableHooks";
10+
import { BoundProperty } from "./boundProperty";
11+
12+
enum MetadataTypes {
13+
NULL = "null",
14+
STRING = "string",
15+
OBJECT = "Object",
16+
JSON = "JSON",
17+
}
18+
19+
const PrettyJSONIndent = 2;
20+
21+
function IsParsable(input: any): boolean {
22+
try {
23+
const parsed = JSON.parse(input);
24+
return !!parsed && !IsString(parsed);
25+
} catch (error) {
26+
return false;
27+
}
28+
}
29+
30+
/**
31+
* Checks if the input is a string.
32+
* @param input - any input to check
33+
* @returns boolean - true if the input is a string, false otherwise
34+
*/
35+
function IsString(input: any): boolean {
36+
return typeof input === "string" || input instanceof String;
37+
}
38+
39+
/**
40+
* Checks recursively for functions on an object and returns `false` if any are found.
41+
* @param o any object, string or number
42+
* @returns boolean
43+
*/
44+
function ObjectCanSafelyStringify(o: object | string | number | boolean): boolean {
45+
if (typeof o === "function") {
46+
return false;
47+
}
48+
if (o === null || o === true || o === false || typeof o === "number" || IsString(o)) {
49+
return true;
50+
}
51+
52+
if (typeof o === "object") {
53+
if (Object.values(o).length === 0) {
54+
return true;
55+
}
56+
return Object.values(o as Record<string, any>).every((value) => ObjectCanSafelyStringify(value));
57+
}
58+
59+
if (Array.isArray(o)) {
60+
return o.every((value) => ObjectCanSafelyStringify(value));
61+
}
62+
63+
return false;
64+
}
65+
66+
export interface IMetadataContainer {
67+
metadata: any;
68+
}
69+
70+
class MetadataUtils {
71+
private _editedMetadata: Nullable<string> = null;
72+
73+
public readonly settingsChangedObservable = new Observable<MetadataUtils>();
74+
75+
constructor(public readonly entity: IMetadataContainer) {}
76+
77+
get editedMetadata(): string {
78+
if (this._editedMetadata === null || this._editedMetadata === undefined) {
79+
this._editedMetadata = this.parsedMetadata;
80+
}
81+
82+
if (this._editedMetadata && this.prettyJSON && this.isParsable) {
83+
return JSON.stringify(JSON.parse(this._editedMetadata), undefined, PrettyJSONIndent);
84+
}
85+
86+
return this._editedMetadata ?? "";
87+
}
88+
89+
set editedMetadata(value: string) {
90+
if (this._editedMetadata !== value) {
91+
this._editedMetadata = value;
92+
this.settingsChangedObservable.notifyObservers(this);
93+
}
94+
}
95+
96+
get entityType(): MetadataTypes {
97+
if (Object.prototype.hasOwnProperty.call(this.entity, "metadata")) {
98+
const meta = this.entity.metadata;
99+
if (IsString(meta)) {
100+
return MetadataTypes.STRING;
101+
}
102+
if (meta === null) {
103+
return MetadataTypes.NULL;
104+
}
105+
if (!ObjectCanSafelyStringify(meta)) {
106+
return MetadataTypes.OBJECT;
107+
}
108+
return MetadataTypes.JSON;
109+
}
110+
111+
return MetadataTypes.NULL;
112+
}
113+
114+
get hasGLTFExtras(): boolean {
115+
return this._editedMetadata && this.isParsable && JSON.parse(this._editedMetadata).gltf;
116+
}
117+
118+
get isChanged(): boolean {
119+
const changed = this._editedMetadata !== this.parsedMetadata;
120+
return changed;
121+
}
122+
123+
/**
124+
* @returns whether the entity's metadata can be parsed as JSON.
125+
*/
126+
get isParsable(): boolean {
127+
return IsParsable(this._editedMetadata);
128+
}
129+
130+
get isReadonly(): boolean {
131+
return this.entityType === MetadataTypes.OBJECT && MetadataUtils._PreventObjectCorruption;
132+
}
133+
134+
get parsedMetadata(): Nullable<string> {
135+
const metadata = this.entity.metadata;
136+
137+
if (IsString(metadata)) {
138+
return metadata;
139+
}
140+
141+
if (metadata) {
142+
if (ObjectCanSafelyStringify(metadata)) {
143+
return JSON.stringify(metadata, undefined, this.prettyJSON ? PrettyJSONIndent : undefined);
144+
} else {
145+
return String(metadata);
146+
}
147+
}
148+
149+
return null;
150+
}
151+
152+
get prettyJSON(): boolean {
153+
return MetadataUtils._PrettyJSON;
154+
}
155+
156+
set prettyJSON(value: boolean) {
157+
if (MetadataUtils._PrettyJSON !== value) {
158+
MetadataUtils._PrettyJSON = value;
159+
this.settingsChangedObservable.notifyObservers(this);
160+
}
161+
}
162+
163+
get preventObjectCorruption(): boolean {
164+
return MetadataUtils._PreventObjectCorruption;
165+
}
166+
167+
set preventObjectCorruption(value: boolean) {
168+
if (MetadataUtils._PreventObjectCorruption !== value) {
169+
MetadataUtils._PreventObjectCorruption = value;
170+
this.settingsChangedObservable.notifyObservers(this);
171+
}
172+
}
173+
174+
/** Safely checks if valid JSON then appends necessary props without overwriting existing */
175+
populateGLTFExtras() {
176+
if (this._editedMetadata && !this.isParsable) {
177+
return;
178+
}
179+
180+
try {
181+
let changed = false;
182+
183+
if (!this._editedMetadata) {
184+
this._editedMetadata = "{}";
185+
}
186+
187+
const parsedJson = JSON.parse(this._editedMetadata);
188+
if (parsedJson) {
189+
if (Object.prototype.hasOwnProperty.call(parsedJson, "gltf")) {
190+
if (!Object.prototype.hasOwnProperty.call(parsedJson.gltf, "extras")) {
191+
parsedJson.gltf.extras = {};
192+
changed = true;
193+
}
194+
} else {
195+
parsedJson.gltf = { extras: {} };
196+
changed = true;
197+
}
198+
}
199+
200+
if (changed) {
201+
this._editedMetadata = JSON.stringify(parsedJson, undefined, this.prettyJSON ? PrettyJSONIndent : undefined);
202+
this.settingsChangedObservable.notifyObservers(this);
203+
}
204+
} catch (error) {}
205+
}
206+
207+
save() {
208+
if (this._editedMetadata) {
209+
if (this.isParsable) {
210+
const parsed = JSON.parse(this._editedMetadata);
211+
if (!IsString(parsed)) {
212+
this._setMetadata(parsed);
213+
return;
214+
}
215+
}
216+
217+
if (this.entityType === MetadataTypes.STRING) {
218+
if (this._editedMetadata !== "") {
219+
this._setMetadata(this._editedMetadata);
220+
return;
221+
}
222+
}
223+
224+
// Object type or unparseable JSON. Leave as string.
225+
this._setMetadata(this._editedMetadata);
226+
return;
227+
}
228+
229+
this._setMetadata(null);
230+
}
231+
232+
private _setMetadata(value: any) {
233+
if (this.entity.metadata !== value) {
234+
this.entity.metadata = value;
235+
236+
this._editedMetadata = this.parsedMetadata;
237+
238+
this.settingsChangedObservable.notifyObservers(this);
239+
}
240+
}
241+
242+
private static _Instance: Nullable<MetadataUtils> = null;
243+
private static _PrettyJSON = false;
244+
private static _PreventObjectCorruption = true;
245+
246+
public static get Instance(): MetadataUtils {
247+
if (!MetadataUtils._Instance) {
248+
throw new Error("MetadataUtils not initialized.");
249+
}
250+
return MetadataUtils._Instance;
251+
}
252+
253+
public static set Entity(entity: IMetadataContainer) {
254+
if (!MetadataUtils._Instance || MetadataUtils._Instance.entity !== entity) {
255+
MetadataUtils._Instance = new MetadataUtils(entity);
256+
}
257+
}
258+
}
259+
260+
/**
261+
* Component to display metadata properties of an entity.
262+
* @param props - The properties for the component.
263+
* @returns A React component that displays metadata properties.
264+
*/
265+
export const MetadataProperties: FunctionComponent<{ entity: IMetadataContainer }> = (props) => {
266+
const { entity } = props;
267+
268+
MetadataUtils.Entity = entity;
269+
const metadataUtils = MetadataUtils.Instance;
270+
271+
const isChanged = useObservableState(() => metadataUtils.isChanged, metadataUtils.settingsChangedObservable);
272+
const isReadonly = useObservableState(() => metadataUtils.isReadonly, metadataUtils.settingsChangedObservable);
273+
const editedMetadata = useObservableState(() => metadataUtils.editedMetadata, metadataUtils.settingsChangedObservable);
274+
275+
return (
276+
<>
277+
<BoundProperty component={TextPropertyLine} label={"Property type"} target={metadataUtils} propertyKey={"entityType"} />
278+
<BoundProperty component={SwitchPropertyLine} label={"Prevent Object corruption"} target={metadataUtils} propertyKey={"preventObjectCorruption"} />
279+
<BoundProperty component={SwitchPropertyLine} label={"Pretty JSON"} target={metadataUtils} propertyKey={"prettyJSON"} />
280+
<Textarea disabled={isReadonly} value={editedMetadata} onChange={(val) => (metadataUtils.editedMetadata = val)} />
281+
<ButtonLine label={"Populate glTF extras"} disabled={metadataUtils.hasGLTFExtras} onClick={() => metadataUtils.populateGLTFExtras()} />
282+
<ButtonLine label={"Save"} disabled={!isChanged} onClick={() => metadataUtils.save()} />
283+
</>
284+
);
285+
};

packages/dev/inspector-v2/src/inspector.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { FrameGraphPropertiesServiceDefinition } from "./services/panes/properti
1919
import { LightPropertiesServiceDefinition } from "./services/panes/properties/lightPropertiesServices";
2020
import { MaterialPropertiesServiceDefinition } from "./services/panes/properties/materialPropertiesService";
2121
import { NodePropertiesServiceDefinition } from "./services/panes/properties/nodePropertiesService";
22+
import { MetadataPropertiesServiceDefinition } from "./services/panes/properties/metadataPropertiesService";
2223
import { ParticleSystemPropertiesServiceDefinition } from "./services/panes/properties/particleSystemPropertiesService";
2324
import { PhysicsPropertiesServiceDefinition } from "./services/panes/properties/physicsPropertiesService";
2425
import { PostProcessPropertiesServiceDefinition } from "./services/panes/properties/postProcessPropertiesService";
@@ -224,6 +225,7 @@ function _ShowInspector(scene: Nullable<Scene>, options: Partial<IInspectorOptio
224225
EffectLayerPropertiesServiceDefinition,
225226
FrameGraphPropertiesServiceDefinition,
226227
AnimationGroupPropertiesServiceDefinition,
228+
MetadataPropertiesServiceDefinition,
227229

228230
// Debug pane tab and related services.
229231
DebugServiceDefinition,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { IMetadataContainer } from "../../../components/properties/metadataProperties";
2+
import type { ServiceDefinition } from "../../../modularity/serviceDefinition";
3+
import type { IPropertiesService } from "./propertiesService";
4+
5+
import { MetadataProperties } from "../../../components/properties/metadataProperties";
6+
import { PropertiesServiceIdentity } from "./propertiesService";
7+
8+
function IsMetadataContainer(entity: unknown): entity is IMetadataContainer {
9+
return (entity as IMetadataContainer).metadata !== undefined;
10+
}
11+
12+
export const MetadataPropertiesServiceDefinition: ServiceDefinition<[], [IPropertiesService]> = {
13+
friendlyName: "Metadata Properties",
14+
consumes: [PropertiesServiceIdentity],
15+
factory: (propertiesService) => {
16+
const contentRegistration = propertiesService.addSectionContent({
17+
key: "Metadata Properties",
18+
// TransformNode and Bone don't share a common base class, but both have the same transform related properties.
19+
predicate: (entity: unknown) => IsMetadataContainer(entity),
20+
content: [
21+
{
22+
section: "Metadata",
23+
component: ({ context }) => <MetadataProperties entity={context} />,
24+
},
25+
],
26+
});
27+
28+
return {
29+
dispose: () => {
30+
contentRegistration.dispose();
31+
},
32+
};
33+
},
34+
};

packages/dev/inspector-v2/test/app/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,42 @@ function createTestBoxes() {
110110
boxInstance.position = new Vector3(0, 0, -0.5);
111111
}
112112

113+
function createTestMetadata() {
114+
const materialMeta = new StandardMaterial("material.meta", scene);
115+
materialMeta.emissiveColor = Color3.Red();
116+
materialMeta.metadata = {
117+
test: "test string",
118+
description: "Material JSON metadata.",
119+
someNumber: 73,
120+
};
121+
122+
const defaultMeta = MeshBuilder.CreateBox("default.metadata", { size: 0.15 }, scene);
123+
124+
const undefinedMeta = defaultMeta.clone("undefined.metadata");
125+
undefinedMeta.material = materialMeta;
126+
undefinedMeta.metadata = undefined;
127+
128+
const jsonMeta = defaultMeta.clone("json.metadata");
129+
jsonMeta.material = materialMeta;
130+
jsonMeta.metadata = {
131+
test: "test string",
132+
description: "JSON metadata.",
133+
someNumber: 42,
134+
};
135+
136+
const nullMeta = defaultMeta.clone("null.metadata");
137+
nullMeta.material = materialMeta;
138+
nullMeta.metadata = null;
139+
140+
const stringMeta = defaultMeta.clone("string.metadata");
141+
stringMeta.material = materialMeta;
142+
stringMeta.metadata = "String metadata.";
143+
144+
const objectMeta = defaultMeta.clone("object.metadata");
145+
objectMeta.material = materialMeta;
146+
objectMeta.metadata = jsonMeta;
147+
}
148+
113149
function createMaterials() {
114150
const multiMaterial = new MultiMaterial("multi", scene);
115151
multiMaterial.subMaterials.push(...scene.materials);
@@ -128,6 +164,8 @@ function createMaterials() {
128164

129165
createMaterials();
130166

167+
createTestMetadata();
168+
131169
engine.runRenderLoop(() => {
132170
scene.render();
133171
});

0 commit comments

Comments
 (0)