@volter/editor-threejs 0.5.211 → 0.5.213

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 (133) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +8 -13
  3. package/contributions/animation-mixers.service.ts +2 -4
  4. package/contributions/animation-timeline.utility.tsx +1 -2
  5. package/contributions/three.page.ts +50 -0
  6. package/dist-node/serving.mjs +84 -3
  7. package/dist-node/shared-three.mjs +145 -0
  8. package/package.json +296 -16
  9. package/serving/animation-stamp.ts +1 -1
  10. package/serving/index.ts +22 -5
  11. package/serving/model-import-conversion.ts +6 -3
  12. package/serving/shared-three.ts +176 -0
  13. package/src/adapter/constraint.ts +1 -18
  14. package/src/adapter/object3d-authoring-subject.ts +1 -12
  15. package/src/adapter/reflection-probe.ts +1 -9
  16. package/src/adapter/three-contract.ts +6 -60
  17. package/src/animation/live-mixers.ts +0 -4
  18. package/src/api.ts +7 -0
  19. package/src/asset-loaders.ts +2 -178
  20. package/src/capture/photograph-sampling.ts +333 -0
  21. package/src/capture/scene.ts +1 -1
  22. package/src/ecs/object-marks.ts +6 -14
  23. package/src/ecs/user-data.ts +3 -138
  24. package/src/kit/animation/three-clips-subject.ts +19 -42
  25. package/src/kit/asset-compare-core.ts +161 -0
  26. package/src/kit/asset-compare.ts +8 -27
  27. package/src/kit/asset-preview-command.ts +14 -19
  28. package/src/kit/asset-preview-framing.ts +8 -67
  29. package/src/kit/asset-preview.ts +109 -342
  30. package/src/kit/asset-workflow/model-inspection.ts +11 -154
  31. package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
  32. package/src/kit/authoring/component-instance-root.ts +3 -39
  33. package/src/kit/authoring/live-object-transform.ts +1 -30
  34. package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
  35. package/src/kit/authoring/object3d-document-session.ts +471 -208
  36. package/src/kit/authoring/shell-viewport-policy.ts +18 -24
  37. package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
  38. package/src/kit/authoring/three-projection-core.ts +11 -40
  39. package/src/kit/authoring/viewport-pick-context.ts +5 -12
  40. package/src/kit/authoring/viewport-raycast.ts +15 -93
  41. package/src/kit/authoring/world-hidden-viewport.ts +8 -61
  42. package/src/kit/capture-camera-pose.ts +68 -0
  43. package/src/kit/collaboration-presence.ts +41 -0
  44. package/src/kit/components/CameraInfo.tsx +4 -6
  45. package/src/kit/components/InspectorObjectPreview.tsx +3 -30
  46. package/src/kit/components/Object3DDocumentToolbar.css +75 -0
  47. package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
  48. package/src/kit/components/Object3DDocumentViewport.tsx +6 -11
  49. package/src/kit/components/StageHost.css +40 -0
  50. package/src/kit/components/StageHost.tsx +192 -439
  51. package/src/kit/components/StageOverlays.tsx +1 -2
  52. package/src/kit/components/StatsOverlay.tsx +5 -17
  53. package/src/kit/components/ViewportFurniture.tsx +27 -154
  54. package/src/kit/components/ViewportOverlay.tsx +13 -17
  55. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  56. package/src/kit/components/ViewportShadingMenu.tsx +2 -42
  57. package/src/kit/components/ViewportViewMenu.tsx +7 -18
  58. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +6 -17
  59. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +9 -11
  60. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +31 -133
  61. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +10 -12
  62. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +4 -8
  63. package/src/kit/components/asset-viewers/Object3DPreview.tsx +13 -64
  64. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +6 -10
  65. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +48 -65
  66. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  67. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
  68. package/src/kit/components/object3d-contribution-surfaces.tsx +5 -3
  69. package/src/kit/components/stage-keyboard.tsx +6 -12
  70. package/src/kit/components/stage-overlay-set.tsx +11 -20
  71. package/src/kit/components/stage-presence-markers.ts +9 -32
  72. package/src/kit/components/stage-transform-chrome.ts +4 -4
  73. package/src/kit/components/stage-transform-tools.tsx +7 -14
  74. package/src/kit/components/standard-viewport-dressing.ts +47 -167
  75. package/src/kit/components/world-root-binding.ts +2 -7
  76. package/src/kit/constraint-helper.ts +1 -1
  77. package/src/kit/document-renderer-session.ts +114 -0
  78. package/src/kit/editor-presentation-activity.ts +58 -0
  79. package/src/kit/editor-shell-store.ts +31 -403
  80. package/src/kit/editor-viewport.ts +486 -1762
  81. package/src/kit/entity-lod.ts +3 -16
  82. package/src/kit/entity-object.ts +1 -39
  83. package/src/kit/hierarchy-mark-reader.ts +13 -30
  84. package/src/kit/instanced-presentation.ts +1 -47
  85. package/src/kit/live-gesture-lock.ts +35 -0
  86. package/src/kit/live-module-source.ts +12 -52
  87. package/src/kit/model-thumbnail.ts +7 -35
  88. package/src/kit/play-camera-flight.ts +7 -16
  89. package/src/kit/projection/three.ts +13 -366
  90. package/src/kit/retained-document-states.ts +91 -0
  91. package/src/kit/scene-document-viewport.ts +3 -3
  92. package/src/kit/scene-framing.ts +11 -152
  93. package/src/kit/scene-view-fog.ts +2 -47
  94. package/src/kit/spatial-handle-visuals.ts +19 -28
  95. package/src/kit/stories/three-story-model.ts +5 -33
  96. package/src/kit/three-canvas-render.ts +3 -4
  97. package/src/kit/three-hierarchy-row-media.ts +3 -11
  98. package/src/kit/three-inspection-media.ts +6 -6
  99. package/src/kit/three-integration.ts +18 -18
  100. package/src/kit/three-state.ts +2 -19
  101. package/src/kit/three-viewport/camera-fit.ts +2 -6
  102. package/src/kit/three-viewport/selection-brackets.ts +5 -20
  103. package/src/kit/three-viewport/selection-outline.ts +4 -56
  104. package/src/kit/three-viewport/skeleton-helper.ts +1 -32
  105. package/src/kit/three-viewport/source-color.ts +1 -33
  106. package/src/kit/three-viewport/studio-environment.ts +4 -55
  107. package/src/kit/three-viewport-presentation.ts +23 -0
  108. package/src/kit/transport-strip.tsx +147 -0
  109. package/src/kit/viewport-actions.ts +8 -50
  110. package/src/kit/viewport-authoring-policy.ts +5 -52
  111. package/src/kit/viewport-commands.ts +28 -57
  112. package/src/kit/viewport-hotkeys.ts +29 -31
  113. package/src/kit/viewport-status-facet.ts +4 -5
  114. package/src/loader.ts +8 -71
  115. package/src/object3d-contributions.ts +18 -34
  116. package/src/viewport/clip-planes.ts +4 -32
  117. package/src/viewport/content-bounds.ts +13 -142
  118. package/src/viewport/editor-layers.ts +4 -26
  119. package/src/viewport/environment.ts +1 -1
  120. package/src/viewport/preview-renderer.ts +7 -45
  121. package/src/viewport/renderer-ownership.ts +14 -62
  122. package/src/viewport-api.ts +14 -33
  123. package/src/viewport-door.ts +16 -86
  124. package/contributions/three-integration.service.ts +0 -13
  125. package/src/adapter/hierarchy-marks.ts +0 -156
  126. package/src/adapter/ingest/scene-capture.ts +0 -843
  127. package/src/adapter/ingest/structural-ids.ts +0 -139
  128. package/src/adapter/ingest/visible-capture-window.ts +0 -302
  129. package/src/adapter/renderer-config.ts +0 -115
  130. package/src/asset-parse-error.ts +0 -29
  131. package/src/host-hierarchy-objects.ts +0 -31
  132. package/src/kit/authoring/design-time-settle.ts +0 -343
  133. package/src/kit/camera-authoring.ts +0 -175
