@fps-games/editor 0.1.1-beta.9 → 0.1.2-beta.2

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 (111) hide show
  1. package/dist/local-editor-harness.d.ts +52 -4
  2. package/dist/local-editor-harness.d.ts.map +1 -1
  3. package/dist/local-editor-harness.js +437 -91
  4. package/dist/local-editor-harness.js.map +1 -1
  5. package/dist/local-editor-scene-render-scheduler.d.ts +33 -0
  6. package/dist/local-editor-scene-render-scheduler.d.ts.map +1 -0
  7. package/dist/local-editor-scene-render-scheduler.js +160 -0
  8. package/dist/local-editor-scene-render-scheduler.js.map +1 -0
  9. package/dist/local-editor-scene-view-interaction-runtime.d.ts +22 -0
  10. package/dist/local-editor-scene-view-interaction-runtime.d.ts.map +1 -0
  11. package/dist/local-editor-scene-view-interaction-runtime.js +80 -0
  12. package/dist/local-editor-scene-view-interaction-runtime.js.map +1 -0
  13. package/dist/local-editor-viewport-render-coordinator.d.ts +17 -0
  14. package/dist/local-editor-viewport-render-coordinator.d.ts.map +1 -0
  15. package/dist/local-editor-viewport-render-coordinator.js +102 -0
  16. package/dist/local-editor-viewport-render-coordinator.js.map +1 -0
  17. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts +2 -0
  18. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts.map +1 -1
  19. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js +9 -3
  20. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js.map +1 -1
  21. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts.map +1 -1
  22. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js +2 -0
  23. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js.map +1 -1
  24. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts +1 -0
  25. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts.map +1 -1
  26. package/node_modules/@fps-games/editor-babylon/dist/index.js +1 -0
  27. package/node_modules/@fps-games/editor-babylon/dist/index.js.map +1 -1
  28. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts +1 -0
  29. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts.map +1 -1
  30. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js +335 -32
  31. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js.map +1 -1
  32. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.d.ts +25 -0
  33. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.d.ts.map +1 -0
  34. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.js +162 -0
  35. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.js.map +1 -0
  36. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts +28 -1
  37. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts.map +1 -1
  38. package/node_modules/@fps-games/editor-babylon/dist/projection.js +169 -25
  39. package/node_modules/@fps-games/editor-babylon/dist/projection.js.map +1 -1
  40. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.d.ts +2 -22
  41. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.d.ts.map +1 -1
  42. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.js +1 -121
  43. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.js.map +1 -1
  44. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts +4 -0
  45. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts.map +1 -1
  46. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js +37 -23
  47. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js.map +1 -1
  48. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts +1 -0
  49. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts.map +1 -1
  50. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js +100 -8
  51. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js.map +1 -1
  52. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.d.ts +6 -1
  53. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.d.ts.map +1 -1
  54. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.js +172 -106
  55. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.js.map +1 -1
  56. package/node_modules/@fps-games/editor-babylon/dist/transform-solver.d.ts +30 -0
  57. package/node_modules/@fps-games/editor-babylon/dist/transform-solver.d.ts.map +1 -0
  58. package/node_modules/@fps-games/editor-babylon/dist/transform-solver.js +120 -0
  59. package/node_modules/@fps-games/editor-babylon/dist/transform-solver.js.map +1 -0
  60. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts +2 -0
  61. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  62. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  63. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts +2 -1
  64. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts.map +1 -1
  65. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js +167 -34
  66. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js.map +1 -1
  67. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts +2 -1
  68. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts.map +1 -1
  69. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js +105 -1
  70. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js.map +1 -1
  71. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.d.ts +3 -1
  72. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.d.ts.map +1 -1
  73. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js +49 -4
  74. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js.map +1 -1
  75. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-input-router.d.ts +4 -1
  76. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-input-router.d.ts.map +1 -1
  77. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-input-router.js +12 -3
  78. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-input-router.js.map +1 -1
  79. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts +1 -0
  80. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts.map +1 -1
  81. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js +96 -14
  82. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js.map +1 -1
  83. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js +1 -1
  84. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js.map +1 -1
  85. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.d.ts.map +1 -1
  86. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js +7 -6
  87. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js.map +1 -1
  88. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js +1 -1
  89. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +59 -2
  90. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  91. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts +1 -0
  92. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts.map +1 -1
  93. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js +3 -0
  94. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js.map +1 -1
  95. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +5 -2
  96. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  97. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +260 -40
  98. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  99. package/node_modules/@fps-games/editor-browser/package.json +2 -2
  100. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts +7 -0
  101. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts.map +1 -1
  102. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.d.ts +22 -0
  103. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.d.ts.map +1 -1
  104. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.js.map +1 -1
  105. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts +6 -6
  106. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js +3 -3
  107. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js.map +1 -1
  108. package/node_modules/@fps-games/editor-core/package.json +2 -2
  109. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  110. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  111. package/package.json +6 -6
@@ -1,6 +1,9 @@
1
1
  import { createEditorSession, createInspectorRegistry, DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS, DEFAULT_EDITOR_VIEWPORT_TOOL_STATE, createEmptyEditorViewportSpatialOverlayState, cloneEditorViewportToolState, cloneEditorViewportSpatialOverlayState, createInspectorEditPayload, computeEditorTransformActionTargets, mergeInspectorSections, normalizeEditorTransformConstraint, resolveEditorSelectionCommand, serializedMultiObjectToInspectorObject, serializedObjectToInspectorObject, validateSceneGraphDelete, validateSceneGraphDrop, validateSceneGraphGroupSelection, validateSceneGraphMove, validateSceneGraphRename, } from '@fps-games/editor-core';
2
2
  import { createLocalEditorBrowserUi, } from '@fps-games/editor-browser';
