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.
@@ -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 };