@@ -1,49 +1,13 @@
1
1
  /**
2
- * THE PROJECTOR FOR THE THREE SURFACE — one walk, one index, one set of derived
3
- * views, for every authoring adapter that drives a live `Object3D` graph
4
- * (ARCHITECTURE-CORE §The editor protocol, projection family).
5
- *
6
- * The projection unit is the RUNTIME OBJECT. Nothing here reads source, an AST
7
- * or a document; a walk answers from the graph that is actually mounted, so an
8
- * empty projection over a mounted world is a defect rather than a fact.
9
- *
10
- * ## What this owns, and what it deliberately does not
11
- *
12
- * OWNS — the four things all three three-surface authoring classes used to
13
- * spell separately:
14
- *
15
- * - the DFS itself ({@link walkThreeGraph}), including the editor-furniture
16
- * skip and the fold-into-the-nearest-admitted-ancestor rule,
17
- * - IDENTITY MINTING, parameterized by scheme ({@link oidIdentity},
18
- * {@link structuralIdentity}, {@link sourceOidIdentity},
19
- * {@link documentPathIdentity}) — the id FORMATS are the schemes', the walk
20
- * that applies them is one,
21
- * - the INDEX both directions ({@link ThreeProjector.objects} /
22
- * {@link ThreeProjector.idsByObject}) plus the `userData.entityId` stamp
23
- * that makes the viewport's raycast, the gizmo and `store.objectMap` agree
24
- * with the walk,
25
- * - PICKING as a derived view over that index
26
- * ({@link ThreeProjector.pick}).
27
- *
28
- * DOES NOT own the LANE-SPECIFIC hierarchy VIEW built on top. The R3F source
29
- * lane collapses a component instance's interior into one row; the live lane
30
- * resolves the same question over projected nodes with different top-level
31
- * semantics. Both read this projection and derive their rows from it; merging
32
- * the two collapse rules would be inventing a third, not extracting a shared
33
- * one.
34
- *
35
- * BOUNDS are not here either, because they were never in the authoring classes:
36
- * `@volter/editor-threejs/viewport/content-bounds` (`contentWorldBounds`) is the bounds walk
37
- * every consumer uses, and it takes the `Object3D` this index resolves.
2
+ * Project a live Object3D graph into one traversal, two-way object/id index and picker. Skip editor
3
+ * furniture, fold omitted nodes into admitted ancestors, and mint identities using the supplied
4
+ * scheme. Authoring adapters derive their hierarchy rows and write targets from this projection;
5
+ * viewport/content-bounds owns the separate bounds walk.
38
6
  */
