@volter/editor-threejs 0.5.66 → 0.5.68

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.
Files changed (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. package/src/viewport-door.ts +237 -0
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Read-only authoring projection for a native Three.js document graph.
3
+ *
4
+ * The native Object3D tree is truth. The shared three projector
5
+ * (`../projection/three.ts`) walks it under the document-path identity and owns
6
+ * the index; the shared collapse view (`three-projection-core.ts`) turns that
7
+ * index into rows. This CONFIGURATION supplies the two axes — document-path
8
+ * identity, no write target — plus what only a document has: its own document
9
+ * row, the model classification on it, and the read-only inspector. It never
10
+ * fabricates descriptors or a JSON mirror. Source parameters rebuild the graph
11
+ * outside this adapter.
12
+ */
13
+
14
+ import type {
15
+ AuthoringAdapter,
16
+ AuthoringCapabilities,
17
+ AuthoringProvenance,
18
+ HierarchyProvider,
19
+ InspectorProvider,
20
+ PropertyDescriptor,
21
+ SelectionProvider,
22
+ Transform,
23
+ TransformProvider,
24
+ } from '@volter/editor-project/adapter';
25
+ import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
26
+ import { object3DAuthoringSubjectOf } from '@volter/editor-threejs/adapter/object3d-authoring-subject';
27
+ import { getUserData } from '@volter/editor-threejs/ecs/user-data';
28
+ import type * as THREE from 'three';
29
+ import {
30
+ classifyModelStructure,
31
+ inspectModel,
32
+ inspectModelRig,
33
+ } from '../asset-workflow/model-inspection';
34
+ import type { EditorShellStore } from '../editor-shell-store';
35
+ import { documentPathIdentity, ThreeProjector } from '../projection/three';
36
+ import {
37
+ CollapsedHierarchyView,
38
+ readLocalTransform,
39
+ StoreSelectionAdoption,
40
+ } from './three-projection-core';
41
+
42
+ export interface SourceObject3DAuthoringOptions {
43
+ readonly documentId: string;
44
+ readonly title: string;
45
+ readonly sourcePath: string;
46
+ readonly documentKind?: 'source' | 'asset';
47
+ /** The Asset Lab's import audit on this document's inspector — see
48
+ * `ToolObject3DAuthoringProps.audit`. Default on. */
49
+ readonly audit?: boolean;
50
+ /** Authored content roots when the scene also contains a document-owned
51
+ * presentation container. Omitted preserves the native projection roots. */
52
+ readonly hierarchyRoots?: readonly THREE.Object3D[];
53
+ readonly provenance?: AuthoringProvenance;
54
+ readonly modelSource?:
55
+ | { readonly kind: 'project-file'; readonly path: string }
56
+ | { readonly kind: 'entity'; readonly entityId: string };
57
+ }
58
+
59
+ /** `node()` runs on every hierarchy render; classifying a whole graph there
60
+ * would walk it each time. Keyed by the root object, so a rebuilt graph is a
61
+ * fresh entry and a disposed one is collected with it. */
62
+ const documentTypeLabelByRoot = new WeakMap<THREE.Object3D, string>();
63
+
64
+ function classifyDocumentRoot(root: THREE.Object3D): string {
65
+ let splat = false;
66
+ root.traverse((object) => {
67
+ if (getUserData(object, 'gaussianSplat')) splat = true;
68
+ });
69
+ if (splat) return 'Gaussian splat';
70
+ const inspection = inspectModel(root);
71
+ const rig = inspectModelRig(root);
72
+ return {
73
+ static: 'Static model',
74
+ animated: 'Animated model',
75
+ rigged: 'Rigged model',
76
+ skinned: 'Skinned model',
77
+ }[classifyModelStructure(inspection, rig)];
78
+ }
79
+
80
+ function materialOf(object: THREE.Object3D): THREE.Material | null {
81
+ const material = (object as THREE.Mesh).material;
82
+ if (Array.isArray(material)) return material[0] ?? null;
83
+ return material ?? null;
84
+ }
85
+
86
+ function colorOf(material: THREE.Material | null): THREE.Color | null {
87
+ const candidate = material as (THREE.Material & { color?: THREE.Color }) | null;
88
+ return candidate?.color ?? null;
89
+ }
90
+
91
+ export class SourceObject3DAuthoringAdapter implements AuthoringAdapter {
92
+ private readonly documentNodeId: string;
93
+ /** The shared three projector over this document's graph. */
94
+ private readonly projector: ThreeProjector;
95
+ private readonly hierarchyRootIds: readonly string[];
96
+ /** The shared collapse view over {@link projector} — see
97
+ * `three-projection-core.ts`. */
98
+ private readonly view: CollapsedHierarchyView;
99
+ /** Selection, shared with the store the viewport writes to — see
100
+ * `three-projection-core.ts`. */
101
+ private readonly selectionState: StoreSelectionAdoption;
102
+ private markedSelection = new Set<THREE.Object3D>();
103
+
104
+ readonly capabilities: AuthoringCapabilities = {
105
+ transform: false,
106
+ inspectorFields: true,
107
+ persist: false,
108
+ };
109
+
110
+ readonly provenance: AuthoringProvenance;
111
+
112
+ constructor(
113
+ private readonly store: EditorShellStore,
114
+ private readonly scene: THREE.Scene,
115
+ readonly options: SourceObject3DAuthoringOptions,
116
+ ) {
117
+ this.provenance =
118
+ options.provenance ??
119
+ (options.documentKind === 'asset'
120
+ ? {
121
+ source: 'document',
122
+ label: 'derived asset',
123
+ detail: 'Loaded from an ordinary project asset. Edit its source or import recipe.',
124
+ }
125
+ : {
126
+ source: 'source-code',
127
+ label: 'source',
128
+ detail:
129
+ 'Instantiated from project TypeScript. Edit source or exposed parameters, then rebuild.',
130
+ });
131
+ this.documentNodeId = `object3d-document:${options.documentId}`;
132
+ // The projector stamps `userData.entityId` as it walks, so EditorViewport
133
+ // and its gizmo/pick code consume the same native id.
134
+ this.projector = new ThreeProjector(documentPathIdentity(options.documentId));
135
+ this.indexGraph();
136
+ if (
137
+ options.hierarchyRoots &&
138
+ new Set(options.hierarchyRoots).size !== options.hierarchyRoots.length
139
+ ) {
140
+ throw new Error(
141
+ `Object3D document "${options.title}" declared the same hierarchy root twice.`,
142
+ );
143
+ }
144
+ const declaredRootSet = new Set(options.hierarchyRoots ?? []);
145
+ for (const root of declaredRootSet) {
146
+ let parent = root.parent;
147
+ while (parent && parent !== scene) {
148
+ if (declaredRootSet.has(parent)) {
149
+ throw new Error(
150
+ `Object3D document "${options.title}" declared nested hierarchy roots; roots must be non-overlapping.`,
151
+ );
152
+ }
153
+ parent = parent.parent;
154
+ }
155
+ }
156
+ this.hierarchyRootIds = options.hierarchyRoots
157
+ ? options.hierarchyRoots.map((object) => {
158
+ const id = this.idByObject.get(object);
159
+ if (!id) {
160
+ throw new Error(
161
+ `Object3D document "${options.title}" declared a hierarchy root outside its native graph.`,
162
+ );
163
+ }
164
+ return id;
165
+ })
166
+ : [...this.projector.rootIds];
167
+ this.view = new CollapsedHierarchyView({
168
+ projector: this.projector,
169
+ scene,
170
+ documentNodeId: this.documentNodeId,
171
+ rootIds: this.hierarchyRootIds,
172
+ });
173
+ this.selectionState = new StoreSelectionAdoption({
174
+ store: store.shell,
175
+ owns: (id) => this.byId.has(id),
176
+ initial: [this.documentNodeId],
177
+ onChange: (ids) => this.syncMarkedSelection(ids),
178
+ });
179
+ }
180
+
181
+ // ------------------------------------------------ graph mutated after mount
182
+ //
183
+ // The constructor's walk is a snapshot of the graph AT MOUNT, and one class
184
+ // of document mutates its graph afterwards: the live module document
185
+ // (`components/asset-viewers/LiveModuleDocument.tsx`) keeps a stable
186
+ // container root and SWAPS ITS CHILD on every save, precisely so the
187
+ // viewport's camera survives a rebuild. Measured 2026-08-29: the viewport
188
+ // showed the rebuilt model while the hierarchy kept the mount-time walk, so
189
+ // the document row lost its child and the picker addressed a disposed
190
+ // object.
191
+ //
192
+ // The fix is `r3f-source-authoring-adapter.ts`'s, transcribed: re-index on
193
+ // the GRAPH'S OWN structural events. `parent.add(child)` dispatches
194
+ // `childadded` on the parent, so watching the scene plus every indexed
195
+ // object covers every path a subtree can enter through, and no document has
196
+ // to remember to tell this adapter anything. Editor furniture (gizmo and
197
+ // helper attachments) is filtered so selection churn does not re-index.
198
+
199
+ private disposed = false;
200
+ dispose(): void {
201
+ if (this.disposed) return;
202
+ this.disposed = true;
203
+ for (const object of this.watchedForStructure) {
204
+ object.removeEventListener('childadded', this.onStructureChanged);
205
+ object.removeEventListener('childremoved', this.onStructureChanged);
206
+ }
207
+ this.watchedForStructure = [];
208
+ }
209
+
210
+ private watchedForStructure: THREE.Object3D[] = [];
211
+ private structureReindexQueued = false;
212
+
213
+ private readonly onStructureChanged = (event: { child: THREE.Object3D }): void => {
214
+ if (isEditorOwnedObject(event.child)) return;
215
+ this.scheduleStructureReindex();
216
+ };
217
+
218
+ private indexGraph(): void {
219
+ this.projector.project(this.scene);
220
+ this.watchStructure();
221
+ }
222
+
223
+ /** (Re)attach `childadded`/`childremoved` across the indexed graph, so a
224
+ * freshly-swapped subtree is watched from the re-index that found it. */
225
+ private watchStructure(): void {
226
+ const structural = (object: THREE.Object3D) =>
227
+ object as unknown as {
228
+ addEventListener(
229
+ type: 'childadded' | 'childremoved',
230
+ listener: (event: { child: THREE.Object3D }) => void,
231
+ ): void;
232
+ removeEventListener(
233
+ type: 'childadded' | 'childremoved',
234
+ listener: (event: { child: THREE.Object3D }) => void,
235
+ ): void;
236
+ };
237
+ for (const object of this.watchedForStructure) {
238
+ structural(object).removeEventListener('childadded', this.onStructureChanged);
239
+ structural(object).removeEventListener('childremoved', this.onStructureChanged);
240
+ }
241
+ this.watchedForStructure = [];
242
+ const watch = (object: THREE.Object3D): void => {
243
+ structural(object).addEventListener('childadded', this.onStructureChanged);
244
+ structural(object).addEventListener('childremoved', this.onStructureChanged);
245
+ this.watchedForStructure.push(object);
246
+ };
247
+ watch(this.scene);
248
+ for (const [, object] of this.byId) watch(object);
249
+ }
250
+
251
+ /** One re-index per mutation burst: a swap is remove-then-add, and a
252
+ * microtask runs after the whole subtree is in the graph. */
253
+ private scheduleStructureReindex(): void {
254
+ if (this.disposed || this.structureReindexQueued) return;
255
+ this.structureReindexQueued = true;
256
+ queueMicrotask(() => {
257
+ this.structureReindexQueued = false;
258
+ if (this.disposed) return;
259
+ this.indexGraph();
260
+ if (this.store.scene !== this.scene) return;
261
+ const map = this.store.objectMap;
262
+ map.clear();
263
+ for (const [id, object] of this.byId) map.set(id, object);
264
+ // Selection can name an object the swap removed; keep it inside the
265
+ // index the hierarchy, picker and gizmo now consume.
266
+ const retained = [...this.store.shell.selectedEntityIds].filter(
267
+ (id) => id === this.documentNodeId || this.byId.has(id),
268
+ );
269
+ if (retained.length !== this.store.shell.selectedEntityIds.size) {
270
+ this.store.shell.selectMultiple(retained);
271
+ }
272
+ // `subscribe` on this adapter IS the store's subscribe, so notifying the
273
+ // store is this adapter's own change signal.
274
+ this.store.notifyIngestObjectMapEdit();
275
+ });
276
+ }
277
+
278
+ private get byId(): ReadonlyMap<string, THREE.Object3D> {
279
+ return this.projector.objects;
280
+ }
281
+
282
+ private get idByObject(): ReadonlyMap<THREE.Object3D, string> {
283
+ return this.projector.idsByObject;
284
+ }
285
+
286
+ /** The document root's structural type, cached per root graph. `undefined`
287
+ * for a SOURCE document, whose root is whatever the project built and not
288
+ * a model with a structure to classify. */
289
+ private documentTypeLabel(): string | undefined {
290
+ if (this.options.documentKind !== 'asset') return undefined;
291
+ // NOT `documentRootObject` — that reads the document node's `childIds`,
292
+ // and this is called while building that node.
293
+ const root = this.scene.children.find((child) => this.idByObject.has(child)) ?? null;
294
+ if (!root) return undefined;
295
+ const cached = documentTypeLabelByRoot.get(root);
296
+ if (cached !== undefined) return cached;
297
+ const label = classifyDocumentRoot(root);
298
+ documentTypeLabelByRoot.set(root, label);
299
+ return label;
300
+ }
301
+
302
+ readonly hierarchy: HierarchyProvider = {
303
+ roots: () => [this.hierarchy.node(this.documentNodeId)!],
304
+ node: (id) => {
305
+ if (id === this.documentNodeId) {
306
+ const typeLabel = this.documentTypeLabel();
307
+ return {
308
+ id,
309
+ label: this.options.title,
310
+ secondaryLabel: this.options.sourcePath,
311
+ role: 'document',
312
+ kind: this.options.documentKind === 'asset' ? 'model-asset' : 'three-source',
313
+ // What to CALL this document's root, on the inspector's identity
314
+ // row — "Rigged model", "Gaussian splat". It is the structure of
315
+ // the graph this adapter is projecting, so the adapter is the only
316
+ // thing that can answer it; the classification is cached per root
317
+ // because `node()` is called on every hierarchy render.
318
+ ...(typeLabel ? { typeLabel } : {}),
319
+ parentId: null,
320
+ childIds: this.view.rootIds(),
321
+ };
322
+ }
323
+ const object = this.byId.get(id);
324
+ return object ? this.view.node(object) : null;
325
+ },
326
+ object3D: (id) => this.byId.get(id) ?? null,
327
+ idForObject3D: (object: THREE.Object3D) => this.idByObject.get(object) ?? null,
328
+ };
329
+
330
+ readonly selection: SelectionProvider = {
331
+ get: () => this.selectionState.current(),
332
+ // The document row is selectable and is NOT one of this projection's
333
+ // objects, which is why it survives this filter and never reaches the store.
334
+ set: (ids) =>
335
+ this.selectionState.publish(
336
+ ids.filter((id) => id === this.documentNodeId || this.byId.has(id)),
337
+ ),
338
+ };
339
+
340
+ /**
341
+ * THE REASON THIS PROJECTION REFUSES, spelled once because it is PRINTED:
342
+ * it lands in the tooltip of every generic shell control that asks this
343
+ * adapter whether it may write.
344
+ */
345
+ private static readonly READ_ONLY_REASON =
346
+ 'This document shows what the module builds; its properties are read-only here. Edit the module.';
347
+
348
+ /**
349
+ * READ-ONLY, AND IT SAYS SO NOW. Every write door on this adapter is a
350
+ * no-op and always was — `transforms.beginEdit`/`apply`/`endEdit` below and
351
+ * `inspector.set` further down all return `undefined` — but it declared no
352
+ * `editability`, which is the seam the shell asks BEFORE drawing a control
353
+ * (`InspectorProvider.editability`'s own contract names "hierarchy eye/lock
354
+ * controls"). Its default is `{ writable: true }`, so the shell believed
355
+ * this projection and drew a live control over a surface that discards it.
356
+ *
357
+ * MEASURED 2026-09-19 in a cold models scaffold, which is what bought this:
358
+ * the Outliner's EYE on a Model document lit, kept its `Hide` tooltip and
359
+ * its `aria-pressed="false"`, and left the frame hash byte-identical —
360
+ * `GameHierarchy.tsx` gates it on `visibleKnown && (editability?.writable ??
361
+ * true)`, and `visible` is knowable here (it reads `object.visible`) even
362
+ * though it is `readonly: true` in the very descriptor list below.
363
+ *
364
+ * `transforms` DELIBERATELY DOES NOT GET ONE, and that is a measurement
365
+ * rather than an oversight. Its write doors are no-ops too, so the honest
366
+ * answer is the same — but declaring it bought nothing and cost a mark:
367
+ * `transform-mode-request.ts`, the one place that would have NAMED the
368
+ * refusal, reads `store.selectedEntityIds` off the SHELL store while a
369
+ * document stage keeps its selection in its own, so it returns before it
370
+ * can speak and the host's Move/Rotate/Scale stayed silent either way —
371
+ * while `GameHierarchy`'s `hierarchy-transform-lock` span immediately
372
+ * appeared on every row, a glyph Blender's Outliner does not draw. Answer
373
+ * it when something acts on the answer.
374
+ */
375
+ private readonly readOnly = (): { writable: boolean; reason: string } => ({
376
+ writable: false,
377
+ reason: SourceObject3DAuthoringAdapter.READ_ONLY_REASON,
378
+ });
379
+
380
+ readonly transforms: TransformProvider = {
381
+ get: (id): Transform => readLocalTransform(this.byId.get(id) ?? null),
382
+ beginEdit: () => undefined,
383
+ apply: () => undefined,
384
+ endEdit: () => undefined,
385
+ };
386
+
387
+ readonly inspector: InspectorProvider = {
388
+ properties: (id): PropertyDescriptor[] => {
389
+ if (id === this.documentNodeId) {
390
+ // A document that declined the import audit carries its own sections;
391
+ // the name is the identity row's and the path is the hierarchy
392
+ // header's, so the loose "Properties" grid would only repeat them.
393
+ if (this.options.audit === false) return [];
394
+ return [
395
+ { path: 'name', label: 'Name', type: 'string', readonly: true },
396
+ { path: 'source.path', label: 'Source', type: 'string', readonly: true },
397
+ ];
398
+ }
399
+ const object = this.byId.get(id);
400
+ if (!object) return [];
401
+ const subject = object3DAuthoringSubjectOf(object);
402
+ if (subject?.fields) {
403
+ return subject.fields().map(({ value: _value, ...descriptor }) => descriptor);
404
+ }
405
+ const properties: PropertyDescriptor[] = [
406
+ { path: 'name', label: 'Name', type: 'string', readonly: true },
407
+ { path: 'visible', label: 'Visible', type: 'boolean', readonly: true },
408
+ { path: 'object.type', label: 'Native type', type: 'string', readonly: true },
409
+ ];
410
+ const mesh = object as THREE.Mesh;
411
+ if (mesh.isMesh && mesh.geometry) {
412
+ const position = mesh.geometry.getAttribute('position');
413
+ properties.push({
414
+ path: 'mesh.vertices',
415
+ label: 'Vertices',
416
+ type: 'number',
417
+ readonly: true,
418
+ group: 'Mesh',
419
+ });
420
+ if (position) {
421
+ properties.push({
422
+ path: 'mesh.triangles',
423
+ label: 'Triangles',
424
+ type: 'number',
425
+ readonly: true,
426
+ group: 'Mesh',
427
+ });
428
+ }
429
+ }
430
+ const material = materialOf(object);
431
+ if (material) {
432
+ properties.push({
433
+ path: 'material.type',
434
+ label: 'Material',
435
+ type: 'string',
436
+ readonly: true,
437
+ group: 'Material',
438
+ });
439
+ if (colorOf(material)) {
440
+ properties.push({
441
+ path: 'material.color',
442
+ label: 'Color',
443
+ type: 'color',
444
+ readonly: true,
445
+ group: 'Material',
446
+ });
447
+ }
448
+ }
449
+ const skinned = object as THREE.SkinnedMesh;
450
+ if (skinned.isSkinnedMesh && skinned.skeleton) {
451
+ properties.push({
452
+ path: 'skin.bones',
453
+ label: 'Bones',
454
+ type: 'number',
455
+ readonly: true,
456
+ group: 'Skin',
457
+ });
458
+ }
459
+ return properties;
460
+ },
461
+ get: (id, path) => {
462
+ if (id === this.documentNodeId) {
463
+ if (path === 'name') return this.options.title;
464
+ if (path === 'source.path') return this.options.sourcePath;
465
+ return undefined;
466
+ }
467
+ const object = this.byId.get(id);
468
+ if (!object) return undefined;
469
+ const subject = object3DAuthoringSubjectOf(object);
470
+ const subjectField = subject?.fields?.().find((field) => field.path === path);
471
+ if (subjectField) return subjectField.value();
472
+ if (path === 'name') return object.name || object.type;
473
+ if (path === 'visible') return object.visible;
474
+ if (path === 'object.type') return object.type;
475
+ const mesh = object as THREE.Mesh;
476
+ if (path === 'mesh.vertices') return mesh.geometry?.getAttribute('position')?.count ?? 0;
477
+ if (path === 'mesh.triangles') {
478
+ const geometry = mesh.geometry;
479
+ return (geometry?.index?.count ?? geometry?.getAttribute('position')?.count ?? 0) / 3;
480
+ }
481
+ const material = materialOf(object);
482
+ if (path === 'material.type') return material?.type;
483
+ if (path === 'material.color') {
484
+ const color = colorOf(material);
485
+ return color ? `#${color.getHexString()}` : undefined;
486
+ }
487
+ if (path === 'skin.bones') return (object as THREE.SkinnedMesh).skeleton?.bones.length ?? 0;
488
+ return undefined;
489
+ },
490
+ set: () => undefined,
491
+ // See {@link readOnly}: `set` above writes nothing, so no generic surface
492
+ // may draw a live control over one of these values.
493
+ editability: () => this.readOnly(),
494
+ };
495
+
496
+ subscribe(listener: () => void): () => void {
497
+ return this.store.shell.subscribe(listener);
498
+ }
499
+
500
+ get documentId(): string {
501
+ return this.documentNodeId;
502
+ }
503
+
504
+ get documentRootObject(): THREE.Object3D | null {
505
+ const rootId = this.hierarchy.node(this.documentNodeId)?.childIds[0];
506
+ return rootId ? (this.byId.get(rootId) ?? null) : null;
507
+ }
508
+
509
+ private syncMarkedSelection(ids: readonly string[]): void {
510
+ const next = new Set(
511
+ ids
512
+ .map((id) => this.byId.get(id) ?? null)
513
+ .filter((object): object is THREE.Object3D => object !== null)
514
+ .filter((object) => object3DAuthoringSubjectOf(object)?.selectionChanged !== undefined),
515
+ );
516
+ for (const object of this.markedSelection) {
517
+ if (!next.has(object)) object3DAuthoringSubjectOf(object)?.selectionChanged?.(false);
518
+ }
519
+ for (const object of next) {
520
+ if (!this.markedSelection.has(object)) {
521
+ object3DAuthoringSubjectOf(object)?.selectionChanged?.(true);
522
+ }
523
+ }
524
+ this.markedSelection = next;
525
+ }
526
+ }