ohzi-core 13.2.2 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.mjs +1 -1
- package/build/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/camera_controller/CameraController.ts +6 -4
- package/src/dev_bridge/CameraBridge.ts +363 -0
- package/src/dev_bridge/CaptureService.ts +256 -0
- package/src/dev_bridge/CommandDispatcher.ts +114 -0
- package/src/dev_bridge/ConsoleBuffer.ts +286 -0
- package/src/dev_bridge/DevBridge.ts +195 -0
- package/src/dev_bridge/InputSynthesizer.ts +287 -0
- package/src/dev_bridge/PerformanceProbe.ts +118 -0
- package/src/dev_bridge/RenderModeRegistry.ts +66 -0
- package/src/dev_bridge/SceneEditor.ts +288 -0
- package/src/dev_bridge/SceneInspector.ts +388 -0
- package/src/dev_bridge/ViewNavigator.ts +101 -0
- package/src/dev_bridge/protocol.ts +31 -0
- package/src/index.ts +15 -5
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { InspectableObject, InspectableVector } from './SceneInspector';
|
|
2
|
+
|
|
3
|
+
const PRECISION = 10000;
|
|
4
|
+
|
|
5
|
+
export interface NodeSelector
|
|
6
|
+
{
|
|
7
|
+
uuid?: unknown;
|
|
8
|
+
name?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Changes arrive off the dev-bridge wire, so every value is validated.
|
|
12
|
+
export interface NodeChanges
|
|
13
|
+
{
|
|
14
|
+
position?: unknown;
|
|
15
|
+
rotation?: unknown;
|
|
16
|
+
scale?: unknown;
|
|
17
|
+
visible?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NodeDetail
|
|
21
|
+
{
|
|
22
|
+
uuid: string;
|
|
23
|
+
name: string;
|
|
24
|
+
type: string;
|
|
25
|
+
parent: string | null;
|
|
26
|
+
children: number;
|
|
27
|
+
visible: boolean;
|
|
28
|
+
position: number[];
|
|
29
|
+
rotation: number[];
|
|
30
|
+
scale: number[];
|
|
31
|
+
material?: string;
|
|
32
|
+
vertices?: number;
|
|
33
|
+
changed?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Finds and mutates individual scene nodes. Mutation returns the resulting
|
|
37
|
+
// state rather than an acknowledgement, so the caller never has to guess
|
|
38
|
+
// whether a change took.
|
|
39
|
+
class SceneEditor
|
|
40
|
+
{
|
|
41
|
+
get(root: InspectableObject, selector: NodeSelector): NodeDetail
|
|
42
|
+
{
|
|
43
|
+
const found = this.require(root, selector);
|
|
44
|
+
|
|
45
|
+
return this.describe(found.node, found.parent);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
set(root: InspectableObject, selector: NodeSelector, changes: NodeChanges): NodeDetail
|
|
49
|
+
{
|
|
50
|
+
const found = this.require(root, selector);
|
|
51
|
+
const changed: string[] = [];
|
|
52
|
+
|
|
53
|
+
if (this.apply_vector(found.node.position, changes.position))
|
|
54
|
+
{
|
|
55
|
+
changed.push('position');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (this.apply_vector(found.node.rotation, changes.rotation))
|
|
59
|
+
{
|
|
60
|
+
changed.push('rotation');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.apply_vector(found.node.scale, changes.scale))
|
|
64
|
+
{
|
|
65
|
+
changed.push('scale');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof changes.visible === 'boolean')
|
|
69
|
+
{
|
|
70
|
+
found.node.visible = changes.visible;
|
|
71
|
+
changed.push('visible');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const detail = this.describe(found.node, found.parent);
|
|
75
|
+
detail.changed = changed;
|
|
76
|
+
|
|
77
|
+
return detail;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private require(root: InspectableObject, selector: NodeSelector): { node: InspectableObject; parent: InspectableObject | null }
|
|
81
|
+
{
|
|
82
|
+
if (typeof root !== 'object' || root === null)
|
|
83
|
+
{
|
|
84
|
+
throw this.error('not_found', 'No scene is set. SceneManager.current is empty.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const uuid = typeof selector.uuid === 'string' ? selector.uuid : null;
|
|
88
|
+
const name = typeof selector.name === 'string' ? selector.name : null;
|
|
89
|
+
|
|
90
|
+
if (uuid === null && name === null)
|
|
91
|
+
{
|
|
92
|
+
throw this.error('not_found', 'Pass a uuid or a name to identify the object.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const found = this.find(root, null, uuid, name);
|
|
96
|
+
|
|
97
|
+
if (found === null)
|
|
98
|
+
{
|
|
99
|
+
const label = uuid === null ? `name '${name === null ? '' : name}'` : `uuid '${uuid}'`;
|
|
100
|
+
|
|
101
|
+
throw this.error('not_found', `No object matching ${label} in the current scene.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// uuid wins when both are supplied: it is unique, names are not.
|
|
108
|
+
private find(
|
|
109
|
+
node: InspectableObject,
|
|
110
|
+
parent: InspectableObject | null,
|
|
111
|
+
uuid: string | null,
|
|
112
|
+
name: string | null
|
|
113
|
+
): { node: InspectableObject; parent: InspectableObject | null } | null
|
|
114
|
+
{
|
|
115
|
+
const matches = uuid !== null
|
|
116
|
+
? node.uuid === uuid
|
|
117
|
+
: node.name === name;
|
|
118
|
+
|
|
119
|
+
if (matches)
|
|
120
|
+
{
|
|
121
|
+
return { node, parent };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
125
|
+
|
|
126
|
+
for (const child of children)
|
|
127
|
+
{
|
|
128
|
+
const found = this.find(child, node, uuid, name);
|
|
129
|
+
|
|
130
|
+
if (found !== null)
|
|
131
|
+
{
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private apply_vector(target: InspectableVector | undefined, values: unknown): boolean
|
|
140
|
+
{
|
|
141
|
+
if (typeof target !== 'object' || target === null || !Array.isArray(values))
|
|
142
|
+
{
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const axes: Array<keyof InspectableVector> = ['x', 'y', 'z'];
|
|
147
|
+
// Array.isArray narrows to any[]; restate it as unknown[] so each element
|
|
148
|
+
// has to be checked rather than trusted.
|
|
149
|
+
const supplied = values as unknown[];
|
|
150
|
+
let applied = false;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < axes.length; i++)
|
|
153
|
+
{
|
|
154
|
+
const value = supplied[i];
|
|
155
|
+
|
|
156
|
+
// Anything non-numeric is skipped rather than coerced, so a bad payload
|
|
157
|
+
// cannot silently move an object to NaN.
|
|
158
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
159
|
+
{
|
|
160
|
+
target[axes[i]] = value;
|
|
161
|
+
applied = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return applied;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private describe(node: InspectableObject, parent: InspectableObject | null): NodeDetail
|
|
169
|
+
{
|
|
170
|
+
const detail: NodeDetail = {
|
|
171
|
+
uuid: typeof node.uuid === 'string' ? node.uuid : '(no uuid)',
|
|
172
|
+
name: typeof node.name === 'string' ? node.name : '',
|
|
173
|
+
type: typeof node.type === 'string' ? node.type : 'Object3D',
|
|
174
|
+
parent: this.parent_label(parent),
|
|
175
|
+
children: Array.isArray(node.children) ? node.children.length : 0,
|
|
176
|
+
visible: node.visible !== false,
|
|
177
|
+
position: this.vector(node.position, 0),
|
|
178
|
+
rotation: this.vector(node.rotation, 0),
|
|
179
|
+
scale: this.vector(node.scale, 1)
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const material = this.material_type(node.material);
|
|
183
|
+
|
|
184
|
+
if (material !== null)
|
|
185
|
+
{
|
|
186
|
+
detail.material = material;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const vertices = this.vertices(node.geometry);
|
|
190
|
+
|
|
191
|
+
if (vertices !== null)
|
|
192
|
+
{
|
|
193
|
+
detail.vertices = vertices;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return detail;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Prefers a readable name, falls back to uuid, so the caller can always
|
|
200
|
+
// identify the parent it was told about.
|
|
201
|
+
private parent_label(parent: InspectableObject | null): string | null
|
|
202
|
+
{
|
|
203
|
+
if (parent === null)
|
|
204
|
+
{
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (typeof parent.name === 'string' && parent.name.length > 0)
|
|
209
|
+
{
|
|
210
|
+
return parent.name;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return typeof parent.uuid === 'string' ? parent.uuid : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private material_type(material: unknown): string | null
|
|
217
|
+
{
|
|
218
|
+
if (Array.isArray(material))
|
|
219
|
+
{
|
|
220
|
+
return `${material.length} materials`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (typeof material === 'object' && material !== null)
|
|
224
|
+
{
|
|
225
|
+
const type = (material as { type?: unknown }).type;
|
|
226
|
+
|
|
227
|
+
return typeof type === 'string' ? type : 'Material';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private vertices(geometry: unknown): number | null
|
|
234
|
+
{
|
|
235
|
+
if (typeof geometry !== 'object' || geometry === null)
|
|
236
|
+
{
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const attributes = (geometry as { attributes?: unknown }).attributes;
|
|
241
|
+
|
|
242
|
+
if (typeof attributes !== 'object' || attributes === null)
|
|
243
|
+
{
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const position = (attributes as { position?: unknown }).position;
|
|
248
|
+
|
|
249
|
+
if (typeof position !== 'object' || position === null)
|
|
250
|
+
{
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const count = (position as { count?: unknown }).count;
|
|
255
|
+
|
|
256
|
+
return typeof count === 'number' ? count : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private vector(source: InspectableVector | undefined, fallback: number): number[]
|
|
260
|
+
{
|
|
261
|
+
if (typeof source !== 'object' || source === null)
|
|
262
|
+
{
|
|
263
|
+
return [fallback, fallback, fallback];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return [this.round(source.x, fallback), this.round(source.y, fallback), this.round(source.z, fallback)];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private round(value: unknown, fallback: number): number
|
|
270
|
+
{
|
|
271
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
272
|
+
{
|
|
273
|
+
return fallback;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return Math.round(value * PRECISION) / PRECISION;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private error(code: string, message: string): Error
|
|
280
|
+
{
|
|
281
|
+
const error: Error & { code?: string } = new Error(message);
|
|
282
|
+
error.code = code;
|
|
283
|
+
|
|
284
|
+
return error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { SceneEditor };
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
const DEFAULT_DEPTH = 4;
|
|
2
|
+
const DEFAULT_MAX_NODES = 200;
|
|
3
|
+
const PRECISION = 10000;
|
|
4
|
+
|
|
5
|
+
export interface InspectableVector
|
|
6
|
+
{
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
z: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface InspectableObject
|
|
13
|
+
{
|
|
14
|
+
uuid?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
visible?: boolean;
|
|
18
|
+
position?: InspectableVector;
|
|
19
|
+
rotation?: InspectableVector;
|
|
20
|
+
scale?: InspectableVector;
|
|
21
|
+
children?: InspectableObject[];
|
|
22
|
+
material?: unknown;
|
|
23
|
+
geometry?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SceneNode
|
|
27
|
+
{
|
|
28
|
+
uuid: string;
|
|
29
|
+
name: string;
|
|
30
|
+
type: string;
|
|
31
|
+
position: number[];
|
|
32
|
+
rotation?: number[];
|
|
33
|
+
scale?: number[];
|
|
34
|
+
visible?: boolean;
|
|
35
|
+
material?: string;
|
|
36
|
+
vertices?: number;
|
|
37
|
+
children?: SceneNode[];
|
|
38
|
+
truncated?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Options arrive off the dev-bridge wire and are therefore untrusted.
|
|
42
|
+
export interface InspectOptions
|
|
43
|
+
{
|
|
44
|
+
depth?: unknown;
|
|
45
|
+
max_nodes?: unknown;
|
|
46
|
+
filter?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SceneInspectionResult
|
|
50
|
+
{
|
|
51
|
+
root: SceneNode | null;
|
|
52
|
+
counts: { total: number; meshes: number; returned: number };
|
|
53
|
+
truncated: boolean;
|
|
54
|
+
notes: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Budget
|
|
58
|
+
{
|
|
59
|
+
used: number;
|
|
60
|
+
depth_cut: boolean;
|
|
61
|
+
node_cut: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Serialises a Three.js scene graph under a hard budget. A production scene has
|
|
65
|
+
// thousands of nodes; an uncapped traversal would consume an entire context
|
|
66
|
+
// window, so depth and node count are always bounded and what was dropped is
|
|
67
|
+
// always reported.
|
|
68
|
+
class SceneInspector
|
|
69
|
+
{
|
|
70
|
+
inspect(root: InspectableObject, options: InspectOptions): SceneInspectionResult
|
|
71
|
+
{
|
|
72
|
+
if (typeof root !== 'object' || root === null)
|
|
73
|
+
{
|
|
74
|
+
return {
|
|
75
|
+
root: null,
|
|
76
|
+
counts: { total: 0, meshes: 0, returned: 0 },
|
|
77
|
+
truncated: false,
|
|
78
|
+
notes: ['No scene is set. SceneManager.current is empty.']
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const depth = this.positive(options.depth, DEFAULT_DEPTH);
|
|
83
|
+
const max_nodes = this.positive(options.max_nodes, DEFAULT_MAX_NODES);
|
|
84
|
+
const filter = typeof options.filter === 'string' && options.filter.length > 0
|
|
85
|
+
? options.filter.toLowerCase()
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
const notes: string[] = [];
|
|
89
|
+
const counts = this.count(root);
|
|
90
|
+
|
|
91
|
+
let keep: Set<InspectableObject> | null = null;
|
|
92
|
+
|
|
93
|
+
if (filter !== null)
|
|
94
|
+
{
|
|
95
|
+
keep = new Set<InspectableObject>();
|
|
96
|
+
this.mark(root, filter, keep);
|
|
97
|
+
|
|
98
|
+
if (keep.size === 0)
|
|
99
|
+
{
|
|
100
|
+
notes.push(`no nodes matched the filter '${filter}'`);
|
|
101
|
+
keep = new Set<InspectableObject>([root]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const budget: Budget = { used: 0, depth_cut: false, node_cut: false };
|
|
106
|
+
const serialised = this.serialise(root, 0, depth, max_nodes, keep, budget);
|
|
107
|
+
|
|
108
|
+
if (budget.node_cut)
|
|
109
|
+
{
|
|
110
|
+
notes.push(`stopped at max_nodes=${max_nodes}; ${counts.total - budget.used} of ${counts.total} nodes not returned`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (budget.depth_cut)
|
|
114
|
+
{
|
|
115
|
+
notes.push(`stopped at depth=${depth}; deeper nodes not returned`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
root: serialised,
|
|
120
|
+
counts: { total: counts.total, meshes: counts.meshes, returned: budget.used },
|
|
121
|
+
truncated: budget.node_cut || budget.depth_cut,
|
|
122
|
+
notes
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private serialise(
|
|
127
|
+
source: InspectableObject,
|
|
128
|
+
level: number,
|
|
129
|
+
depth: number,
|
|
130
|
+
max_nodes: number,
|
|
131
|
+
keep: Set<InspectableObject> | null,
|
|
132
|
+
budget: Budget
|
|
133
|
+
): SceneNode | null
|
|
134
|
+
{
|
|
135
|
+
if (keep !== null && !keep.has(source))
|
|
136
|
+
{
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (budget.used >= max_nodes)
|
|
141
|
+
{
|
|
142
|
+
budget.node_cut = true;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
budget.used++;
|
|
147
|
+
|
|
148
|
+
const node: SceneNode = {
|
|
149
|
+
uuid: typeof source.uuid === 'string' ? source.uuid : '(no uuid)',
|
|
150
|
+
name: typeof source.name === 'string' ? source.name : '',
|
|
151
|
+
type: typeof source.type === 'string' ? source.type : 'Object3D',
|
|
152
|
+
position: this.vector(source.position, 0)
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Defaults are omitted so the payload carries only what differs.
|
|
156
|
+
const rotation = this.vector(source.rotation, 0);
|
|
157
|
+
const scale = this.vector(source.scale, 1);
|
|
158
|
+
|
|
159
|
+
if (!this.is_uniform(rotation, 0))
|
|
160
|
+
{
|
|
161
|
+
node.rotation = rotation;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!this.is_uniform(scale, 1))
|
|
165
|
+
{
|
|
166
|
+
node.scale = scale;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (source.visible === false)
|
|
170
|
+
{
|
|
171
|
+
node.visible = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const material = this.describe_material(source.material);
|
|
175
|
+
|
|
176
|
+
if (material !== null)
|
|
177
|
+
{
|
|
178
|
+
node.material = material;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const vertices = this.count_vertices(source.geometry);
|
|
182
|
+
|
|
183
|
+
if (vertices !== null)
|
|
184
|
+
{
|
|
185
|
+
node.vertices = vertices;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const children = Array.isArray(source.children) ? source.children : [];
|
|
189
|
+
|
|
190
|
+
if (children.length === 0)
|
|
191
|
+
{
|
|
192
|
+
return node;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (level + 1 > depth)
|
|
196
|
+
{
|
|
197
|
+
budget.depth_cut = true;
|
|
198
|
+
node.truncated = `${children.length} children hidden (depth limit ${depth})`;
|
|
199
|
+
|
|
200
|
+
return node;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const serialised: SceneNode[] = [];
|
|
204
|
+
let hidden = 0;
|
|
205
|
+
|
|
206
|
+
for (const child of children)
|
|
207
|
+
{
|
|
208
|
+
const child_node = this.serialise(child, level + 1, depth, max_nodes, keep, budget);
|
|
209
|
+
|
|
210
|
+
if (child_node === null)
|
|
211
|
+
{
|
|
212
|
+
hidden++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
serialised.push(child_node);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (serialised.length > 0)
|
|
220
|
+
{
|
|
221
|
+
node.children = serialised;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Only meaningful when nothing was filtered out on purpose.
|
|
225
|
+
if (hidden > 0 && keep === null)
|
|
226
|
+
{
|
|
227
|
+
node.truncated = `${hidden} children hidden (max_nodes ${max_nodes})`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return node;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private count(root: InspectableObject): { total: number; meshes: number }
|
|
234
|
+
{
|
|
235
|
+
let total = 0;
|
|
236
|
+
let meshes = 0;
|
|
237
|
+
|
|
238
|
+
const stack: InspectableObject[] = [root];
|
|
239
|
+
|
|
240
|
+
while (stack.length > 0)
|
|
241
|
+
{
|
|
242
|
+
const current = stack.pop();
|
|
243
|
+
|
|
244
|
+
if (typeof current !== 'object' || current === null)
|
|
245
|
+
{
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
total++;
|
|
250
|
+
|
|
251
|
+
if (current.type === 'Mesh')
|
|
252
|
+
{
|
|
253
|
+
meshes++;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const children = Array.isArray(current.children) ? current.children : [];
|
|
257
|
+
|
|
258
|
+
for (const child of children)
|
|
259
|
+
{
|
|
260
|
+
stack.push(child);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { total, meshes };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A node is kept when it matches, or when any descendant matches, so the
|
|
268
|
+
// path to a match stays visible.
|
|
269
|
+
private mark(source: InspectableObject, filter: string, keep: Set<InspectableObject>): boolean
|
|
270
|
+
{
|
|
271
|
+
const name = typeof source.name === 'string' ? source.name.toLowerCase() : '';
|
|
272
|
+
const type = typeof source.type === 'string' ? source.type.toLowerCase() : '';
|
|
273
|
+
|
|
274
|
+
let matched = name.includes(filter) || type.includes(filter);
|
|
275
|
+
|
|
276
|
+
const children = Array.isArray(source.children) ? source.children : [];
|
|
277
|
+
|
|
278
|
+
for (const child of children)
|
|
279
|
+
{
|
|
280
|
+
if (this.mark(child, filter, keep))
|
|
281
|
+
{
|
|
282
|
+
matched = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (matched)
|
|
287
|
+
{
|
|
288
|
+
keep.add(source);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return matched;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private describe_material(material: unknown): string | null
|
|
295
|
+
{
|
|
296
|
+
if (Array.isArray(material))
|
|
297
|
+
{
|
|
298
|
+
const types = material.map((entry) => this.material_type(entry));
|
|
299
|
+
|
|
300
|
+
return `${material.length} materials: ${types.join(', ')}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (typeof material === 'object' && material !== null)
|
|
304
|
+
{
|
|
305
|
+
return this.material_type(material);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private material_type(material: unknown): string
|
|
312
|
+
{
|
|
313
|
+
if (typeof material === 'object' && material !== null)
|
|
314
|
+
{
|
|
315
|
+
const type = (material as { type?: unknown }).type;
|
|
316
|
+
|
|
317
|
+
if (typeof type === 'string')
|
|
318
|
+
{
|
|
319
|
+
return type;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return 'Material';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private count_vertices(geometry: unknown): number | null
|
|
327
|
+
{
|
|
328
|
+
if (typeof geometry !== 'object' || geometry === null)
|
|
329
|
+
{
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const attributes = (geometry as { attributes?: unknown }).attributes;
|
|
334
|
+
|
|
335
|
+
if (typeof attributes !== 'object' || attributes === null)
|
|
336
|
+
{
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const position = (attributes as { position?: unknown }).position;
|
|
341
|
+
|
|
342
|
+
if (typeof position !== 'object' || position === null)
|
|
343
|
+
{
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const count = (position as { count?: unknown }).count;
|
|
348
|
+
|
|
349
|
+
return typeof count === 'number' ? count : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private vector(source: InspectableVector | undefined, fallback: number): number[]
|
|
353
|
+
{
|
|
354
|
+
if (typeof source !== 'object' || source === null)
|
|
355
|
+
{
|
|
356
|
+
return [fallback, fallback, fallback];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return [this.round(source.x, fallback), this.round(source.y, fallback), this.round(source.z, fallback)];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private round(value: unknown, fallback: number): number
|
|
363
|
+
{
|
|
364
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
365
|
+
{
|
|
366
|
+
return fallback;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return Math.round(value * PRECISION) / PRECISION;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private is_uniform(values: number[], expected: number): boolean
|
|
373
|
+
{
|
|
374
|
+
return values.every((value) => value === expected);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private positive(value: unknown, fallback: number): number
|
|
378
|
+
{
|
|
379
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1)
|
|
380
|
+
{
|
|
381
|
+
return fallback;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return Math.floor(value);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export { SceneInspector };
|