39
7
 
40
8
  import type { EditorNode } from '@volter/project/adapter';
41
9
  import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
42
10
  import { constraintsOf } from '@volter/editor-threejs/adapter/constraint';
43
- import {
44
- CAMERA_ID,
45
- collectStructuralIds,
46
- } from '@volter/editor-threejs/adapter/ingest/structural-ids';
47
11
  import { object3DAuthoringSubjectOf } from '@volter/editor-threejs/adapter/object3d-authoring-subject';
48
12
  import { reflectionProbeOf } from '@volter/editor-threejs/adapter/reflection-probe';
49
13
  import { getUserData } from '@volter/editor-threejs/ecs/user-data';
@@ -52,17 +16,16 @@ import { authoringOidOf, occurrenceId } from '../authoring/component-instance-ro
52
16
  import { raycastCandidates, raycastPick } from '../authoring/viewport-raycast';
53
17
  import type { EditorShellStore } from '../editor-shell-store';
54
18
  import { entityIdOf, stampEntityId } from '../entity-object';
55
- import type { ProjectedNode, Projection } from '@volter/sdk/kit/projection-types';
19
+ import type { ProjectedNode, Projection } from '@volter/sdk/projection-types';
56
20
 
57
- export type { ProjectedNode, Projection } from '@volter/sdk/kit/projection-types';
21
+ export type { ProjectedNode, Projection } from '@volter/sdk/projection-types';
58
22
 
59
23
  /** One projected authoring node over a live three graph. */
60
24
  export type ThreeNode = ProjectedNode<THREE.Object3D>;
61
25
 
62
26
  /**
63
27
  * Reflection stats for one walk. Surfaced by the adapters' `refresh()` for
64
- * proof/telemetry (the ingest console line, the `__volterIngest.reflected` hook) —
65
- * deliberately a fresh measurement each walk rather than a mount-time snapshot,
28
+ * proof/telemetry — deliberately a fresh measurement each walk rather than a mount-time snapshot,
66
29
  * because a running world keeps streaming objects in.
67
30
  */