3
- import { createBabylonEditorProjection, createBabylonEditorWorld, createBabylonProjectionSelectionController, createBabylonSceneCameraPreviewController, createBabylonSceneViewCameraController, createBabylonSceneViewInputController, createBabylonSceneViewMeasurementController, createBabylonSceneViewSpatialOverlayController, createBabylonTransformGizmoController, focusEditorViewportSelection, } from '@fps-games/editor-babylon';
3
+ import { createBabylonEditorProjection, createBabylonEditorWorld, createBabylonProjectionSelectionController, createBabylonMainCameraPreviewController, createBabylonSceneViewCameraController, createBabylonSceneViewInputController, createBabylonSceneViewMeasurementController, createBabylonSceneViewSpatialOverlayController, createBabylonTransformGizmoController, focusEditorViewportSelection, } from '@fps-games/editor-babylon';
4
+ import { createLocalEditorSceneRenderScheduler, } from './local-editor-scene-render-scheduler';
5
+ import { createLocalEditorViewportRenderCoordinator, } from './local-editor-viewport-render-coordinator';
6
+ import { createLocalEditorSceneViewInteractionRuntime, } from './local-editor-scene-view-interaction-runtime';
4
7
  export function createLocalEditorHarness(options) {
5
8
  const root = options.root ?? document.body;
6
9
  const state = {
@@ -17,10 +20,11 @@ export function createLocalEditorHarness(options) {
17
20
  gridVisible: true,
18
21
  projection: null,
19
22
  gizmo: null,
20
- sceneCameraPreview: null,
21
- sceneCameraPreviewEnabled: false,
23
+ mainCameraPreview: null,
24
+ mainCameraPreviewEnabled: false,
22
25
  sceneViewInput: null,
23
26
  sceneViewCamera: null,
27
+ sceneViewInteraction: null,
24
28
  sceneViewMeasurement: null,
25
29
  sceneViewSpatialOverlay: null,
26
30
  selectionController: null,
@@ -34,6 +38,9 @@ export function createLocalEditorHarness(options) {
34
38
  viewportSpatialOverlay: createEmptyEditorViewportSpatialOverlayState(),
35
39
  duplicateDrag: null,
36
40
  armedPlacement: null,
41
+ sceneRenderScheduler: null,
42
+ viewportRenderCoordinator: null,
43
+ sceneFrameStats: null,
37
44
  resizeHandler: null,
38
45
  status: 'Game running',
39
46
  statusTone: 'default',
@@ -46,7 +53,13 @@ export function createLocalEditorHarness(options) {
46
53
  root,
47
54
  theme: options.theme,
48
55
  localTestActions: options.localTestActions,
56
+ input: {
57
+ isShortcutReserved: (event) => state.sceneViewInput?.ownsKeyboardEvent(event) ?? false,
58
+ },
49
59
  inspector: options.inspector,
60
+ hierarchy: {
61
+ contextActions: createBrowserHierarchyContextActions(state, options),
62
+ },
50
63
  callbacks: {
51
64
  onEnterEditor: () => {
52
65
  void runExclusive(state, harness.render, () => harness.enterEditor());
@@ -138,16 +151,19 @@ export function createLocalEditorHarness(options) {
138
151
  state.transformConstraint = normalizeTransformConstraint(tool, state.transformConstraint);
139
152
  state.gizmo?.setTool(tool);
140
153
  state.gizmo?.setConstraint(state.transformConstraint);
154
+ requestEditorSceneFrame(state, 'transform-tool-change');
141
155
  harness.render();
142
156
  },
143
157
  onTransformSpaceChange: (space) => {
144
158
  state.transformSpace = space;
145
159
  state.gizmo?.setSpace(space);
160
+ requestEditorSceneFrame(state, 'transform-space-change');
146
161
  harness.render();
147
162
  },
148
163
  onTransformConstraintChange: (constraint) => {
149
164
  state.transformConstraint = normalizeTransformConstraint(state.transformTool, constraint);
150
165
  state.gizmo?.setConstraint(state.transformConstraint);
166
+ requestEditorSceneFrame(state, 'transform-constraint-change');
151
167
  harness.render();
152
168
  },
153
169
  onTransformSnapEnabledChange: (enabled) => {
@@ -159,6 +175,7 @@ export function createLocalEditorHarness(options) {
159
175
  });
160
176
  state.gizmo?.setOperationSettings(state.transformOperationSettings);
161
177
  state.status = enabled ? 'Transform snap enabled' : 'Transform snap disabled';
178
+ requestEditorSceneFrame(state, 'transform-operation-settings-change');
162
179
  harness.render();
163
180
  },
164
181
  onTransformSnapStepChange: (input) => {
@@ -175,6 +192,7 @@ export function createLocalEditorHarness(options) {
175
192
  state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, { snap });
176
193
  state.gizmo?.setOperationSettings(state.transformOperationSettings);
177
194
  state.status = `Transform snap ${input.kind} step ${value}`;
195
+ requestEditorSceneFrame(state, 'transform-operation-settings-change');
178
196
  harness.render();
179
197
  },
180
198
  onPlacementModeChange: (mode) => {
@@ -185,6 +203,7 @@ export function createLocalEditorHarness(options) {
185
203
  if (state.transformOperationSettings.placementMode === 'off')
186
204
  clearArmedPlacement(state);
187
205
  state.status = `Placement mode: ${state.transformOperationSettings.placementMode}`;
206
+ requestEditorSceneFrame(state, 'placement-mode-change');
188
207
  harness.render();
189
208
  },
190
209
  onTransformAction: (action) => {
@@ -211,8 +230,8 @@ export function createLocalEditorHarness(options) {
211
230
  if (clearViewportMeasurement(state))
212
231
  harness.render();
213
232
  },
214
- onSceneCameraPreviewToggle: (enabled) => {
215
- if (setSceneCameraPreviewEnabled(state, options, enabled))
233
+ onMainCameraPreviewToggle: (enabled) => {
234
+ if (setMainCameraPreviewEnabled(state, options, enabled))
216
235
  harness.render();
217
236
  },
218
237
  onGridVisibleChange: (visible) => {
@@ -229,17 +248,23 @@ export function createLocalEditorHarness(options) {
229
248
  },
230
249
  onCancelActiveOperation: () => {
231
250
  cancelActiveOperation(state);
251
+ requestEditorSceneFrame(state, 'cancel-active-operation');
232
252
  harness.render();
233
253
  },
234
254
  },
235
255
  });
256
+ const renderUi = () => {
257
+ syncViewportCameraState(state);
258
+ syncViewportMeasurementState(state);
259
+ syncViewportSpatialOverlay(state);
260
+ ui.update(createUiState(state, options));
261
+ };
236
262
  harness = {
237
263
  render() {
238
- syncSceneCameraPreview(state, options);
239
- syncViewportCameraState(state);
240
- syncViewportMeasurementState(state);
241
- syncViewportSpatialOverlay(state);
242
- ui.update(createUiState(state, options));
264
+ renderUi();
265
+ },
266
+ notifyViewportRevealed(reason = 'viewport-revealed') {
267
+ state.viewportRenderCoordinator?.requestRevealFrame(reason);
243
268
  },
244
269
  setTheme(theme) {
245
270
  ui.setTheme(theme);
@@ -307,10 +332,12 @@ export function createLocalEditorHarness(options) {
307
332
  reduceDocument: options.documentAdapter.reduceDocument,
308
333
  });
309
334
  await options.worldAdapter.disposeGameWorld();
310
- await createEditorWorld(state, options, harness.render);
335
+ await createEditorWorld(state, options, harness.render, stats => ui.updateSceneFrameStats?.(stats));
311
336
  state.mode = 'editor';
312
337
  state.summary = loadedSource?.summary ?? summarizeDocument(options, preparedDocument, source);
313
338
  state.status = `GameWorld disposed; EditorWorld active; assets=${assets.length}`;
339
+ harness.render();
340
+ harness.notifyViewportRevealed('editor-enter');
314
341
  },
315
342
  async saveScene() {
316
343
  cancelActiveOperation(state);
@@ -485,11 +512,11 @@ function cloneViewportMeasurement(measurement) {
485
512
  };
486
513
  }
487
514
  function setViewportViewPreset(state, preset) {
488
- if (state.sceneCameraPreviewEnabled) {
489
- state.status = 'Viewport view unavailable during Scene Camera preview';
515
+ if (state.mainCameraPreviewEnabled) {
516
+ state.status = 'Viewport view unavailable during Main Camera preview';
490
517
  state.statusTone = 'warning';
491
518
  state.statusToneStatus = state.status;
492
- state.statusDetails = 'Disable Scene Camera preview before switching editor viewport views.';
519
+ state.statusDetails = 'Disable Main Camera preview before switching editor viewport views.';
493
520
  return true;
494
521
  }
495
522
  const nextPreset = normalizeViewportViewPreset(preset);
@@ -508,14 +535,15 @@ function setViewportViewPreset(state, preset) {
508
535
  state.statusTone = 'default';
509
536
  state.statusToneStatus = state.status;
510
537
  state.statusDetails = '';
538
+ requestEditorSceneFrame(state, 'viewport-view-preset-change');
511
539
  return true;
512
540
  }
513
541
  function setViewportProjectionMode(state, mode) {
514
- if (state.sceneCameraPreviewEnabled) {
515
- state.status = 'Viewport projection unavailable during Scene Camera preview';
542
+ if (state.mainCameraPreviewEnabled) {
543
+ state.status = 'Viewport projection unavailable during Main Camera preview';
516
544
  state.statusTone = 'warning';
517
545
  state.statusToneStatus = state.status;
518
- state.statusDetails = 'Disable Scene Camera preview before switching editor viewport projection.';
546
+ state.statusDetails = 'Disable Main Camera preview before switching editor viewport projection.';
519
547
  return true;
520
548
  }
521
549
  const nextMode = normalizeViewportProjectionMode(mode);
@@ -531,6 +559,7 @@ function setViewportProjectionMode(state, mode) {
531
559
  state.statusTone = 'default';
532
560
  state.statusToneStatus = state.status;
533
561
  state.statusDetails = '';
562
+ requestEditorSceneFrame(state, 'viewport-projection-mode-change');
534
563
  return true;
535
564
  }
536
565
  function setViewportOverlaySettings(state, settings) {
@@ -682,7 +711,7 @@ function validateTransformActionSelection(state, action) {
682
711
  ? { ok: true }
683
712
  : { ok: false, message: 'Distribute needs at least 3 selected objects' };
684
713
  }
685
- async function createEditorWorld(state, options, render) {
714
+ async function createEditorWorld(state, options, renderUi, updateSceneFrameStats) {
686
715
  disposeEditorWorld(state);
687
716
  const canvas = options.worldAdapter.getCanvas();
688
717
  if (!canvas)
@@ -700,13 +729,39 @@ async function createEditorWorld(state, options, render) {
700
729
  useRightHandedSystem: options.world?.useRightHandedSystem,
701
730
  enableDefaultCameraControls: false,
702
731
  });
703
- const grid = options.createGrid?.(babylon, world.scene) ?? null;
732
+ const sceneRenderScheduler = createLocalEditorSceneRenderScheduler((frame) => {
733
+ state.sceneViewInteraction?.updateFrame(frame);
734
+ world.render();
735
+ }, {
736
+ onStatsChange(stats) {
737
+ state.sceneFrameStats = stats;
738
+ updateSceneFrameStats(stats);
739
+ },
740
+ });
741
+ const viewportRenderCoordinator = createLocalEditorViewportRenderCoordinator({
742
+ scheduler: sceneRenderScheduler,
743
+ getEngine: () => engine,
744
+ getScene: () => world.scene,
745
+ });
746
+ const sceneViewInteraction = createLocalEditorSceneViewInteractionRuntime({
747
+ coordinator: viewportRenderCoordinator,
748
+ getCamera: () => state.sceneViewCamera,
749
+ });
750
+ state.sceneViewInteraction = sceneViewInteraction;
751
+ const grid = options.createGrid?.(babylon, world.scene, world.camera, {
752
+ getCamera: () => world.scene?.activeCamera ?? world.camera ?? null,
753
+ getEditorCamera: () => world.camera ?? null,
754
+ }) ?? null;
704
755
  grid?.setVisible(state.gridVisible);
705
756
  const projection = createBabylonEditorProjection({
706
757
  babylon,
707
758
  scene: world.scene,
708
759
  importModel: options.worldAdapter.importProjectionModel,
709
760
  logger: console,
761
+ onProjectionReady(event) {
762
+ syncCurrentSelectionToSceneArtifacts(state);
763
+ viewportRenderCoordinator.invalidateScene(`projection-${event.nodeId}-ready`);
764
+ },
710
765
  });
711
766
  const gizmo = createBabylonTransformGizmoController({
712
767
  babylon,
@@ -716,27 +771,43 @@ async function createEditorWorld(state, options, render) {
716
771
  initialSpace: state.transformSpace,
717
772
  logger: console,
718
773
  onDragStart(event) {
774
+ sceneViewInteraction.beginGizmoDrag();
719
775
  state.status = event.targetIds.length > 1
720
776
  ? `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.targetIds.length} objects`
721
777
  : `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
722
- render();
778
+ renderUi();
723
779
  },
724
780
  onDragUpdate() {
725
- render();
781
+ renderUi();
726
782
  },
727
783
  onDragEnd(event) {
728
- commitGizmoTransform(state, options, event);
729
- render();
784
+ sceneViewInteraction.endGizmoDrag();
785
+ const changed = commitGizmoTransform(state, options, event);
786
+ if (!changed)
787
+ requestEditorSceneFrame(state, 'gizmo-drag-end');
788
+ renderUi();
730
789
  },
731
790
  onDragCancel(event) {
791
+ sceneViewInteraction.endGizmoDrag();
732
792
  if (event.duplicate && cancelDuplicateDrag(state, options)) {
733
- render();
793
+ renderUi();
734
794
  return;
735
795
  }
796
+ requestEditorSceneFrame(state, 'gizmo-drag-cancel');
736
797
  state.status = event.targetIds.length > 1
737
798
  ? `Canceled ${event.tool} ${event.targetIds.length} objects`
738
799
  : `Canceled ${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
739
- render();
800
+ renderUi();
801
+ },
802
+ onDragBlocked(event) {
803
+ sceneViewInteraction.endGizmoDrag();
804
+ if (event.duplicate)
805
+ cancelDuplicateDrag(state, options);
806
+ requestEditorSceneFrame(state, 'gizmo-drag-blocked');
807
+ state.status = formatBlockedGizmoTransformStatus(event);
808
+ state.statusTone = 'warning';
809
+ state.statusToneStatus = state.status;
810
+ renderUi();
740
811
  },
741
812
  onDuplicateDragStart(input) {
742
813
  return beginDuplicateDrag(state, options, input);
@@ -755,15 +826,15 @@ async function createEditorWorld(state, options, render) {
755
826
  isOperationBlocked: () => state.gizmo?.getState().dragPhase === 'dragging',
756
827
  onSelectionCommand(command) {
757
828
  if (dispatchSelectionCommand(state, options, command))
758
- render();
829
+ renderUi();
759
830
  },
760
831
  onFocusIntent(nodeId) {
761
832
  if (focusProjectionNode(state, nodeId))
762
- render();
833
+ renderUi();
763
834
  },
764
835
  onBoxSelectionChange(box) {
765
836
  state.boxSelection = box;
766
- render();
837
+ renderUi();
767
838
  },
768
839
  });
769
840
  const sceneViewMeasurement = createBabylonSceneViewMeasurementController({
@@ -779,9 +850,10 @@ async function createEditorWorld(state, options, render) {
779
850
  isPlacementCandidate: () => isPlacementArmed(state),
780
851
  isMeasurementCandidate: () => state.viewportTools.activeUtilityTool === 'measure-distance',
781
852
  onPointerIntentStart(event) {
853
+ sceneViewInteraction.beginPointerIntent(event.state.intent);
782
854
  if (event.state.intent === 'measurement') {
783
855
  if (handleViewportMeasurementStart(state, event.originalEvent))
784
- render();
856
+ renderUi();
785
857
  return;
786
858
  }
787
859
  if (event.state.intent === 'gizmo-drag') {
@@ -790,88 +862,104 @@ async function createEditorWorld(state, options, render) {
790
862
  }
791
863
  if (event.state.intent === 'placement') {
792
864
  if (previewArmedPlacement(state, event.originalEvent))
793
- render();
865
+ renderUi();
794
866
  return;
795
867
  }
796
868
  if (event.state.intent === 'view-plane-move') {
797
869
  if (gizmo.beginViewPlaneMove(event.originalEvent))
798
- render();
870
+ renderUi();
799
871
  return;
800
872
  }
801
873
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
802
874
  selectionController.beginPointerSelection(event.originalEvent);
875
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}-start`);
803
876
  }
804
877
  },
805
878
  onPointerIntentMove(event) {
806
879
  if (event.state.intent === 'measurement') {
807
880
  if (handleViewportMeasurementMove(state, event.originalEvent))
808
- render();
881
+ renderUi();
809
882
  return;
810
883
  }
811
884
  if (event.state.intent === 'placement') {
812
885
  if (previewArmedPlacement(state, event.originalEvent))
813
- render();
886
+ renderUi();
814
887
  return;
815
888
  }
816
889
  if (event.state.intent === 'view-plane-move') {
817
890
  if (gizmo.updateViewPlaneMove(event.originalEvent))
818
- render();
891
+ renderUi();
819
892
  return;
820
893
  }
821
- if (!state.sceneCameraPreviewEnabled && state.sceneViewCamera?.handlePointerIntentMove(event)) {
822
- render();
894
+ if (!state.mainCameraPreviewEnabled && state.sceneViewCamera?.handlePointerIntentMove(event)) {
895
+ renderUi();
823
896
  return;
824
897
  }
825
898
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
826
899
  selectionController.updatePointerSelection(event.originalEvent, event.state.intent);
827
900
  }
901
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}`);
828
902
  },
829
903
  onPointerIntentEnd(event) {
904
+ sceneViewInteraction.endPointerIntent(event.state.intent);
830
905
  if (event.state.intent === 'measurement') {
831
906
  if (handleViewportMeasurementEnd(state))
832
- render();
907
+ renderUi();
833
908
  return;
834
909
  }
835
910
  if (event.state.intent === 'placement') {
836
911
  if (commitArmedPlacement(state, options, event.originalEvent))
837
- render();
912
+ renderUi();
838
913
  return;
839
914
  }
840
915
  if (event.state.intent === 'view-plane-move') {
841
- if (gizmo.endViewPlaneMove(event.originalEvent))
842
- render();
916
+ if (gizmo.endViewPlaneMove(event.originalEvent)) {
917
+ requestEditorSceneFrame(state, 'view-plane-move-end');
918
+ renderUi();
919
+ }
843
920
  return;
844
921
  }
845
922
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
846
923
  selectionController.endPointerSelection(event.originalEvent, event.state.intent);
847
924
  }
925
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}-end`);
848
926
  },
849
927
  onPointerIntentCancel(event) {
928
+ sceneViewInteraction.cancelPointerIntent(event.state.intent);
850
929
  if (event.state.intent === 'measurement') {
851
- render();
930
+ renderUi();
852
931
  return;
853
932
  }
854
933
  if (event.state.intent === 'placement') {
855
934
  state.gizmo?.setPlacementMarker(null);
856
- render();
935
+ requestEditorSceneFrame(state, 'placement-marker-clear');
936
+ renderUi();
857
937
  return;
858
938
  }
859
939
  if (event.state.intent === 'view-plane-move') {
860
940
  gizmo.cancelDrag();
861
- render();
941
+ requestEditorSceneFrame(state, 'view-plane-move-cancel');
942
+ renderUi();
862
943
  return;
863
944
  }
864
945
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
865
946
  selectionController.cancelBoxSelection();
866
- render();
947
+ renderUi();
867
948
  }
949
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}-cancel`);
868
950
  },
869
951
  onDoubleClick(event) {
870
952
  selectionController.handleDoubleClick(event);
871
953
  },
872
954
  onWheel(event) {
873
- if (!state.sceneCameraPreviewEnabled && state.sceneViewCamera?.handleWheel(event))
874
- render();
955
+ if (!state.mainCameraPreviewEnabled && state.sceneViewCamera?.handleWheel(event)) {
956
+ requestEditorSceneFrame(state, 'camera-wheel');
957
+ renderUi();
958
+ }
959
+ },
960
+ onMovementKeysChange(event) {
961
+ sceneViewInteraction.updateMovementKeys(event.pressedMovementKeys);
962
+ renderUi();
875
963
  },
876
964
  });
877
965
  const sceneViewCamera = createBabylonSceneViewCameraController({
@@ -880,7 +968,7 @@ async function createEditorWorld(state, options, render) {
880
968
  camera: world.camera,
881
969
  input: sceneViewInput,
882
970
  });
883
- const sceneCameraPreview = createBabylonSceneCameraPreviewController({
971
+ const mainCameraPreview = createBabylonMainCameraPreviewController({
884
972
  babylon,
885
973
  scene: world.scene,
886
974
  editorCamera: world.camera,
@@ -896,31 +984,44 @@ async function createEditorWorld(state, options, render) {
896
984
  projection.syncSelection(selection);
897
985
  gizmo.setSelection(selection);
898
986
  }
899
- const resize = () => engine.resize?.();
987
+ const resize = () => {
988
+ engine.resize?.();
989
+ viewportRenderCoordinator.requestFrame('resize');
990
+ };
900
991
  window.addEventListener('resize', resize);
901
- engine.runRenderLoop?.(() => world.render());
902
992
  state.babylon = babylon;
903
993
  state.engine = engine;
904
994
  state.world = world;
905
995
  state.grid = grid;
906
996
  state.projection = projection;
907
997
  state.gizmo = gizmo;
908
- state.sceneCameraPreview = sceneCameraPreview;
998
+ state.mainCameraPreview = mainCameraPreview;
909
999
  state.sceneViewInput = sceneViewInput;
910
1000
  state.sceneViewCamera = sceneViewCamera;
911
1001
  state.sceneViewMeasurement = sceneViewMeasurement;
912
1002
  state.sceneViewSpatialOverlay = sceneViewSpatialOverlay;
913
1003
  state.selectionController = selectionController;
1004
+ state.sceneRenderScheduler = sceneRenderScheduler;
1005
+ state.viewportRenderCoordinator = viewportRenderCoordinator;
1006
+ state.sceneFrameStats = sceneRenderScheduler.getStats();
914
1007
  state.resizeHandler = resize;
1008
+ await sceneRenderScheduler.waitForNextFrame('editor-world-created');
915
1009
  }
916
1010
  function disposeEditorWorld(state) {
1011
+ state.sceneViewInteraction?.dispose();
1012
+ state.sceneViewInteraction = null;
1013
+ state.viewportRenderCoordinator?.dispose();
1014
+ state.viewportRenderCoordinator = null;
1015
+ state.sceneRenderScheduler?.dispose();
1016
+ state.sceneRenderScheduler = null;
1017
+ state.sceneFrameStats = null;
917
1018
  if (state.resizeHandler) {
918
1019
  window.removeEventListener('resize', state.resizeHandler);
919
1020
  state.resizeHandler = null;
920
1021
  }
921
- state.sceneCameraPreview?.dispose();
922
- state.sceneCameraPreview = null;
923
- state.sceneCameraPreviewEnabled = false;
1022
+ state.mainCameraPreview?.dispose();
1023
+ state.mainCameraPreview = null;
1024
+ state.mainCameraPreviewEnabled = false;
924
1025
  state.sceneViewCamera?.dispose();
925
1026
  state.sceneViewCamera = null;
926
1027
  state.sceneViewMeasurement?.dispose();
@@ -944,18 +1045,20 @@ function disposeEditorWorld(state) {
944
1045
  state.projection = null;
945
1046
  state.grid?.dispose();
946
1047
  state.grid = null;
947
- state.engine?.stopRenderLoop?.();
948
1048
  state.world?.dispose();
949
1049
  state.engine?.dispose?.();
950
1050
  state.babylon = null;
951
1051
  state.world = null;
952
1052
  state.engine = null;
953
1053
  }
954
- async function runExclusive(state, render, action) {
1054
+ function requestEditorSceneFrame(state, reason) {
1055
+ state.viewportRenderCoordinator?.requestFrame(reason);
1056
+ }
1057
+ async function runExclusive(state, renderUi, action) {
955
1058
  if (state.busy)
956
1059
  return;
957
1060
  state.busy = true;
958
- render();
1061
+ renderUi();
959
1062
  try {
960
1063
  await action();
961
1064
  }
@@ -968,7 +1071,7 @@ async function runExclusive(state, render, action) {
968
1071
  }
969
1072
  finally {
970
1073
  state.busy = false;
971
- render();
1074
+ renderUi();
972
1075
  }
973
1076
  }
974
1077
  function selectItem(state, options, input) {
@@ -988,9 +1091,92 @@ function selectItem(state, options, input) {
988
1091
  return false;
989
1092
  return dispatchSelectionCommand(state, options, command);
990
1093
  }
1094
+ function createBrowserHierarchyContextActions(state, options) {
1095
+ return (options.hierarchy?.contextActions ?? []).map((registration) => ({
1096
+ id: registration.id,
1097
+ label: registration.label,
1098
+ shortcut: registration.shortcut,
1099
+ danger: registration.danger,
1100
+ placement: registration.placement,
1101
+ separatorBefore: registration.separatorBefore,
1102
+ visible: (browserContext) => {
1103
+ const context = createHarnessHierarchyContextActionContext(state, options, browserContext);
1104
+ if (!context)
1105
+ return false;
1106
+ return registration.visible?.(context) ?? true;
1107
+ },
1108
+ disabled: (browserContext) => {
1109
+ const context = createHarnessHierarchyContextActionContext(state, options, browserContext);
1110
+ if (!context)
1111
+ return 'No editable document is loaded.';
1112
+ return registration.disabled?.(context) ?? false;
1113
+ },
1114
+ }));
1115
+ }
1116
+ function createHarnessHierarchyContextActionContext(state, options, browserContext, payload) {
1117
+ const document = state.session?.getState().workingDocument ?? null;
1118
+ if (!document)
1119
+ return null;
1120
+ const activeId = browserContext.activeId;
1121
+ return {
1122
+ document,
1123
+ contextNodeId: browserContext.contextNodeId,
1124
+ targetIds: browserContext.targetIds,
1125
+ activeId,
1126
+ hierarchyItem: browserContext.node,
1127
+ projectionNode: browserContext.contextNodeId
1128
+ ? options.documentAdapter.getProjectionNode(document, browserContext.contextNodeId)
1129
+ : null,
1130
+ hostServices: options.hostServices ?? null,
1131
+ payload,
1132
+ browserContext,
1133
+ };
1134
+ }
1135
+ function createBrowserContextForCustomAction(state, options, action) {
1136
+ const document = state.session?.getState().workingDocument ?? null;
1137
+ if (!document)
1138
+ return null;
1139
+ const uiState = createUiState(state, options);
1140
+ const node = action.contextNodeId
1141
+ ? uiState.hierarchy.find(item => item.id === action.contextNodeId) ?? null
1142
+ : null;
1143
+ return {
1144
+ state: uiState,
1145
+ menuKind: node ? 'node' : 'blank',
1146
+ node,
1147
+ contextNodeId: action.contextNodeId,
1148
+ targetIds: action.targetIds,
1149
+ activeId: action.activeId,
1150
+ };
1151
+ }
1152
+ function runHierarchyCustomContextAction(state, options, action) {
1153
+ const registration = options.hierarchy?.contextActions?.find(candidate => candidate.id === action.id);
1154
+ if (!registration)
1155
+ return false;
1156
+ const browserContext = createBrowserContextForCustomAction(state, options, action);
1157
+ const context = browserContext
1158
+ ? createHarnessHierarchyContextActionContext(state, options, browserContext, action.payload)
1159
+ : null;
1160
+ if (!context)
1161
+ return false;
1162
+ try {
1163
+ return registration.run(context) === true;
1164
+ }
1165
+ catch (error) {
1166
+ const message = error instanceof Error ? error.message : String(error);
1167
+ state.status = `${registration.label} failed`;
1168
+ state.statusTone = 'error';
1169
+ state.statusToneStatus = state.status;
1170
+ state.statusDetails = message;
1171
+ console.error('[LocalEditorHarness] hierarchy context action failed', error);
1172
+ return true;
1173
+ }
1174
+ }
991
1175
  function handleContextAction(state, options, action) {
992
1176
  if (action.region !== 'hierarchy')
993
1177
  return false;
1178
+ if (action.action === 'custom')
1179
+ return runHierarchyCustomContextAction(state, options, action);
994
1180
  if (action.action === 'focus') {
995
1181
  const activeId = action.activeId ?? action.targetIds[action.targetIds.length - 1] ?? null;
996
1182
  const selectionChanged = activeId && !state.session?.getState().selection.selectedIds.includes(activeId)
@@ -1399,6 +1585,16 @@ function syncSelectionToProjection(state, selection) {
1399
1585
  state.projection?.syncSelection(selection);
1400
1586
  state.gizmo?.setSelection(selection);
1401
1587
  state.gizmo?.refreshSelection();
1588
+ requestEditorSceneFrame(state, 'projection-selection');
1589
+ }
1590
+ function syncCurrentSelectionToSceneArtifacts(state) {
1591
+ syncSelectionToProjection(state, state.session?.getState().selection ?? { selectedIds: [], activeId: null });
1592
+ }
1593
+ function invalidateEditorScene(state, reason, options) {
1594
+ if (options && state.mainCameraPreviewEnabled) {
1595
+ syncMainCameraPreview(state, options);
1596
+ }
1597
+ state.viewportRenderCoordinator?.invalidateScene(reason);
1402
1598
  }
1403
1599
  function executeTransformAction(state, options, action) {
1404
1600
  if (state.mode !== 'editor' || !state.session)
@@ -1501,11 +1697,12 @@ function armAssetPlacement(state, options, assetId) {
1501
1697
  if (state.mode !== 'editor')
1502
1698
  return false;
1503
1699
  cancelActiveOperation(state);
1504
- const asset = state.assets.find(candidate => resolveAssetId(options, candidate) === assetId);
1700
+ const asset = findAssetByResolvedId(state, options, assetId);
1505
1701
  if (!asset)
1506
1702
  return false;
1507
1703
  state.armedPlacement = { assetId, asset };
1508
1704
  state.gizmo?.setPlacementMarker(null);
1705
+ requestEditorSceneFrame(state, 'placement-arm-change');
1509
1706
  state.status = `Placement armed: ${formatAssetLabel(asset, assetId)} (${state.transformOperationSettings.placementMode})`;
1510
1707
  return true;
1511
1708
  }
@@ -1535,6 +1732,7 @@ function commitArmedPlacement(state, options, event) {
1535
1732
  if (!armed || !hit) {
1536
1733
  state.gizmo?.setPlacementMarker(null);
1537
1734
  state.status = 'Placement rejected: no hit';
1735
+ requestEditorSceneFrame(state, 'placement-marker-clear');
1538
1736
  return true;
1539
1737
  }
1540
1738
  const patch = options.documentAdapter.createPlacedAssetPatch?.({
@@ -1584,8 +1782,11 @@ function pickArmedPlacementHit(state, event) {
1584
1782
  : state.gizmo?.pickPlacementHit(event.clientX, event.clientY, mode) ?? null;
1585
1783
  }
1586
1784
  function clearArmedPlacement(state) {
1785
+ const hadArmedPlacement = !!state.armedPlacement;
1587
1786
  state.armedPlacement = null;
1588
1787
  state.gizmo?.setPlacementMarker(null);
1788
+ if (hadArmedPlacement)
1789
+ requestEditorSceneFrame(state, 'placement-clear');
1589
1790
  }
1590
1791
  function addAssetToDocument(state, options, assetId, createOptions = {}) {
1591
1792
  if (state.mode !== 'editor') {
@@ -1599,7 +1800,7 @@ function addAssetToDocument(state, options, assetId, createOptions = {}) {
1599
1800
  state.status = 'Asset creation rejected: document is not loaded';
1600
1801
  return { ok: false, assetId, changed: false, status: state.status, error: 'document_not_loaded' };
1601
1802
  }
1602
- const asset = state.assets.find(candidate => resolveAssetId(options, candidate) === assetId);
1803
+ const asset = findAssetByResolvedId(state, options, assetId);
1603
1804
  if (!asset) {
1604
1805
  state.status = `Asset creation rejected: ${assetId} not found`;
1605
1806
  return { ok: false, assetId, changed: false, status: state.status, error: 'asset_not_found' };
@@ -1636,6 +1837,7 @@ function addAssetToDocument(state, options, assetId, createOptions = {}) {
1636
1837
  state.projection?.projectNode(projectedNode);
1637
1838
  if (selectionResult)
1638
1839
  syncSelectionToProjection(state, selectionResult.selection);
1840
+ invalidateEditorScene(state, 'projection-project-node', options);
1639
1841
  }
1640
1842
  }
1641
1843
  return {
@@ -1928,6 +2130,22 @@ function restoreBatchTransformPreview(state, targets) {
1928
2130
  transforms[target.id] = target.before;
1929
2131
  state.projection?.setNodeTransformsPreview(transforms);
1930
2132
  }
2133
+ function formatBlockedGizmoTransformStatus(event) {
2134
+ const target = event.targetIds.length > 1
2135
+ ? `${event.targetIds.length} objects`
2136
+ : event.nodeId ?? event.activeId ?? 'selection';
2137
+ const failed = event.failedTargetId && event.targetIds.length > 1
2138
+ ? ` at ${event.failedTargetId}`
2139
+ : '';
2140
+ return `Blocked ${event.tool} ${target}${failed}: ${formatTransformOperationBlockReason(event.reason)}`;
2141
+ }
2142
+ function formatTransformOperationBlockReason(reason) {
2143
+ if (reason === 'non-trs-representable')
2144
+ return 'result cannot be represented as position/rotation/scale';
2145
+ if (reason === 'non-invertible-parent')
2146
+ return 'parent transform cannot be inverted';
2147
+ return 'unsupported transform';
2148
+ }
1931
2149
  function cancelActiveGizmoDrag(state) {
1932
2150
  state.gizmo?.cancelDrag();
1933
2151
  }
@@ -1976,6 +2194,8 @@ function focusProjectionNode(state, nodeId) {
1976
2194
  state.status = focused
1977
2195
  ? `Focused ${nodeId} · ${formatEditorStatusTime(Date.now())}`
1978
2196
  : `Focus failed: camera could not frame ${nodeId}`;
2197
+ if (focused)
2198
+ requestEditorSceneFrame(state, 'viewport-focus-selection');
1979
2199
  return true;
1980
2200
  }
1981
2201
  function formatEditorStatusTime(timestamp) {
@@ -2021,6 +2241,7 @@ function rebuildProjectionFromDocument(state, options, document, selection) {
2021
2241
  state.projection?.rebuild(options.documentAdapter.getProjectionNodes(document));
2022
2242
  const sanitized = sanitizeSelection(state, options, document, selection);
2023
2243
  syncSelectionToProjection(state, sanitized ?? selection);
2244
+ invalidateEditorScene(state, 'projection-rebuild', options);
2024
2245
  }
2025
2246
  function syncProjectionForDispatchResult(state, options, result, changedId) {
2026
2247
  if (result.documentChanged && changedId && result.workingDocument) {
@@ -2036,6 +2257,9 @@ function syncProjectionForDispatchResult(state, options, result, changedId) {
2036
2257
  const sanitized = result.workingDocument ? sanitizeSelection(state, options, result.workingDocument, selection) : null;
2037
2258
  syncSelectionToProjection(state, sanitized ?? selection);
2038
2259
  }
2260
+ if (result.documentChanged || result.selectionChanged) {
2261
+ invalidateEditorScene(state, 'projection-dispatch-result', options);
2262
+ }
2039
2263
  }
2040
2264
  function syncProjectionForChangedIds(state, options, document, changedIds) {
2041
2265
  for (const changedId of changedIds) {
@@ -2045,6 +2269,7 @@ function syncProjectionForChangedIds(state, options, document, changedIds) {
2045
2269
  }
2046
2270
  const selection = state.session?.getState().selection ?? { selectedIds: [], activeId: null };
2047
2271
  syncSelectionToProjection(state, selection);
2272
+ invalidateEditorScene(state, 'projection-sync-changed-ids', options);
2048
2273
  }
2049
2274
  function reprojectProjectionForChangedIds(state, options, document, changedIds) {
2050
2275
  for (const changedId of changedIds) {
@@ -2052,27 +2277,29 @@ function reprojectProjectionForChangedIds(state, options, document, changedIds)
2052
2277
  if (projectedNode)
2053
2278
  state.projection?.projectNode(projectedNode);
2054
2279
  }
2055
- const selection = state.session?.getState().selection ?? { selectedIds: [], activeId: null };
2056
- syncSelectionToProjection(state, selection);
2057
- state.gizmo?.refreshSelection();
2280
+ syncCurrentSelectionToSceneArtifacts(state);
2281
+ invalidateEditorScene(state, 'projection-reproject-changed-ids', options);
2058
2282
  }
2059
- function setSceneCameraPreviewEnabled(state, options, enabled) {
2283
+ function setMainCameraPreviewEnabled(state, options, enabled) {
2060
2284
  if (!enabled) {
2061
- state.sceneCameraPreviewEnabled = false;
2062
- state.sceneCameraPreview?.setActive(false);
2063
- state.status = 'Scene Camera preview disabled';
2285
+ state.mainCameraPreviewEnabled = false;
2286
+ state.mainCameraPreview?.setActive(false);
2287
+ state.status = 'Main Camera preview disabled';
2288
+ requestEditorSceneFrame(state, 'main-camera-preview-toggle');
2064
2289
  return true;
2065
2290
  }
2066
- state.sceneCameraPreviewEnabled = true;
2067
- if (!syncSceneCameraPreview(state, options)) {
2068
- state.sceneCameraPreviewEnabled = false;
2069
- state.status = 'Scene Camera preview unavailable';
2291
+ state.mainCameraPreviewEnabled = true;
2292
+ if (!syncMainCameraPreview(state, options)) {
2293
+ state.mainCameraPreviewEnabled = false;
2294
+ state.status = 'Main Camera preview unavailable';
2070
2295
  state.statusTone = 'warning';
2071
2296
  state.statusToneStatus = state.status;
2072
2297
  state.statusDetails = 'The current document did not provide a Main Camera preview rig.';
2298
+ requestEditorSceneFrame(state, 'main-camera-preview-toggle');
2073
2299
  return true;
2074
2300
  }
2075
- state.status = 'Scene Camera preview enabled';
2301
+ state.status = 'Main Camera preview enabled';
2302
+ requestEditorSceneFrame(state, 'main-camera-preview-toggle');
2076
2303
  return true;
2077
2304
  }
2078
2305
  function setGridVisible(state, visible) {
@@ -2085,18 +2312,19 @@ function setGridVisible(state, visible) {
2085
2312
  state.statusTone = 'default';
2086
2313
  state.statusToneStatus = state.status;
2087
2314
  state.statusDetails = '';
2315
+ requestEditorSceneFrame(state, 'grid-visible-change');
2088
2316
  return true;
2089
2317
  }
2090
- function syncSceneCameraPreview(state, options) {
2091
- const controller = state.sceneCameraPreview;
2318
+ function syncMainCameraPreview(state, options) {
2319
+ const controller = state.mainCameraPreview;
2092
2320
  if (!controller)
2093
2321
  return false;
2094
- if (!state.sceneCameraPreviewEnabled) {
2322
+ if (!state.mainCameraPreviewEnabled) {
2095
2323
  controller.setActive(false);
2096
2324
  return false;
2097
2325
  }
2098
2326
  const document = state.session?.getState().workingDocument ?? null;
2099
- const rig = document ? options.documentAdapter.getSceneCameraPreviewRig?.(document) ?? null : null;
2327
+ const rig = document ? options.documentAdapter.getMainCameraPreviewRig?.(document) ?? null : null;
2100
2328
  if (!rig) {
2101
2329
  controller.setActive(false);
2102
2330
  return false;
@@ -2115,6 +2343,11 @@ function syncViewportCameraState(state) {
2115
2343
  };
2116
2344
  }
2117
2345
  function syncViewportSpatialOverlay(state) {
2346
+ const settings = state.viewportTools.overlay;
2347
+ if (!settings.bounds && !settings.dimensions && !settings.edgeLengths && !settings.anchor) {
2348
+ state.viewportSpatialOverlay = createEmptyEditorViewportSpatialOverlayState();
2349
+ return;
2350
+ }
2118
2351
  const overlay = state.sceneViewSpatialOverlay;
2119
2352
  const selection = state.session?.getState().selection;
2120
2353
  const activeId = selection?.activeId ?? null;
@@ -2128,7 +2361,7 @@ function syncViewportSpatialOverlay(state) {
2128
2361
  nodeId: activeId,
2129
2362
  bounds,
2130
2363
  anchor,
2131
- settings: state.viewportTools.overlay,
2364
+ settings,
2132
2365
  });
2133
2366
  }
2134
2367
  function syncViewportMeasurementState(state) {
@@ -2138,9 +2371,9 @@ function syncViewportMeasurementState(state) {
2138
2371
  if (measurement)
2139
2372
  state.viewportMeasurement = measurement;
2140
2373
  }
2141
- function hasSceneCameraPreviewRig(state, options) {
2374
+ function hasMainCameraPreviewRig(state, options) {
2142
2375
  const document = state.session?.getState().workingDocument ?? null;
2143
- return !!document && !!options.documentAdapter.getSceneCameraPreviewRig?.(document);
2376
+ return !!document && !!options.documentAdapter.getMainCameraPreviewRig?.(document);
2144
2377
  }
2145
2378
  function createUiState(state, options) {
2146
2379
  const sessionState = state.session?.getState();
@@ -2165,6 +2398,8 @@ function createUiState(state, options) {
2165
2398
  const inspectorMultiObject = document && inspectorMultiObjectBase
2166
2399
  ? withRuntimeInspectorSections(state, options, document, inspectorMultiObjectBase)
2167
2400
  : inspectorMultiObjectBase;
2401
+ const assets = dedupeLocalEditorBrowserAssetItems(state.assets
2402
+ .map(asset => toBrowserAssetItem(options, asset)));
2168
2403
  return {
2169
2404
  mode: state.mode,
2170
2405
  busy: state.busy,
@@ -2173,10 +2408,8 @@ function createUiState(state, options) {
2173
2408
  statusDetails: state.statusToneStatus === state.status ? state.statusDetails : '',
2174
2409
  summary: state.summary,
2175
2410
  assetFilter: state.assetFilter,
2176
- assets: state.assets
2177
- .filter(asset => asset.placeable !== false)
2178
- .map(asset => toBrowserAssetItem(options, asset)),
2179
- assetCountLabel: `${state.assets.length} assets`,
2411
+ assets,
2412
+ assetCountLabel: `${assets.length} assets`,
2180
2413
  hierarchy: document ? options.documentAdapter.getHierarchyItems(document) : [],
2181
2414
  selectedIds,
2182
2415
  activeId,
@@ -2209,9 +2442,12 @@ function createUiState(state, options) {
2209
2442
  viewportTools: cloneEditorViewportToolState(state.viewportTools),
2210
2443
  viewportMeasurement: cloneViewportMeasurement(state.viewportMeasurement),
2211
2444
  viewportSpatialOverlay: cloneEditorViewportSpatialOverlayState(state.viewportSpatialOverlay),
2212
- sceneCameraPreview: {
2213
- enabled: state.sceneCameraPreviewEnabled,
2214
- available: hasSceneCameraPreviewRig(state, options),
2445
+ sceneFrameStats: state.sceneFrameStats
2446
+ ? { ...state.sceneFrameStats, activeReasons: [...state.sceneFrameStats.activeReasons] }
2447
+ : null,
2448
+ mainCameraPreview: {
2449
+ enabled: state.mainCameraPreviewEnabled,
2450
+ available: hasMainCameraPreviewRig(state, options),
2215
2451
  },
2216
2452
  grid: {
2217
2453
  visible: state.gridVisible,
@@ -2242,6 +2478,8 @@ function createSceneViewCoordinateAxesState(state) {
2242
2478
  if (!right || !up || !forward)
2243
2479
  return null;
2244
2480
  return {
2481
+ projectionMode: readSceneViewFreeCameraProjectionMode(state, camera),
2482
+ projectionToggleDisabled: state.mainCameraPreviewEnabled,
2245
2483
  axes: [
2246
2484
  createSceneViewCoordinateAxis('x', 'X', '#ff4b70', { x: 1, y: 0, z: 0 }, right, up, forward),
2247
2485
  createSceneViewCoordinateAxis('y', 'Y', '#75ff42', { x: 0, y: 1, z: 0 }, right, up, forward),
@@ -2249,6 +2487,14 @@ function createSceneViewCoordinateAxesState(state) {
2249
2487
  ],
2250
2488
  };
2251
2489
  }
2490
+ function readSceneViewFreeCameraProjectionMode(state, camera) {
2491
+ return state.sceneViewCamera?.getState().projectionMode
2492
+ ?? readSceneViewCameraProjectionMode(state, state.world?.camera ?? camera);
2493
+ }
2494
+ function readSceneViewCameraProjectionMode(state, camera) {
2495
+ const orthoMode = state.babylon?.Camera?.ORTHOGRAPHIC_CAMERA ?? 1;
2496
+ return camera?.mode === orthoMode ? 'orthographic' : 'perspective';
2497
+ }
2252
2498
  function createSceneViewCoordinateAxis(id, label, color, worldAxis, cameraRight, cameraUp, cameraForward) {
2253
2499
  const screenX = dotSceneViewAxisVec3(worldAxis, cameraRight);
2254
2500
  const screenY = -dotSceneViewAxisVec3(worldAxis, cameraUp);
@@ -2382,9 +2628,10 @@ function createDefaultRuntimeInspectorSections(context) {
2382
2628
  const properties = [];
2383
2629
  if (projectionNode) {
2384
2630
  properties.push(createRuntimeInspectorProperty('runtime.projection.nodeId', 'Projected ID', projectionNode.id, properties.length));
2385
- const assetSource = projectionNode.asset?.sourceId ?? projectionNode.asset?.id ?? '';
2386
- if (assetSource)
2387
- properties.push(createRuntimeInspectorProperty('runtime.projection.assetSource', 'Asset Source', assetSource, properties.length));
2631
+ const projectionAsset = projectionNode.asset;
2632
+ const assetId = projectionAsset?.assetId ?? projectionAsset?.id ?? '';
2633
+ if (assetId)
2634
+ properties.push(createRuntimeInspectorProperty('runtime.projection.assetId', 'Asset ID', assetId, properties.length));
2388
2635
  }
2389
2636
  const runtimeClass = readRuntimeClassName(root);
2390
2637
  if (runtimeClass)
@@ -2501,15 +2748,114 @@ function isNodeSelectableInDocument(options, document, id) {
2501
2748
  }
2502
2749
  function resolveAssetId(options, asset) {
2503
2750
  return options.worldAdapter.resolveAssetId?.(asset)
2751
+ ?? asset.assetId
2504
2752
  ?? asset.id;
2505
2753
  }
2754
+ function findAssetByResolvedId(state, options, assetId) {
2755
+ let selected = null;
2756
+ for (const asset of state.assets) {
2757
+ if (resolveAssetId(options, asset) !== assetId)
2758
+ continue;
2759
+ const item = toBrowserAssetItem(options, asset);
2760
+ if (!selected || shouldReplaceBrowserAssetItem(selected.item, item)) {
2761
+ selected = { asset, item };
2762
+ }
2763
+ }
2764
+ return selected?.asset;
2765
+ }
2506
2766
  function toBrowserAssetItem(options, asset) {
2507
2767
  return options.worldAdapter.toBrowserAssetItem?.(asset)
2508
2768
  ?? {
2509
- id: asset.id,
2510
- label: asset.label,
2769
+ id: asset.assetId ?? asset.id,
2770
+ label: asset.displayName ?? asset.label,
2771
+ guid: asset.guid,
2772
+ assetId: asset.assetId,
2773
+ kind: asset.kind,
2774
+ external: asset.external,
2775
+ origin: asset.origin,
2776
+ dedupeKey: asset.dedupeKey,
2777
+ placeable: asset.placeable,
2511
2778
  meta: asset.meta,
2512
- disabled: asset.placeable === false,
2779
+ disabled: asset.disabled ?? asset.placeable === false,
2513
2780
  };
2514
2781
  }
2782
+ export function dedupeLocalEditorBrowserAssetItems(items) {
2783
+ const byKey = new Map();
2784
+ for (const item of items) {
2785
+ const key = getBrowserAssetCanonicalKey(item);
2786
+ const existing = byKey.get(key);
2787
+ if (!existing || shouldReplaceBrowserAssetItem(existing, item)) {
2788
+ byKey.set(key, item);
2789
+ }
2790
+ }
2791
+ const projectDuplicateKeys = new Set();
2792
+ for (const item of byKey.values()) {
2793
+ if (!item.guid || item.origin !== 'project')
2794
+ continue;
2795
+ for (const duplicateKey of getBrowserAssetDuplicateSuppressionKeys(item)) {
2796
+ projectDuplicateKeys.add(duplicateKey);
2797
+ }
2798
+ }
2799
+ return [...byKey.values()].filter((item) => {
2800
+ if (item.guid)
2801
+ return true;
2802
+ return !getBrowserAssetDuplicateSuppressionKeys(item)
2803
+ .some(duplicateKey => projectDuplicateKeys.has(duplicateKey));
2804
+ });
2805
+ }
2806
+ function getBrowserAssetCanonicalKey(item) {
2807
+ const guid = normalizeBrowserAssetString(item.guid);
2808
+ if (guid)
2809
+ return `guid:${guid}`;
2810
+ const platformAssetId = normalizeBrowserAssetString(item.external?.platformAssetId);
2811
+ if (platformAssetId)
2812
+ return `external:${platformAssetId}`;
2813
+ const dedupeKey = normalizeBrowserAssetString(item.dedupeKey);
2814
+ if (dedupeKey)
2815
+ return `dedupe:${dedupeKey}`;
2816
+ const assetId = normalizeBrowserAssetString(item.assetId);
2817
+ if (assetId)
2818
+ return `asset:${assetId}`;
2819
+ return `id:${normalizeBrowserAssetString(item.id) ?? item.id}`;
2820
+ }
2821
+ function getBrowserAssetDuplicateSuppressionKeys(item) {
2822
+ const keys = [];
2823
+ pushBrowserAssetDuplicateKey(keys, 'external', item.external?.platformAssetId);
2824
+ pushBrowserAssetDuplicateKey(keys, 'dedupe', item.dedupeKey);
2825
+ pushBrowserAssetDuplicateKey(keys, 'asset', item.assetId);
2826
+ pushBrowserAssetDuplicateKey(keys, 'id', item.id);
2827
+ return keys;
2828
+ }
2829
+ function pushBrowserAssetDuplicateKey(keys, prefix, value) {
2830
+ const normalized = normalizeBrowserAssetString(value);
2831
+ if (normalized)
2832
+ keys.push(`${prefix}:${normalized}`);
2833
+ }
2834
+ function shouldReplaceBrowserAssetItem(current, candidate) {
2835
+ const currentScore = getBrowserAssetPreferenceScore(current);
2836
+ const candidateScore = getBrowserAssetPreferenceScore(candidate);
2837
+ if (candidateScore !== currentScore)
2838
+ return candidateScore > currentScore;
2839
+ return getBrowserAssetDetailScore(candidate) > getBrowserAssetDetailScore(current);
2840
+ }
2841
+ function getBrowserAssetPreferenceScore(item) {
2842
+ return (item.origin === 'project' ? 1000 : 0)
2843
+ + (item.guid ? 100 : 0)
2844
+ + (item.assetId ? 40 : 0)
2845
+ + (item.placeable === false ? 0 : 10)
2846
+ + (item.disabled ? 0 : 5);
2847
+ }
2848
+ function getBrowserAssetDetailScore(item) {
2849
+ return [
2850
+ item.label,
2851
+ item.meta,
2852
+ item.kind,
2853
+ item.external?.platformAssetId,
2854
+ item.external?.assetPath,
2855
+ item.external?.assetUrl,
2856
+ ].filter(value => typeof value === 'string' && value.trim()).length;
2857
+ }
2858
+ function normalizeBrowserAssetString(value) {
2859
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
2860
+ }
2515
2861
  //# sourceMappingURL=local-editor-harness.js.map