@scenoco-three/editor 0.2.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/app/panels.ts ADDED
@@ -0,0 +1,553 @@
1
+ import type { Color, Euler, Object3D, Vector2, Vector3 } from 'three';
2
+ import {
3
+ Registry,
4
+ elementType,
5
+ getAttachmentDef,
6
+ isArrayType,
7
+ isEnumType,
8
+ isScalarType,
9
+ type Component,
10
+ type EnumDef,
11
+ type Engine,
12
+ type NodeAttr,
13
+ type PropertyMeta,
14
+ } from '@scenoco-three/core';
15
+ import type { AttachmentView, SceneDocument } from './document.js';
16
+
17
+ export interface Panels {
18
+ /** Re-read the scene into the tree + inspector. */
19
+ refresh(): void;
20
+ /** Select a node from outside (e.g. a viewport pick) — syncs tree + inspector. */
21
+ select(node: Object3D | null): void;
22
+ }
23
+
24
+ /**
25
+ * The editor's two side panels: a left **Hierarchy** (collapsible scene tree) and
26
+ * a right **Inspector** that *edits* the selected node's transform and component
27
+ * `@property` values. Every edit goes through the active `SceneDocument`, so it is
28
+ * undoable and savable. Geometry/material (pooled, shared) and reference/array
29
+ * props are shown read-only for now. `onSelect` lets the host highlight the node.
30
+ */
31
+ export function buildPanels(
32
+ getEngine: () => Engine,
33
+ getDoc: () => SceneDocument | undefined,
34
+ onSelect: (node: Object3D | null) => void,
35
+ ): Panels {
36
+ const tree = div('sc-tree');
37
+ const inspector = div('sc-inspector');
38
+ document.body.append(drawer('left', 'Hierarchy', tree), drawer('right', 'Inspector', inspector));
39
+
40
+ const engine = () => getEngine();
41
+ let selected: Object3D | null = null;
42
+ const collapsedNodes = new Set<Object3D>(); // hierarchy fold state
43
+ const closedSections = new Set<string>(); // inspector section fold state
44
+
45
+ function select(node: Object3D | null): void {
46
+ selected = node;
47
+ onSelect(node);
48
+ renderTree();
49
+ renderInspector();
50
+ }
51
+
52
+ // ── hierarchy ──────────────────────────────────────────────────────────────────
53
+
54
+ function renderTree(): void {
55
+ tree.replaceChildren();
56
+ // <Scene> is the root row, so its own attachments (Background/Fog) are selectable.
57
+ tree.append(treeRow(engine().scene, 0));
58
+ }
59
+
60
+ function treeRow(node: Object3D, depth: number): HTMLElement {
61
+ const wrap = div('');
62
+ const row = div('sc-node' + (node === selected ? ' sel' : ''));
63
+ row.style.paddingLeft = `${6 + depth * 14}px`;
64
+ const children = node.children.filter((c) => !isEditorOnly(c)); // hide gizmo/helper/preview objects
65
+ const open = !collapsedNodes.has(node);
66
+ const caret = div('sc-caret', children.length > 0 ? (open ? '▾' : '▸') : '');
67
+ if (children.length > 0) {
68
+ caret.onclick = (e) => {
69
+ e.stopPropagation();
70
+ if (open) collapsedNodes.add(node);
71
+ else collapsedNodes.delete(node);
72
+ renderTree();
73
+ };
74
+ }
75
+ row.append(caret, nodeLabel(node));
76
+ row.onclick = () => select(node);
77
+ wrap.append(row);
78
+ if (open) for (const child of children) wrap.append(treeRow(child, depth + 1));
79
+ return wrap;
80
+ }
81
+
82
+ // ── inspector ──────────────────────────────────────────────────────────────────
83
+
84
+ function renderInspector(): void {
85
+ inspector.replaceChildren();
86
+ if (!selected) {
87
+ inspector.append(div('sc-empty', 'Select a node — tap it in the viewport or the hierarchy.'));
88
+ return;
89
+ }
90
+ const node = selected;
91
+ const doc = getDoc();
92
+
93
+ const head = div('');
94
+ head.append(div('sc-sec sc-sec-plain', typeName(node)));
95
+ if (node.name) head.append(row('id', node.name));
96
+ inspector.append(head);
97
+
98
+ // A prefab instance: its `src` is editable (swap the prefab); content below is read-only.
99
+ const prefabSrc = doc?.prefabSrc(node);
100
+ if (prefabSrc !== undefined) {
101
+ inspector.append(
102
+ sectionBlock('Prefab', [editRow('src', textField(() => prefabSrc, (v) => doc?.setNodeSrc(node, v)))]),
103
+ );
104
+ }
105
+
106
+ // Transform — editable on authored nodes + prefab roots; read-only inside a prefab.
107
+ if (node !== engine().scene) {
108
+ const editT = doc?.canEditTransform(node) ?? false;
109
+ inspector.append(
110
+ sectionBlock(
111
+ 'Transform',
112
+ editT
113
+ ? [
114
+ vecEditor('position', () => node.position.toArray(), (v, c) => doc?.setTransform(node, 'position', v, c)),
115
+ eulerEditor('rotation', () => eulerArr(node.rotation), (v, c) => doc?.setTransform(node, 'rotation', v, c)),
116
+ vecEditor('scale', () => node.scale.toArray(), (v, c) => doc?.setTransform(node, 'scale', v, c)),
117
+ boolEditor('visible', () => node.visible, (v) => doc?.setVisible(node, v)),
118
+ ]
119
+ : [
120
+ row('position', vec(node.position)),
121
+ row('rotation', euler(node.rotation)),
122
+ row('scale', vec(node.scale)),
123
+ row('visible', String(node.visible)),
124
+ ],
125
+ ),
126
+ );
127
+ }
128
+
129
+ // Attachments (geometry / material / scene settings) from the compiled model.
130
+ for (const view of doc?.attachmentsOf(node) ?? []) inspector.append(attachmentSection(node, view, doc));
131
+
132
+ // Components — editable only on authored scene nodes.
133
+ const editC = doc?.canEditComponents(node) ?? false;
134
+ for (const component of engine().getComponents(node)) {
135
+ inspector.append(sectionBlock(componentName(component) ?? component.constructor.name, componentRows(component, doc, editC)));
136
+ }
137
+ }
138
+
139
+ /** A section for one attachment slot: editable inline attrs, or a `src` field for file-loaded ones. */
140
+ function attachmentSection(node: Object3D, view: AttachmentView, doc: SceneDocument | undefined): HTMLElement {
141
+ const def = getAttachmentDef(view.tag);
142
+ const rows: HTMLElement[] = [];
143
+ if (view.src !== null) {
144
+ // Loaded from a file: only the src is editable; its props are read-only.
145
+ rows.push(editRow('src', textField(() => view.src ?? '', (v) => doc?.setAttachmentSrc(node, view.slot, v))));
146
+ for (const attr of Object.keys(def?.attrs ?? {})) rows.push(row(attr, fmt(view.attrs[attr])));
147
+ } else {
148
+ for (const [attr, meta] of Object.entries(def?.attrs ?? {})) {
149
+ rows.push(attachmentAttrRow(node, view, attr, meta.type, view.editable, doc));
150
+ }
151
+ }
152
+ return sectionBlock(view.tag, rows);
153
+ }
154
+
155
+ /** One attachment attribute, dispatched by type (reads/writes the compiled value). */
156
+ function attachmentAttrRow(
157
+ node: Object3D,
158
+ view: AttachmentView,
159
+ attr: string,
160
+ type: NodeAttr['type'],
161
+ editable: boolean,
162
+ doc: SceneDocument | undefined,
163
+ ): HTMLElement {
164
+ const get = () => doc?.attachmentAttr(node, view.slot, attr) ?? view.attrs[attr];
165
+ const commit = (v: number | boolean | string | number[], c?: { coalesce?: boolean }) =>
166
+ doc?.setAttachmentProp(node, view.slot, attr, v, c);
167
+ const elem = elementType(type);
168
+ if (!editable) return row(attr, fmt(get()));
169
+ if (isEnumType(elem) && !isArrayType(type)) return enumEditor(attr, elem, get, (v) => commit(v));
170
+ if (isArrayType(type) || !isScalarType(elem)) return row(attr, fmt(get())); // arrays read-only this pass
171
+ switch (elem) {
172
+ case 'float':
173
+ case 'int':
174
+ return editRow(attr, numField(() => get() as number, (v, c) => commit(v, c), elem === 'int'));
175
+ case 'bool':
176
+ return boolEditor(attr, () => get() as boolean, (v) => commit(v));
177
+ case 'string':
178
+ return editRow(attr, textField(() => String(get() ?? ''), (v) => commit(v)));
179
+ case 'color':
180
+ return editRow(attr, colorField(() => get() as number, (v) => commit(v)));
181
+ case 'vec2':
182
+ return vecEditor(attr, () => get() as number[], (v, c) => commit(v, c), ['x', 'y']);
183
+ case 'vec3':
184
+ return vecEditor(attr, () => get() as number[], (v, c) => commit(v, c));
185
+ case 'euler':
186
+ return eulerEditor(attr, () => get() as number[], (v, c) => commit(v, c));
187
+ default:
188
+ return row(attr, fmt(get()));
189
+ }
190
+ }
191
+
192
+ function componentRows(component: Component, doc: SceneDocument | undefined, editable: boolean): HTMLElement[] {
193
+ const out: HTMLElement[] = [];
194
+ // The component's scene id (addressable as `#id`), read-only — like a node's id.
195
+ if (component.id) out.push(row('id', component.id));
196
+ const meta = registeredMeta(component);
197
+ if (meta) {
198
+ const live = component as unknown as Record<string, unknown>;
199
+ for (const [name, prop] of meta.properties) {
200
+ out.push(editable ? propEditor(component, name, prop, doc) : row(name, fmt(live[name])));
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /** An editable row for a component `@property`, dispatched by its type. */
207
+ function propEditor(component: Component, name: string, prop: PropertyMeta, doc: SceneDocument | undefined): HTMLElement {
208
+ const live = component as unknown as Record<string, unknown>;
209
+ const commit = (v: number | boolean | string | number[], c?: { coalesce?: boolean }) =>
210
+ doc?.setComponentProp(component, name, v, c);
211
+ const elem = elementType(prop.type);
212
+ // Enums render as a dropdown of member names (the live field holds the native value).
213
+ if (isEnumType(elem) && !isArrayType(prop.type)) {
214
+ return enumEditor(name, elem, () => live[name], (v) => commit(v));
215
+ }
216
+ // References and arrays are read-only this pass.
217
+ if (isArrayType(prop.type) || !isScalarType(elem)) return row(name, fmt(live[name]));
218
+ switch (elem) {
219
+ case 'float':
220
+ case 'int':
221
+ return editRow(name, numField(() => live[name] as number, (v, c) => commit(v, c), elem === 'int'));
222
+ case 'bool':
223
+ return boolEditor(name, () => live[name] as boolean, (v) => commit(v));
224
+ case 'string':
225
+ return editRow(name, textField(() => String(live[name] ?? ''), (v) => commit(v)));
226
+ case 'color':
227
+ return editRow(name, colorField(() => (live[name] as Color).getHex(), (v) => commit(v)));
228
+ case 'vec2':
229
+ return vecEditor(name, () => (live[name] as Vector2).toArray(), (v, c) => commit(v, c), ['x', 'y']);
230
+ case 'vec3':
231
+ return vecEditor(name, () => (live[name] as Vector3).toArray(), (v, c) => commit(v, c));
232
+ case 'euler':
233
+ return eulerEditor(name, () => eulerArr(live[name] as Euler), (v, c) => commit(v, c));
234
+ default:
235
+ return row(name, fmt(live[name]));
236
+ }
237
+ }
238
+
239
+ // ── collapsible section ──────────────────────────────────────────────────────────
240
+
241
+ function sectionBlock(title: string, rows: HTMLElement[]): HTMLElement {
242
+ const wrap = div('sc-sec-wrap');
243
+ const closed = closedSections.has(title);
244
+ const head = div('sc-sec' + (closed ? ' closed' : ''));
245
+ head.append(div('sc-caret', closed ? '▸' : '▾'), document.createTextNode(title));
246
+ const body = div('sc-sec-body');
247
+ if (closed) body.style.display = 'none';
248
+ body.append(...rows);
249
+ head.onclick = () => {
250
+ if (closedSections.has(title)) closedSections.delete(title);
251
+ else closedSections.add(title);
252
+ const nowClosed = closedSections.has(title);
253
+ body.style.display = nowClosed ? 'none' : '';
254
+ head.classList.toggle('closed', nowClosed);
255
+ (head.firstChild as HTMLElement).textContent = nowClosed ? '▸' : '▾';
256
+ };
257
+ wrap.append(head, body);
258
+ return wrap;
259
+ }
260
+
261
+ renderTree();
262
+ renderInspector();
263
+ return {
264
+ refresh: () => {
265
+ renderTree();
266
+ renderInspector();
267
+ },
268
+ select,
269
+ };
270
+ }
271
+
272
+ // ── editable field builders ──────────────────────────────────────────────────────
273
+
274
+ /** A labelled multi-axis vector editor (x/y/z), each axis scrub + number input. */
275
+ function vecEditor(
276
+ key: string,
277
+ get: () => number[],
278
+ commit: (value: number[], opts?: { coalesce?: boolean }) => void,
279
+ labels: string[] = ['x', 'y', 'z'],
280
+ ): HTMLElement {
281
+ const r = div('sc-row sc-vec');
282
+ r.append(div('k', key));
283
+ const fields = div('v sc-axes');
284
+ labels.forEach((axis, i) => {
285
+ fields.append(
286
+ axisField(
287
+ axis,
288
+ () => get()[i] ?? 0,
289
+ (v, opts) => {
290
+ const next = get();
291
+ next[i] = v;
292
+ commit(next, opts);
293
+ },
294
+ ),
295
+ );
296
+ });
297
+ r.append(fields);
298
+ return r;
299
+ }
300
+
301
+ /** Euler editor: UI in degrees, commits radians (the compiled/runtime unit). */
302
+ function eulerEditor(
303
+ key: string,
304
+ getRad: () => number[],
305
+ commitRad: (value: number[], opts?: { coalesce?: boolean }) => void,
306
+ ): HTMLElement {
307
+ return vecEditor(
308
+ key,
309
+ () => getRad().map(toDeg),
310
+ (degs, opts) => commitRad(degs.map(toRad), opts),
311
+ );
312
+ }
313
+
314
+ /** One axis: a scrub handle (the axis letter) + a number input. */
315
+ function axisField(label: string, get: () => number, set: (v: number, opts?: { coalesce?: boolean }) => void): HTMLElement {
316
+ const cell = div('sc-axis');
317
+ const handle = div('sc-axis-k', label);
318
+ const input = numInput(() => get(), (v, opts) => set(v, opts));
319
+ scrub(handle, () => Number(input.value) || 0, (v) => {
320
+ input.value = fmtNum(v);
321
+ set(v, { coalesce: true });
322
+ });
323
+ cell.append(handle, input);
324
+ return cell;
325
+ }
326
+
327
+ /** A standalone number row (single scalar property). */
328
+ function numField(get: () => number, set: (v: number, opts?: { coalesce?: boolean }) => void, int = false): HTMLElement {
329
+ return numInput(get, (v, opts) => set(int ? Math.round(v) : v, opts), int);
330
+ }
331
+
332
+ function numInput(get: () => number, set: (v: number, opts?: { coalesce?: boolean }) => void, int = false): HTMLInputElement {
333
+ const input = document.createElement('input');
334
+ input.type = 'number';
335
+ input.className = 'sc-input sc-num';
336
+ input.inputMode = int ? 'numeric' : 'decimal';
337
+ input.value = fmtNum(get());
338
+ input.onchange = () => {
339
+ const v = parseFloat(input.value);
340
+ if (Number.isFinite(v)) set(int ? Math.round(v) : v);
341
+ };
342
+ return input;
343
+ }
344
+
345
+ function textField(get: () => string, set: (v: string) => void): HTMLInputElement {
346
+ const input = document.createElement('input');
347
+ input.type = 'text';
348
+ input.className = 'sc-input';
349
+ input.value = get();
350
+ input.onchange = () => set(input.value);
351
+ return input;
352
+ }
353
+
354
+ function colorField(getHex: () => number, set: (hex: number) => void): HTMLElement {
355
+ const wrap = div('sc-color');
356
+ const picker = document.createElement('input');
357
+ picker.type = 'color';
358
+ picker.className = 'sc-swatch';
359
+ const hex = document.createElement('input');
360
+ hex.type = 'text';
361
+ hex.className = 'sc-input sc-hex';
362
+ const sync = () => {
363
+ const css = '#' + getHex().toString(16).padStart(6, '0');
364
+ picker.value = css;
365
+ hex.value = css;
366
+ };
367
+ sync();
368
+ picker.oninput = () => {
369
+ hex.value = picker.value;
370
+ set(parseInt(picker.value.slice(1), 16));
371
+ };
372
+ hex.onchange = () => {
373
+ const v = parseInt(hex.value.replace(/^#/, ''), 16);
374
+ if (Number.isFinite(v)) {
375
+ set(v);
376
+ picker.value = '#' + v.toString(16).padStart(6, '0');
377
+ }
378
+ };
379
+ wrap.append(picker, hex);
380
+ return wrap;
381
+ }
382
+
383
+ /** A dropdown of enum member names; maps the live native value ↔ its name. */
384
+ function enumEditor(key: string, def: EnumDef, get: () => unknown, set: (v: string | number) => void): HTMLElement {
385
+ const select = document.createElement('select');
386
+ select.className = 'sc-input sc-select';
387
+ for (const member of def.names) {
388
+ const opt = document.createElement('option');
389
+ opt.value = member;
390
+ opt.textContent = member;
391
+ select.append(opt);
392
+ }
393
+ const current = def.byValue.get(get() as string | number);
394
+ if (current !== undefined) select.value = current;
395
+ select.onchange = () => {
396
+ const value = def.byName.get(select.value);
397
+ if (value !== undefined) set(value);
398
+ };
399
+ return editRow(key, select);
400
+ }
401
+
402
+ function boolEditor(key: string, get: () => boolean, set: (v: boolean) => void): HTMLElement {
403
+ const r = div('sc-row');
404
+ r.append(div('k', key));
405
+ const toggle = document.createElement('input');
406
+ toggle.type = 'checkbox';
407
+ toggle.className = 'sc-toggle';
408
+ toggle.checked = get();
409
+ toggle.onchange = () => set(toggle.checked);
410
+ const v = div('v');
411
+ v.append(toggle);
412
+ r.append(v);
413
+ return r;
414
+ }
415
+
416
+ /** Drag horizontally on `handle` to scrub a numeric value (pointer + touch). */
417
+ function scrub(handle: HTMLElement, get: () => number, set: (v: number) => void, step = 0.05): void {
418
+ handle.classList.add('sc-scrub');
419
+ let startX = 0;
420
+ let startV = 0;
421
+ let active = false;
422
+ let pid = -1;
423
+ handle.addEventListener('pointerdown', (e) => {
424
+ active = true;
425
+ pid = e.pointerId;
426
+ startX = e.clientX;
427
+ startV = get();
428
+ handle.setPointerCapture(pid);
429
+ e.preventDefault();
430
+ });
431
+ handle.addEventListener('pointermove', (e) => {
432
+ if (!active) return;
433
+ set(startV + (e.clientX - startX) * step);
434
+ });
435
+ const end = (): void => {
436
+ if (!active) return;
437
+ active = false;
438
+ try {
439
+ handle.releasePointerCapture(pid);
440
+ } catch {
441
+ /* capture already gone */
442
+ }
443
+ };
444
+ handle.addEventListener('pointerup', end);
445
+ handle.addEventListener('pointercancel', end);
446
+ }
447
+
448
+ // ── value formatting (read-only fallback) ─────────────────────────────────────────
449
+
450
+ function fmt(v: unknown): string {
451
+ if (v == null) return '—';
452
+ if (typeof v === 'number') return fmtNum(v);
453
+ if (typeof v === 'boolean' || typeof v === 'string') return String(v);
454
+ if (Array.isArray(v)) return `[${v.map(fmt).join(', ')}]`;
455
+ const o = v as Record<string, unknown> & { isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; isObject3D?: boolean };
456
+ if (o.isVector3 || o.isVector2) return vec(o as unknown as Vector3);
457
+ if (o.isEuler) return euler(o as unknown as Euler);
458
+ if (o.isColor) return `#${(o as unknown as Color).getHexString()}`;
459
+ if (o.isObject3D) return (o as unknown as Object3D).name || '(node)';
460
+ if (o.constructor?.name === 'PrefabRef') return '(prefab)';
461
+ if ('node' in o && 'engine' in o) return componentName(v as Component) ?? '(component)';
462
+ return String(v);
463
+ }
464
+
465
+ const vec = (v: Vector3 | Vector2): string =>
466
+ 'z' in v ? `${round(v.x)} ${round(v.y)} ${round((v as Vector3).z)}` : `${round(v.x)} ${round(v.y)}`;
467
+ const euler = (e: Euler): string => `${toDeg(e.x)} ${toDeg(e.y)} ${toDeg(e.z)}`;
468
+ const eulerArr = (e: Euler): number[] => [e.x, e.y, e.z];
469
+ const toDeg = (rad: number): number => round((rad * 180) / Math.PI);
470
+ const toRad = (deg: number): number => (deg * Math.PI) / 180;
471
+ const round = (n: number): number => Math.round(n * 1000) / 1000;
472
+ const fmtNum = (n: number): string => String(round(n));
473
+
474
+ // ── registry helpers ──────────────────────────────────────────────────────────────
475
+
476
+ function registeredMeta(component: Component) {
477
+ for (const name of Registry.componentNames()) {
478
+ const entry = Registry.getComponent(name);
479
+ if (entry && component instanceof entry.ctor) return entry.meta;
480
+ }
481
+ return undefined;
482
+ }
483
+ function componentName(component: Component): string | undefined {
484
+ for (const name of Registry.componentNames()) {
485
+ const entry = Registry.getComponent(name);
486
+ if (entry && component.constructor === entry.ctor) return name;
487
+ }
488
+ return undefined;
489
+ }
490
+
491
+ // ── DOM helpers ────────────────────────────────────────────────────────────────────
492
+
493
+ function div(className: string, text?: string): HTMLDivElement {
494
+ const el = document.createElement('div');
495
+ if (className) el.className = className;
496
+ if (text) el.textContent = text;
497
+ return el;
498
+ }
499
+ /** A side drawer that slides off-screen, leaving an arrow tab to reopen it. */
500
+ function drawer(side: 'left' | 'right', heading: string, content: HTMLElement): HTMLElement {
501
+ const el = div(`sc-drawer sc-${side}`);
502
+ const panel = div('sc-panel');
503
+ panel.append(titleEl(heading), content);
504
+ const tab = document.createElement('button');
505
+ tab.className = 'sc-tab';
506
+ tab.title = `${heading} panel`;
507
+ const sync = () => {
508
+ const open = !el.classList.contains('collapsed');
509
+ tab.textContent = side === 'left' ? (open ? '‹' : '›') : open ? '›' : '‹';
510
+ };
511
+ tab.onclick = () => {
512
+ el.classList.toggle('collapsed');
513
+ sync();
514
+ };
515
+ el.append(...(side === 'left' ? [panel, tab] : [tab, panel]));
516
+ sync();
517
+ return el;
518
+ }
519
+ function titleEl(text: string): HTMLDivElement {
520
+ return div('sc-title', text);
521
+ }
522
+ function row(key: string, value: string): HTMLDivElement {
523
+ const r = div('sc-row');
524
+ r.append(div('k', key), div('v', value));
525
+ return r;
526
+ }
527
+ /** A row whose value cell holds an editor element. */
528
+ function editRow(key: string, control: HTMLElement): HTMLDivElement {
529
+ const r = div('sc-row');
530
+ const v = div('v');
531
+ v.append(control);
532
+ r.append(div('k', key), v);
533
+ return r;
534
+ }
535
+ function nodeLabel(node: Object3D): DocumentFragment {
536
+ const frag = document.createDocumentFragment();
537
+ const name = node.name || `(${typeName(node)})`;
538
+ frag.append(document.createTextNode(name));
539
+ if (node.name) {
540
+ const ty = div('ty', ` ${typeName(node)}`);
541
+ ty.style.display = 'inline';
542
+ frag.append(ty);
543
+ }
544
+ return frag;
545
+ }
546
+ function typeName(node: Object3D): string {
547
+ return node.type || node.constructor.name;
548
+ }
549
+ /** True if `object` (or an ancestor) is editor-internal — kept out of the Hierarchy. */
550
+ function isEditorOnly(object: Object3D): boolean {
551
+ for (let o: Object3D | null = object; o; o = o.parent) if (o.userData.scenocoEditorOnly) return true;
552
+ return false;
553
+ }