68
31
  export interface ThreeWalkStats {
@@ -76,9 +39,7 @@ export interface ThreeWalkStats {
76
39
  * object drawing N units — so `count`/`meshes` (and the hierarchy rows built
77
40
  * from the same nodes) report 1 where a reader sees N, with no error
78
41
  * anywhere, because both numbers are individually correct. These two fields
79
- * are the only place the shortfall is expressible; a probe's
80
- * instanced-content check reads exactly this pair
81
- * (`packages/editor/src/doctor/detection-gaps.ts`).
42
+ * make that difference visible to an instanced-content inspection.
82
43
  */
83
44
  readonly instancedMeshes: number;
84
45
  readonly instances: number;
@@ -189,19 +150,7 @@ function tally(nodes: ReadonlyMap<string, ThreeNode>): ThreeWalkStats {
189
150
  return { count: nodes.size, meshes, lights, ...instancedTally(nodes) };
190
151
  }
191
152
 
192
- /**
193
- * THE WALK. One DFS over a live scene, projecting it into authoring nodes and
194
- * stamping `userData.entityId` on each, so the viewport raycast/gizmo and
195
- * `EditorShellStore.objectMap` key off the same identity the walk minted.
196
- *
197
- * Every scheme that goes through here addresses a graph the editor is entitled
198
- * to stamp. The one scheme that is not ({@link structuralIdentity}, over
199
- * foreign objects) keeps identity in the projection instead and does not use
200
- * this walk at all — see #1965.
201
- *
202
- * Idempotent for every scheme that reuses an existing stamp: re-walking a scene
203
- * re-derives the same ids for the same objects.
204
- */
153
+ /** Project a live scene in one DFS and stamp entityId so raycast, gizmo and objectMap share the same identities. Reused stamps make repeated walks stable. */
205
154
  export function walkThreeGraph(scene: THREE.Scene, scheme: ThreeIdentityScheme): ThreeProjection {
206
155
  const nodes = new Map<string, ThreeNode>();
207
156
  const rootIds: string[] = [];
@@ -237,15 +186,7 @@ export function walkThreeGraph(scene: THREE.Scene, scheme: ThreeIdentityScheme):
237
186
  return { nodes, rootIds, stats: tally(nodes) };
238
187
  }
239
188
 
240
- /**
241
- * The scheme half of every three-lane entity id (`r3f:<worldId>:<oid>`).
242
- *
243
- * Spelled ONCE because it is read as well as written: the editor shell tests a
244
- * focused id with `startsWith` to decide whether the three lane owns it
245
- * (`components/DefaultEditorLayout.tsx`), so a literal changed at one of the
246
- * mint sites and not at the reader is a silent loss of that decision rather
247
- * than a compile error.
248
- */
189
+ /** Shared scheme for Three-lane entity ids, r3f:<worldId>:<oid>. */
249
190
  export const THREE_ENTITY_ID_SCHEME = 'r3f:';
250
191
 
251
192
  /** `r3f:<worldId>:<oid>` — the one place the three lane's id shape is built. */
@@ -253,297 +194,6 @@ export function threeEntityId(worldId: string, oid: string): string {
253
194
  return `${THREE_ENTITY_ID_SCHEME}${worldId}:${oid}`;
254
195
  }
255
196
 
256
- /**
257
- * The LIVE OID scheme. A node stamped by the served TSX's `__volterOid` transform
258
- * keeps `r3f:<worldId>:<oid>`, so selection survives Edit→Play; anything a
259
- * source location cannot claim gets a session-stable `live:<worldId>:<n>`.
260
- */
261
- function liveOidScheme(worldId: string): ThreeIdentityScheme {
262
- const oidOccurrences = new Map<string, number>();
263
- const reservedOccurrenceOwners = new Map<string, THREE.Object3D>();
264
- const occurrenceBelongsTo = (id: string, base: string): boolean =>
265
- id === base || (id.startsWith(`${base}#`) && /^\d+$/u.test(id.slice(base.length + 1)));
266
-
267
- const reserveExistingOccurrences = (scene: THREE.Scene): void => {
268
- reservedOccurrenceOwners.clear();
269
- const visit = (object: THREE.Object3D): void => {
270
- if (isEditorOwnedObject(object)) return;
271
- const sourceOid = authoringOidOf(object);
272
- const stamped = entityIdOf(object);
273
- if (sourceOid !== undefined && stamped !== undefined) {
274
- const base = threeEntityId(worldId, sourceOid);
275
- if (occurrenceBelongsTo(stamped, base) && !reservedOccurrenceOwners.has(stamped)) {
276
- reservedOccurrenceOwners.set(stamped, object);
277
- }
278
- }
279
- for (const child of object.children) visit(child);
280
- };
281
- for (const child of scene.children) visit(child);
282
- };
283
-
284
- const reusableStampedIdentity = (
285
- object: THREE.Object3D,
286
- sourceOid: string | undefined,
287
- ): string | undefined => {
288
- const stamped = entityIdOf(object);
289
- if (stamped === undefined || sourceOid === undefined) return stamped;
290
- const base = threeEntityId(worldId, sourceOid);
291
- if (!occurrenceBelongsTo(stamped, base)) return stamped;
292
- return reservedOccurrenceOwners.get(stamped) === object ? stamped : undefined;
293
- };
294
-
295
- const claimSourceIdentity = (object: THREE.Object3D, sourceOid: string): string => {
296
- const base = threeEntityId(worldId, sourceOid);
297
- let occurrence = oidOccurrences.get(sourceOid) ?? 0;
298
- let identity = occurrenceId(base, occurrence);
299
- while (reservedOccurrenceOwners.has(identity)) {
300
- occurrence += 1;
301
- identity = occurrenceId(base, occurrence);
302
- }
303
- oidOccurrences.set(sourceOid, occurrence + 1);
304
- reservedOccurrenceOwners.set(identity, object);
305
- return identity;
306
- };
307
-
308
- return {
309
- begin: (scene) => {
310
- oidOccurrences.clear();
311
- reserveExistingOccurrences(scene);
312
- },
313
- identify: (object, context) => {
314
- // Count every source-stamped runtime node in native traversal order,
315
- // including projected-away wrappers/parts, so surviving authoring roots
316
- // keep the same id across Edit and Play (mirrors the R3F design adapter's
317
- // duplicate-OID suffixing exactly).
318
- const sourceOid = authoringOidOf(object);
319
- const stamped = reusableStampedIdentity(object, sourceOid);
320
- if (stamped !== undefined) return stamped;
321
- // Two live objects carrying one old occurrence stamp is the precise
322
- // collision the reservation pass repairs. The first object in the
323
- // current graph keeps the address; this later one receives the first
324
- // free occurrence and is restamped by walkThreeGraph.
325
- const sourceIdentity =
326
- sourceOid === undefined ? undefined : claimSourceIdentity(object, sourceOid);
327
- // ADMISSION. Three independent grounds, and the FIRST is what makes a
328
- // play mount project the world the author wrote:
329
- //
330
- // - A SOURCE IDENTITY. The served TSX stamped this element, so it is a
331
- // node the author authored — `<object3D name='Coins'>` as much as the
332
- // `<Coin/>` instances inside it. Ownership by an ancestor is beside the
333
- // point: an authored node is not its owner's implementation detail.
334
- // Without this, a fresh play mount (where nothing is pre-stamped with
335
- // an `entityId` yet) admitted only the nodes whose `authoringInstance`
336
- // differed from their parent's — so `Stage`'s whole interior collapsed
337
- // and its 49 component instances rendered FLAT, with the `Coins` and
338
- // `Enemies` groups that really parent them gone from the panel.
339
- // - NO OWNER ABOVE. An unstamped world (an ingest, an adopted graph) has
340
- // no source identities at all; its outermost objects are the rows, and
341
- // everything under them stays folded in as parts.
342
- // - AN EXPLICIT OPT-IN. `userData.authoringRoot` is how a runtime
343
- // descendant of such a world asks to be a row of its own.
344
- //
345
- // What stays folded is exactly what has none of the three: the parts a
346
- // library builds under an authored node (GLTF meshes, bones, a light's
347
- // `target`), which the mark convention folds by classification instead.
348
- if (
349
- sourceIdentity !== undefined ||
350
- !context.ownedByAncestor ||
351
- getUserData(object, 'authoringRoot') === true
352
- ) {
353
- return sourceIdentity ?? `live:${worldId}:${mintedIdCounter++}`;
354
- }
355
- return null;
356
- },
357
- };
358
- }
359
-
360
- /**
361
- * Walk a live scene under the OID scheme. Exported because scene ADOPTION runs
362
- * the same walk the adapter does (`viewport-root-presentation.ts`), and the two must
363
- * agree about every id.
364
- */
365
- export function projectThreeScene(scene: THREE.Scene, worldId: string): ThreeProjection {
366
- return walkThreeGraph(scene, liveOidScheme(worldId));
367
- }
368
-
369
- /**
370
- * Stamp identities on a live scene and return the id → Object3D map the store
371
- * adopts as its `objectMap`. The adapter's own index is built by the same walk,
372
- * so the two can never disagree about an id.
373
- */
374
- export function stampThreeIdentities(
375
- scene: THREE.Scene,
376
- worldId: string,
377
- ): Map<string, THREE.Object3D> {
378
- const { nodes } = projectThreeScene(scene, worldId);
379
- const map = new Map<string, THREE.Object3D>();
380
- for (const [id, node] of nodes) map.set(id, node.object);
381
- return map;
382
- }
383
-
384
- /**
385
- * The OID identity (see {@link liveOidScheme}), for a LIVE world.
386
- *
387
- * `camera` is the SAME allowance {@link structuralIdentity} makes and for the
388
- * same reason: a captured world's render camera often is not a child of the
389
- * scene it renders, and a camera that is not a node is not selectable,
390
- * inspectable, or reachable by the viewport's framing. It joins as an extra
391
- * root under the fixed {@link CAMERA_ID} only when the walk did not already
392
- * meet it as a scene child — an R3F world usually authors its camera inside the
393
- * tree, and admitting it twice would put the same object on two rows.
394
- */
395
- export function oidIdentity(
396
- worldId: string,
397
- options: { readonly camera?: THREE.Camera | undefined } = {},
398
- ): ThreeIdentity {
399
- const camera = options.camera;
400
- return {
401
- incremental: { kind: 'live-oid', worldId },
402
- project: (scene) => {
403
- const projection = projectThreeScene(scene, worldId);
404
- if (!camera || [...projection.nodes.values()].some((node) => node.object === camera)) {
405
- return projection;
406
- }
407
- stampEntityId(camera, CAMERA_ID);
408
- projection.nodes.set(CAMERA_ID, {
409
- object: camera,
410
- id: CAMERA_ID,
411
- parentId: null,
412
- childIds: [],
413
- });
414
- projection.rootIds.push(CAMERA_ID);
415
- return { ...projection, stats: { ...projection.stats, count: projection.nodes.size } };
416
- },
417
- };
418
- }
419
-
420
- export interface StructuralIdentityOptions {
421
- /**
422
- * A render camera that is NOT a scene child — surfaced as an extra root node
423
- * under the fixed {@link CAMERA_ID} so it is selectable and inspectable. A
424
- * captured world's camera usually lives outside the tree it renders.
425
- */
426
- readonly camera?: THREE.Camera | undefined;
427
- }
428
-
429
- /**
430
- * The STRUCTURAL-PATH scheme. Deterministic from the scene's shape (position in
431
- * the tree + three.js type + name), so the same id re-binds to the same object
432
- * after the world rebuilds part of its graph within a session. This is the
433
- * scheme for a graph whose source was never stamped, and it pairs with the
434
- * creation-site anchors the persistence backend writes through.
435
- *
436
- * The walk itself lives in `@volter/editor-threejs/adapter/ingest/structural-ids` — it is
437
- * the engine's own ingest walk and stays there; this provider is the projection
438
- * hop, turning its pure flat id→object map into the parent/child node graph
439
- * every authoring provider reads, WITHOUT stamping the foreign objects.
440
- */
441
- export function structuralIdentity(options: StructuralIdentityOptions = {}): ThreeIdentity {
442
- const camera = options.camera;
443
- return {
444
- project: (scene) => {
445
- const walk = collectStructuralIds(scene, camera);
446
- // Reverse the walk's own map rather than storing identity on the foreign
447
- // objects. A re-walk after a rebuild therefore has no stale stamps to
448
- // collide with live ids or parent under the wrong object.
449
- const idByObject = new Map<THREE.Object3D, string>();
450
- for (const [id, object] of walk.byId) idByObject.set(object, id);
451
- const nodes = new Map<string, ThreeNode>();
452
- const rootIds: string[] = [];
453
- for (const [id, object] of walk.byId) {
454
- const parent = object.parent;
455
- const parentId = (parent && parent !== scene ? idByObject.get(parent) : undefined) ?? null;
456
- // `childIds` is filtered to the walk's own membership: editor furniture
457
- // parked in the world's scene is skipped by `assignStructuralIds`, and
458
- // it must not reappear as a hierarchy row through a parent's children.
459
- const childIds = object.children
460
- .map((child) => idByObject.get(child))
461
- .filter((childId): childId is string => childId !== undefined);
462
- nodes.set(id, { object, id, parentId, childIds });
463
- if (parentId === null) rootIds.push(id);
464
- }
465
- return {
466
- nodes,
467
- rootIds,
468
- stats: {
469
- count: walk.count,
470
- meshes: walk.meshes,
471
- lights: walk.lights,
472
- ...instancedTally(nodes),
473
- },
474
- };
475
- },
476
- };
477
- }
478
-
479
- /**
480
- * The SOURCE OID identity, for a first-party R3F world whose TSX the editor
481
- * serves: every object is a node (the lane's hierarchy view does its own
482
- * collapsing on top), addressed by its authored callsite where it has one and
483
- * by its structural path where it does not.
484
- *
485
- * It also reports the two OID-lane facts the walk is the only place able to
486
- * measure, refreshed on every `project`:
487
- *
488
- * - `instanceRoots` — how many times each authored element RENDERS, counted at
489
- * instance ROOTS only (an object with no ancestor carrying the same
490
- * identity). Every host element inside a component definition carries that
491
- * instance's stamp, so counting stamped objects would report a component as
492
- * rendering ~50 times instead of once.
493
- * - `representatives` — the FIRST live object that resolved to each oid: the
494
- * occurrence that keeps the bare address, and the one every `…#n`
495
- * occurrence's write authority is decided against.
496
- */
497
- export interface SourceOidIdentity extends ThreeIdentity {
498
- readonly instanceRoots: ReadonlyMap<string, number>;
499
- readonly representatives: ReadonlyMap<string, THREE.Object3D>;
500
- }
501
-
502
- export function sourceOidIdentity(worldId: string): SourceOidIdentity {
503
- let instanceRoots: ReadonlyMap<string, number> = new Map();
504
- let representatives: ReadonlyMap<string, THREE.Object3D> = new Map();
505
- return {
506
- get instanceRoots() {
507
- return instanceRoots;
508
- },
509
- get representatives() {
510
- return representatives;
511
- },
512
- project: (scene) => {
513
- const oidOccurrence = new Map<string, number>();
514
- const roots = new Map<string, number>();
515
- const reps = new Map<string, THREE.Object3D>();
516
- /** Authoring identities of the objects ON the current DFS path. */
517
- const ancestorIdentities = new Set<string>();
518
- const openedBy = new Map<THREE.Object3D, string>();
519
- const projection = walkThreeGraph(scene, {
520
- identify: (object, context) => {
521
- const oid = authoringOidOf(object);
522
- if (oid === undefined) return threeEntityId(worldId, `@${context.path.join('.')}`);
523
- const n = oidOccurrence.get(oid) ?? 0;
524
- oidOccurrence.set(oid, n + 1);
525
- if (n === 0) reps.set(oid, object);
526
- if (!ancestorIdentities.has(oid)) {
527
- roots.set(oid, (roots.get(oid) ?? 0) + 1);
528
- ancestorIdentities.add(oid);
529
- openedBy.set(object, oid);
530
- }
531
- return occurrenceId(threeEntityId(worldId, oid), n);
532
- },
533
- leave: (object) => {
534
- const opened = openedBy.get(object);
535
- if (opened === undefined) return;
536
- ancestorIdentities.delete(opened);
537
- openedBy.delete(object);
538
- },
539
- });
540
- instanceRoots = roots;
541
- representatives = reps;
542
- return projection;
543
- },
544
- };
545
- }
546
-
547
197
  /**
548
198
  * The DOCUMENT-PATH identity, for a native Object3D graph opened as a document
549
199
  * (a loaded model, a project source graph): document-scoped structural ids over
@@ -580,10 +230,7 @@ export function nativeKindOf(object: THREE.Object3D): EditorNode['kind'] {
580
230
  if ((object as THREE.Bone).isBone) return 'bone';
581
231
  if ((object as THREE.Light).isLight) return 'light';
582
232
  if ((object as THREE.Camera).isCamera) return 'camera';
583
- // Three tags its audio nodes only through `type` — there is no `isAudio`
584
- // flag to read like the lines above, and an `Audio`/`AudioListener` carries
585
- // no geometry, so without this it fell through to the generic `object`
586
- // circle and a world's sound was the one thing the hierarchy could not name.
233
+ // Audio nodes expose their kind through type rather than an isAudio flag or geometry.
587
234
  if (
588
235
  object.type === 'Audio' ||
589
236
  object.type === 'PositionalAudio' ||
@@ -886,13 +533,13 @@ export class ThreeProjector {
886
533
  private pickProjection():
887
534
  | {
888
535
  objects: Iterable<THREE.Object3D>;
889
- idForObject3D: (object: THREE.Object3D) => string | null;
536
+ idForNativeObject: (object: THREE.Object3D) => string | null;
890
537
  }
891
538
  | undefined {
892
539
  if (this.options.pickScope === 'store-object-map') return undefined;
893
540
  return {
894
541
  objects: this.objectsById.values(),
895
- idForObject3D: (object) => this.idOf(object),
542
+ idForNativeObject: (object) => this.idOf(object),
896
543
  };
897
544
  }
898
545
  }
@@ -0,0 +1,91 @@
1
+ interface DisposableSource {
2
+ dispose(): void;
3
+ }
4
+
5
+ export interface RetainedDocumentState {
6
+ readonly documentId: string;
7
+ inUse: boolean;
8
+ source: DisposableSource | null;
9
+ }
10
+
11
+ /** CPU state belongs to an open document, not to the lifetime of its last pane. */
12
+ export class RetainedDocumentStates<T extends RetainedDocumentState> {
13
+ private readonly states = new Map<string, T[]>();
14
+ private readonly generations = new WeakMap<T, number>();
15
+ private unsubscribe: (() => void) | null = null;
16
+
17
+ constructor(
18
+ private readonly create: (documentId: string) => T,
19
+ private readonly isOpen: (documentId: string) => boolean,
20
+ private readonly subscribe: (changed: () => void) => () => void,
21
+ ) {}
22
+
23
+ claim(documentId: string): T {
24
+ const states = this.states.get(documentId) ?? [];
25
+ let state = states.find(candidate => !candidate.inUse);
26
+ if (!state) {
27
+ state = this.create(documentId);
28
+ states.push(state);
29
+ this.states.set(documentId, states);
30
+ }
31
+ state.inUse = true;
32
+ this.generations.set(state, this.generation(state) + 1);
33
+ this.unsubscribe ??= this.subscribe(() => this.discardClosed());
34
+ return state;
35
+ }
36
+
37
+ generation(state: T): number {
38
+ return this.generations.get(state) ?? 0;
39
+ }
40
+
41
+ release(state: T, generation = this.generation(state)): void {
42
+ if (this.generation(state) !== generation) return;
43
+ state.inUse = false;
44
+ // React cleanup may precede the registry's close notification. The
45
+ // subscription also handles closing a pane that was already unmounted.
46
+ queueMicrotask(() => this.discardClosed());
47
+ }
48
+
49
+ /** A gesture may finish releasing its source after its document has closed
50
+ * or its state slot has been claimed by a replacement pane. */
51
+ retain(state: T, source: NonNullable<T['source']>, generation: number): void {
52
+ if (
53
+ !this.isOpen(state.documentId) ||
54
+ !this.states.get(state.documentId)?.includes(state) ||
55
+ this.generation(state) !== generation
56
+ ) {
57
+ source.dispose();
58
+ return;
59
+ }
60
+ const previous = state.source;
61
+ state.source = source;
62
+ if (previous !== source) previous?.dispose();
63
+ }
64
+
65
+ private discardClosed(): void {
66
+ const discarded: T[] = [];
67
+ for (const [documentId, states] of this.states) {
68
+ if (this.isOpen(documentId)) continue;
69
+ const remaining = states.filter(state => state.inUse);
70
+ discarded.push(...states.filter(state => !state.inUse));
71
+ if (remaining.length) this.states.set(documentId, remaining);
72
+ else this.states.delete(documentId);
73
+ }
74
+ if (this.states.size === 0) {
75
+ this.unsubscribe?.();
76
+ this.unsubscribe = null;
77
+ }
78
+ // Remove ownership first, even if a disposer throws or publishes a change.
79
+ const failures: unknown[] = [];
80
+ for (const state of discarded) {
81
+ const source = state.source;
82
+ state.source = null;
83
+ try {
84
+ source?.dispose();
85
+ } catch (error) {
86
+ failures.push(error);
87
+ }
88
+ }
89
+ if (failures.length) throw new AggregateError(failures, 'Retained document cleanup failed');
90
+ }
91
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * THE SCENE'S VIEWPORT (`@volter/sdk/kit/document-viewports`): the world
2
+ * THE SCENE'S VIEWPORT (`@volter/sdk/document-viewports`): the world
3
3
  * stage draws the Scene through the session store's own camera, shading and
4
4
  * grid, and frames the shell's selection. It keeps no selection of its own, so a
5
5
  * view's selection is the shell's.
6
6
  */
7
- import type { DocumentViewport } from '@volter/sdk/kit/document-viewports';
7
+ import type { DocumentViewport } from '@volter/sdk/document-viewports';
8
8
  import type { EditorShellStore } from './editor-shell-store';
9
- import { viewGridVisible } from '@volter/sdk/kit/viewport-presentation';
9
+ import { viewGridVisible } from '@volter/sdk/viewport-presentation';
10
10
 
11
11
  type ShadingMode = Parameters<EditorShellStore['setShadingMode']>[0];
12
12