@fps-games/editor 0.1.1-beta.8 → 0.1.2-beta.1

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 (75) hide show
  1. package/dist/local-editor-harness.d.ts +48 -4
  2. package/dist/local-editor-harness.d.ts.map +1 -1
  3. package/dist/local-editor-harness.js +428 -88
  4. package/dist/local-editor-harness.js.map +1 -1
  5. package/dist/local-editor-scene-render-scheduler.d.ts +26 -0
  6. package/dist/local-editor-scene-render-scheduler.d.ts.map +1 -0
  7. package/dist/local-editor-scene-render-scheduler.js +139 -0
  8. package/dist/local-editor-scene-render-scheduler.js.map +1 -0
  9. package/dist/local-editor-viewport-render-coordinator.d.ts +17 -0
  10. package/dist/local-editor-viewport-render-coordinator.d.ts.map +1 -0
  11. package/dist/local-editor-viewport-render-coordinator.js +102 -0
  12. package/dist/local-editor-viewport-render-coordinator.js.map +1 -0
  13. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts +2 -0
  14. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts.map +1 -1
  15. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js +9 -3
  16. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js.map +1 -1
  17. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts.map +1 -1
  18. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js +2 -0
  19. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js.map +1 -1
  20. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts +1 -0
  21. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts.map +1 -1
  22. package/node_modules/@fps-games/editor-babylon/dist/index.js +1 -0
  23. package/node_modules/@fps-games/editor-babylon/dist/index.js.map +1 -1
  24. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts.map +1 -1
  25. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js +167 -27
  26. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js.map +1 -1
  27. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.d.ts +25 -0
  28. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.d.ts.map +1 -0
  29. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.js +162 -0
  30. package/node_modules/@fps-games/editor-babylon/dist/main-camera-preview.js.map +1 -0
  31. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts +29 -1
  32. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts.map +1 -1
  33. package/node_modules/@fps-games/editor-babylon/dist/projection.js +280 -17
  34. package/node_modules/@fps-games/editor-babylon/dist/projection.js.map +1 -1
  35. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.d.ts +2 -22
  36. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.d.ts.map +1 -1
  37. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.js +1 -121
  38. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.js.map +1 -1
  39. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.d.ts.map +1 -1
  40. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.js +68 -30
  41. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.js.map +1 -1
  42. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts +6 -0
  43. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  44. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  45. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts +2 -1
  46. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts.map +1 -1
  47. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js +139 -34
  48. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js.map +1 -1
  49. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts +2 -1
  50. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts.map +1 -1
  51. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js +2 -0
  52. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js.map +1 -1
  53. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts +1 -0
  54. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts.map +1 -1
  55. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js +96 -14
  56. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js.map +1 -1
  57. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js +1 -1
  58. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +56 -2
  59. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  60. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts +1 -0
  61. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts.map +1 -1
  62. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js +3 -0
  63. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js.map +1 -1
  64. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +4 -2
  65. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  66. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +253 -39
  67. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  68. package/node_modules/@fps-games/editor-browser/package.json +2 -2
  69. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts +6 -6
  70. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js +3 -3
  71. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js.map +1 -1
  72. package/node_modules/@fps-games/editor-core/package.json +2 -2
  73. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  74. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  75. package/package.json +6 -6
@@ -1,6 +1,8 @@
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';
4
6
  export function createLocalEditorHarness(options) {
5
7
  const root = options.root ?? document.body;
6
8
  const state = {
@@ -17,8 +19,8 @@ export function createLocalEditorHarness(options) {
17
19
  gridVisible: true,
18
20
  projection: null,
19
21
  gizmo: null,
20
- sceneCameraPreview: null,
21
- sceneCameraPreviewEnabled: false,
22
+ mainCameraPreview: null,
23
+ mainCameraPreviewEnabled: false,
22
24
  sceneViewInput: null,
23
25
  sceneViewCamera: null,
24
26
  sceneViewMeasurement: null,
@@ -34,6 +36,9 @@ export function createLocalEditorHarness(options) {
34
36
  viewportSpatialOverlay: createEmptyEditorViewportSpatialOverlayState(),
35
37
  duplicateDrag: null,
36
38
  armedPlacement: null,
39
+ sceneRenderScheduler: null,
40
+ viewportRenderCoordinator: null,
41
+ sceneFrameStats: null,
37
42
  resizeHandler: null,
38
43
  status: 'Game running',
39
44
  statusTone: 'default',
@@ -47,6 +52,9 @@ export function createLocalEditorHarness(options) {
47
52
  theme: options.theme,
48
53
  localTestActions: options.localTestActions,
49
54
  inspector: options.inspector,
55
+ hierarchy: {
56
+ contextActions: createBrowserHierarchyContextActions(state, options),
57
+ },
50
58
  callbacks: {
51
59
  onEnterEditor: () => {
52
60
  void runExclusive(state, harness.render, () => harness.enterEditor());
@@ -138,16 +146,19 @@ export function createLocalEditorHarness(options) {
138
146
  state.transformConstraint = normalizeTransformConstraint(tool, state.transformConstraint);
139
147
  state.gizmo?.setTool(tool);
140
148
  state.gizmo?.setConstraint(state.transformConstraint);
149
+ requestEditorSceneFrame(state, 'transform-tool-change');
141
150
  harness.render();
142
151
  },
143
152
  onTransformSpaceChange: (space) => {
144
153
  state.transformSpace = space;
145
154
  state.gizmo?.setSpace(space);
155
+ requestEditorSceneFrame(state, 'transform-space-change');
146
156
  harness.render();
147
157
  },
148
158
  onTransformConstraintChange: (constraint) => {
149
159
  state.transformConstraint = normalizeTransformConstraint(state.transformTool, constraint);
150
160
  state.gizmo?.setConstraint(state.transformConstraint);
161
+ requestEditorSceneFrame(state, 'transform-constraint-change');
151
162
  harness.render();
152
163
  },
153
164
  onTransformSnapEnabledChange: (enabled) => {
@@ -159,6 +170,7 @@ export function createLocalEditorHarness(options) {
159
170
  });
160
171
  state.gizmo?.setOperationSettings(state.transformOperationSettings);
161
172
  state.status = enabled ? 'Transform snap enabled' : 'Transform snap disabled';
173
+ requestEditorSceneFrame(state, 'transform-operation-settings-change');
162
174
  harness.render();
163
175
  },
164
176
  onTransformSnapStepChange: (input) => {
@@ -175,6 +187,7 @@ export function createLocalEditorHarness(options) {
175
187
  state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, { snap });
176
188
  state.gizmo?.setOperationSettings(state.transformOperationSettings);
177
189
  state.status = `Transform snap ${input.kind} step ${value}`;
190
+ requestEditorSceneFrame(state, 'transform-operation-settings-change');
178
191
  harness.render();
179
192
  },
180
193
  onPlacementModeChange: (mode) => {
@@ -185,6 +198,7 @@ export function createLocalEditorHarness(options) {
185
198
  if (state.transformOperationSettings.placementMode === 'off')
186
199
  clearArmedPlacement(state);
187
200
  state.status = `Placement mode: ${state.transformOperationSettings.placementMode}`;
201
+ requestEditorSceneFrame(state, 'placement-mode-change');
188
202
  harness.render();
189
203
  },
190
204
  onTransformAction: (action) => {
@@ -211,8 +225,8 @@ export function createLocalEditorHarness(options) {
211
225
  if (clearViewportMeasurement(state))
212
226
  harness.render();
213
227
  },
214
- onSceneCameraPreviewToggle: (enabled) => {
215
- if (setSceneCameraPreviewEnabled(state, options, enabled))
228
+ onMainCameraPreviewToggle: (enabled) => {
229
+ if (setMainCameraPreviewEnabled(state, options, enabled))
216
230
  harness.render();
217
231
  },
218
232
  onGridVisibleChange: (visible) => {
@@ -229,17 +243,23 @@ export function createLocalEditorHarness(options) {
229
243
  },
230
244
  onCancelActiveOperation: () => {
231
245
  cancelActiveOperation(state);
246
+ requestEditorSceneFrame(state, 'cancel-active-operation');
232
247
  harness.render();
233
248
  },
234
249
  },
235
250
  });
251
+ const renderUi = () => {
252
+ syncViewportCameraState(state);
253
+ syncViewportMeasurementState(state);
254
+ syncViewportSpatialOverlay(state);
255
+ ui.update(createUiState(state, options));
256
+ };
236
257
  harness = {
237
258
  render() {
238
- syncSceneCameraPreview(state, options);
239
- syncViewportCameraState(state);
240
- syncViewportMeasurementState(state);
241
- syncViewportSpatialOverlay(state);
242
- ui.update(createUiState(state, options));
259
+ renderUi();
260
+ },
261
+ notifyViewportRevealed(reason = 'viewport-revealed') {
262
+ state.viewportRenderCoordinator?.requestRevealFrame(reason);
243
263
  },
244
264
  setTheme(theme) {
245
265
  ui.setTheme(theme);
@@ -307,10 +327,12 @@ export function createLocalEditorHarness(options) {
307
327
  reduceDocument: options.documentAdapter.reduceDocument,
308
328
  });
309
329
  await options.worldAdapter.disposeGameWorld();
310
- await createEditorWorld(state, options, harness.render);
330
+ await createEditorWorld(state, options, harness.render, stats => ui.updateSceneFrameStats?.(stats));
311
331
  state.mode = 'editor';
312
332
  state.summary = loadedSource?.summary ?? summarizeDocument(options, preparedDocument, source);
313
333
  state.status = `GameWorld disposed; EditorWorld active; assets=${assets.length}`;
334
+ harness.render();
335
+ harness.notifyViewportRevealed('editor-enter');
314
336
  },
315
337
  async saveScene() {
316
338
  cancelActiveOperation(state);
@@ -485,11 +507,11 @@ function cloneViewportMeasurement(measurement) {
485
507
  };
486
508
  }
487
509
  function setViewportViewPreset(state, preset) {
488
- if (state.sceneCameraPreviewEnabled) {
489
- state.status = 'Viewport view unavailable during Scene Camera preview';
510
+ if (state.mainCameraPreviewEnabled) {
511
+ state.status = 'Viewport view unavailable during Main Camera preview';
490
512
  state.statusTone = 'warning';
491
513
  state.statusToneStatus = state.status;
492
- state.statusDetails = 'Disable Scene Camera preview before switching editor viewport views.';
514
+ state.statusDetails = 'Disable Main Camera preview before switching editor viewport views.';
493
515
  return true;
494
516
  }
495
517
  const nextPreset = normalizeViewportViewPreset(preset);
@@ -508,14 +530,15 @@ function setViewportViewPreset(state, preset) {
508
530
  state.statusTone = 'default';
509
531
  state.statusToneStatus = state.status;
510
532
  state.statusDetails = '';
533
+ requestEditorSceneFrame(state, 'viewport-view-preset-change');
511
534
  return true;
512
535
  }
513
536
  function setViewportProjectionMode(state, mode) {
514
- if (state.sceneCameraPreviewEnabled) {
515
- state.status = 'Viewport projection unavailable during Scene Camera preview';
537
+ if (state.mainCameraPreviewEnabled) {
538
+ state.status = 'Viewport projection unavailable during Main Camera preview';
516
539
  state.statusTone = 'warning';
517
540
  state.statusToneStatus = state.status;
518
- state.statusDetails = 'Disable Scene Camera preview before switching editor viewport projection.';
541
+ state.statusDetails = 'Disable Main Camera preview before switching editor viewport projection.';
519
542
  return true;
520
543
  }
521
544
  const nextMode = normalizeViewportProjectionMode(mode);
@@ -531,6 +554,7 @@ function setViewportProjectionMode(state, mode) {
531
554
  state.statusTone = 'default';
532
555
  state.statusToneStatus = state.status;
533
556
  state.statusDetails = '';
557
+ requestEditorSceneFrame(state, 'viewport-projection-mode-change');
534
558
  return true;
535
559
  }
536
560
  function setViewportOverlaySettings(state, settings) {
@@ -682,7 +706,7 @@ function validateTransformActionSelection(state, action) {
682
706
  ? { ok: true }
683
707
  : { ok: false, message: 'Distribute needs at least 3 selected objects' };
684
708
  }
685
- async function createEditorWorld(state, options, render) {
709
+ async function createEditorWorld(state, options, renderUi, updateSceneFrameStats) {
686
710
  disposeEditorWorld(state);
687
711
  const canvas = options.worldAdapter.getCanvas();
688
712
  if (!canvas)
@@ -700,13 +724,27 @@ async function createEditorWorld(state, options, render) {
700
724
  useRightHandedSystem: options.world?.useRightHandedSystem,
701
725
  enableDefaultCameraControls: false,
702
726
  });
703
- const grid = options.createGrid?.(babylon, world.scene) ?? null;
727
+ const sceneRenderScheduler = createLocalEditorSceneRenderScheduler(() => world.render(), {
728
+ onStatsChange(stats) {
729
+ state.sceneFrameStats = stats;
730
+ updateSceneFrameStats(stats);
731
+ },
732
+ });
733
+ const viewportRenderCoordinator = createLocalEditorViewportRenderCoordinator({
734
+ scheduler: sceneRenderScheduler,
735
+ getEngine: () => engine,
736
+ getScene: () => world.scene,
737
+ });
738
+ const grid = options.createGrid?.(babylon, world.scene, world.camera) ?? null;
704
739
  grid?.setVisible(state.gridVisible);
705
740
  const projection = createBabylonEditorProjection({
706
741
  babylon,
707
742
  scene: world.scene,
708
743
  importModel: options.worldAdapter.importProjectionModel,
709
744
  logger: console,
745
+ onProjectionReady(event) {
746
+ viewportRenderCoordinator.invalidateScene(`projection-${event.nodeId}-ready`);
747
+ },
710
748
  });
711
749
  const gizmo = createBabylonTransformGizmoController({
712
750
  babylon,
@@ -716,27 +754,33 @@ async function createEditorWorld(state, options, render) {
716
754
  initialSpace: state.transformSpace,
717
755
  logger: console,
718
756
  onDragStart(event) {
757
+ beginEditorSceneContinuousRender(state, 'gizmo-drag');
719
758
  state.status = event.targetIds.length > 1
720
759
  ? `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.targetIds.length} objects`
721
760
  : `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
722
- render();
761
+ renderUi();
723
762
  },
724
763
  onDragUpdate() {
725
- render();
764
+ renderUi();
726
765
  },
727
766
  onDragEnd(event) {
728
- commitGizmoTransform(state, options, event);
729
- render();
767
+ endEditorSceneContinuousRender(state, 'gizmo-drag');
768
+ const changed = commitGizmoTransform(state, options, event);
769
+ if (!changed)
770
+ requestEditorSceneFrame(state, 'gizmo-drag-end');
771
+ renderUi();
730
772
  },
731
773
  onDragCancel(event) {
774
+ endEditorSceneContinuousRender(state, 'gizmo-drag');
732
775
  if (event.duplicate && cancelDuplicateDrag(state, options)) {
733
- render();
776
+ renderUi();
734
777
  return;
735
778
  }
779
+ requestEditorSceneFrame(state, 'gizmo-drag-cancel');
736
780
  state.status = event.targetIds.length > 1
737
781
  ? `Canceled ${event.tool} ${event.targetIds.length} objects`
738
782
  : `Canceled ${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
739
- render();
783
+ renderUi();
740
784
  },
741
785
  onDuplicateDragStart(input) {
742
786
  return beginDuplicateDrag(state, options, input);
@@ -755,15 +799,15 @@ async function createEditorWorld(state, options, render) {
755
799
  isOperationBlocked: () => state.gizmo?.getState().dragPhase === 'dragging',
756
800
  onSelectionCommand(command) {
757
801
  if (dispatchSelectionCommand(state, options, command))
758
- render();
802
+ renderUi();
759
803
  },
760
804
  onFocusIntent(nodeId) {
761
805
  if (focusProjectionNode(state, nodeId))
762
- render();
806
+ renderUi();
763
807
  },
764
808
  onBoxSelectionChange(box) {
765
809
  state.boxSelection = box;
766
- render();
810
+ renderUi();
767
811
  },
768
812
  });
769
813
  const sceneViewMeasurement = createBabylonSceneViewMeasurementController({
@@ -779,9 +823,10 @@ async function createEditorWorld(state, options, render) {
779
823
  isPlacementCandidate: () => isPlacementArmed(state),
780
824
  isMeasurementCandidate: () => state.viewportTools.activeUtilityTool === 'measure-distance',
781
825
  onPointerIntentStart(event) {
826
+ beginContinuousRenderForPointerIntent(state, event.state.intent);
782
827
  if (event.state.intent === 'measurement') {
783
828
  if (handleViewportMeasurementStart(state, event.originalEvent))
784
- render();
829
+ renderUi();
785
830
  return;
786
831
  }
787
832
  if (event.state.intent === 'gizmo-drag') {
@@ -790,88 +835,109 @@ async function createEditorWorld(state, options, render) {
790
835
  }
791
836
  if (event.state.intent === 'placement') {
792
837
  if (previewArmedPlacement(state, event.originalEvent))
793
- render();
838
+ renderUi();
794
839
  return;
795
840
  }
796
841
  if (event.state.intent === 'view-plane-move') {
797
842
  if (gizmo.beginViewPlaneMove(event.originalEvent))
798
- render();
843
+ renderUi();
799
844
  return;
800
845
  }
801
846
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
802
847
  selectionController.beginPointerSelection(event.originalEvent);
848
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}-start`);
803
849
  }
804
850
  },
805
851
  onPointerIntentMove(event) {
806
852
  if (event.state.intent === 'measurement') {
807
853
  if (handleViewportMeasurementMove(state, event.originalEvent))
808
- render();
854
+ renderUi();
809
855
  return;
810
856
  }
811
857
  if (event.state.intent === 'placement') {
812
858
  if (previewArmedPlacement(state, event.originalEvent))
813
- render();
859
+ renderUi();
814
860
  return;
815
861
  }
816
862
  if (event.state.intent === 'view-plane-move') {
817
863
  if (gizmo.updateViewPlaneMove(event.originalEvent))
818
- render();
864
+ renderUi();
819
865
  return;
820
866
  }
821
- if (!state.sceneCameraPreviewEnabled && state.sceneViewCamera?.handlePointerIntentMove(event)) {
822
- render();
867
+ if (!state.mainCameraPreviewEnabled && state.sceneViewCamera?.handlePointerIntentMove(event)) {
868
+ renderUi();
823
869
  return;
824
870
  }
825
871
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
826
872
  selectionController.updatePointerSelection(event.originalEvent, event.state.intent);
827
873
  }
874
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}`);
828
875
  },
829
876
  onPointerIntentEnd(event) {
877
+ endContinuousRenderForPointerIntent(state, event.state.intent);
830
878
  if (event.state.intent === 'measurement') {
831
879
  if (handleViewportMeasurementEnd(state))
832
- render();
880
+ renderUi();
833
881
  return;
834
882
  }
835
883
  if (event.state.intent === 'placement') {
836
884
  if (commitArmedPlacement(state, options, event.originalEvent))
837
- render();
885
+ renderUi();
838
886
  return;
839
887
  }
840
888
  if (event.state.intent === 'view-plane-move') {
841
- if (gizmo.endViewPlaneMove(event.originalEvent))
842
- render();
889
+ if (gizmo.endViewPlaneMove(event.originalEvent)) {
890
+ requestEditorSceneFrame(state, 'view-plane-move-end');
891
+ renderUi();
892
+ }
843
893
  return;
844
894
  }
845
895
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
846
896
  selectionController.endPointerSelection(event.originalEvent, event.state.intent);
847
897
  }
898
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}-end`);
848
899
  },
849
900
  onPointerIntentCancel(event) {
901
+ endContinuousRenderForPointerIntent(state, event.state.intent);
850
902
  if (event.state.intent === 'measurement') {
851
- render();
903
+ renderUi();
852
904
  return;
853
905
  }
854
906
  if (event.state.intent === 'placement') {
855
907
  state.gizmo?.setPlacementMarker(null);
856
- render();
908
+ requestEditorSceneFrame(state, 'placement-marker-clear');
909
+ renderUi();
857
910
  return;
858
911
  }
859
912
  if (event.state.intent === 'view-plane-move') {
860
913
  gizmo.cancelDrag();
861
- render();
914
+ requestEditorSceneFrame(state, 'view-plane-move-cancel');
915
+ renderUi();
862
916
  return;
863
917
  }
864
918
  if (event.state.intent === 'selection-click' || event.state.intent === 'box-select') {
865
919
  selectionController.cancelBoxSelection();
866
- render();
920
+ renderUi();
867
921
  }
922
+ requestEditorSceneFrame(state, `pointer-${event.state.intent}-cancel`);
868
923
  },
869
924
  onDoubleClick(event) {
870
925
  selectionController.handleDoubleClick(event);
871
926
  },
872
927
  onWheel(event) {
873
- if (!state.sceneCameraPreviewEnabled && state.sceneViewCamera?.handleWheel(event))
874
- render();
928
+ if (!state.mainCameraPreviewEnabled && state.sceneViewCamera?.handleWheel(event)) {
929
+ requestEditorSceneFrame(state, 'camera-wheel');
930
+ renderUi();
931
+ }
932
+ },
933
+ onMovementKeysChange(event) {
934
+ if (event.pressedMovementKeys.length > 0) {
935
+ beginEditorSceneContinuousRender(state, 'camera-flythrough-keys');
936
+ }
937
+ else {
938
+ endEditorSceneContinuousRender(state, 'camera-flythrough-keys');
939
+ }
940
+ renderUi();
875
941
  },
876
942
  });
877
943
  const sceneViewCamera = createBabylonSceneViewCameraController({
@@ -880,7 +946,7 @@ async function createEditorWorld(state, options, render) {
880
946
  camera: world.camera,
881
947
  input: sceneViewInput,
882
948
  });
883
- const sceneCameraPreview = createBabylonSceneCameraPreviewController({
949
+ const mainCameraPreview = createBabylonMainCameraPreviewController({
884
950
  babylon,
885
951
  scene: world.scene,
886
952
  editorCamera: world.camera,
@@ -896,31 +962,42 @@ async function createEditorWorld(state, options, render) {
896
962
  projection.syncSelection(selection);
897
963
  gizmo.setSelection(selection);
898
964
  }
899
- const resize = () => engine.resize?.();
965
+ const resize = () => {
966
+ engine.resize?.();
967
+ viewportRenderCoordinator.requestFrame('resize');
968
+ };
900
969
  window.addEventListener('resize', resize);
901
- engine.runRenderLoop?.(() => world.render());
902
970
  state.babylon = babylon;
903
971
  state.engine = engine;
904
972
  state.world = world;
905
973
  state.grid = grid;
906
974
  state.projection = projection;
907
975
  state.gizmo = gizmo;
908
- state.sceneCameraPreview = sceneCameraPreview;
976
+ state.mainCameraPreview = mainCameraPreview;
909
977
  state.sceneViewInput = sceneViewInput;
910
978
  state.sceneViewCamera = sceneViewCamera;
911
979
  state.sceneViewMeasurement = sceneViewMeasurement;
912
980
  state.sceneViewSpatialOverlay = sceneViewSpatialOverlay;
913
981
  state.selectionController = selectionController;
982
+ state.sceneRenderScheduler = sceneRenderScheduler;
983
+ state.viewportRenderCoordinator = viewportRenderCoordinator;
984
+ state.sceneFrameStats = sceneRenderScheduler.getStats();
914
985
  state.resizeHandler = resize;
986
+ await sceneRenderScheduler.waitForNextFrame('editor-world-created');
915
987
  }
916
988
  function disposeEditorWorld(state) {
989
+ state.viewportRenderCoordinator?.dispose();
990
+ state.viewportRenderCoordinator = null;
991
+ state.sceneRenderScheduler?.dispose();
992
+ state.sceneRenderScheduler = null;
993
+ state.sceneFrameStats = null;
917
994
  if (state.resizeHandler) {
918
995
  window.removeEventListener('resize', state.resizeHandler);
919
996
  state.resizeHandler = null;
920
997
  }
921
- state.sceneCameraPreview?.dispose();
922
- state.sceneCameraPreview = null;
923
- state.sceneCameraPreviewEnabled = false;
998
+ state.mainCameraPreview?.dispose();
999
+ state.mainCameraPreview = null;
1000
+ state.mainCameraPreviewEnabled = false;
924
1001
  state.sceneViewCamera?.dispose();
925
1002
  state.sceneViewCamera = null;
926
1003
  state.sceneViewMeasurement?.dispose();
@@ -944,18 +1021,55 @@ function disposeEditorWorld(state) {
944
1021
  state.projection = null;
945
1022
  state.grid?.dispose();
946
1023
  state.grid = null;
947
- state.engine?.stopRenderLoop?.();
948
1024
  state.world?.dispose();
949
1025
  state.engine?.dispose?.();
950
1026
  state.babylon = null;
951
1027
  state.world = null;
952
1028
  state.engine = null;
953
1029
  }
954
- async function runExclusive(state, render, action) {
1030
+ function requestEditorSceneFrame(state, reason) {
1031
+ state.viewportRenderCoordinator?.requestFrame(reason);
1032
+ }
1033
+ function beginEditorSceneContinuousRender(state, reason) {
1034
+ state.viewportRenderCoordinator?.beginContinuous(reason);
1035
+ }
1036
+ function endEditorSceneContinuousRender(state, reason) {
1037
+ state.viewportRenderCoordinator?.endContinuous(reason);
1038
+ }
1039
+ function beginContinuousRenderForPointerIntent(state, intent) {
1040
+ const reason = continuousRenderReasonForPointerIntent(intent);
1041
+ if (reason)
1042
+ beginEditorSceneContinuousRender(state, reason);
1043
+ }
1044
+ function endContinuousRenderForPointerIntent(state, intent) {
1045
+ const reason = continuousRenderReasonForPointerIntent(intent);
1046
+ if (reason)
1047
+ endEditorSceneContinuousRender(state, reason);
1048
+ if (intent === 'gizmo-drag')
1049
+ endEditorSceneContinuousRender(state, 'gizmo-drag');
1050
+ if (intent === 'flythrough')
1051
+ endEditorSceneContinuousRender(state, 'camera-flythrough-keys');
1052
+ }
1053
+ function continuousRenderReasonForPointerIntent(intent) {
1054
+ switch (intent) {
1055
+ case 'gizmo-drag':
1056
+ case 'view-plane-move':
1057
+ case 'placement':
1058
+ case 'measurement':
1059
+ case 'orbit':
1060
+ case 'pan':
1061
+ case 'dolly':
1062
+ case 'flythrough':
1063
+ return `pointer-${intent}`;
1064
+ default:
1065
+ return null;
1066
+ }
1067
+ }
1068
+ async function runExclusive(state, renderUi, action) {
955
1069
  if (state.busy)
956
1070
  return;
957
1071
  state.busy = true;
958
- render();
1072
+ renderUi();
959
1073
  try {
960
1074
  await action();
961
1075
  }
@@ -968,7 +1082,7 @@ async function runExclusive(state, render, action) {
968
1082
  }
969
1083
  finally {
970
1084
  state.busy = false;
971
- render();
1085
+ renderUi();
972
1086
  }
973
1087
  }
974
1088
  function selectItem(state, options, input) {
@@ -988,9 +1102,92 @@ function selectItem(state, options, input) {
988
1102
  return false;
989
1103
  return dispatchSelectionCommand(state, options, command);
990
1104
  }
1105
+ function createBrowserHierarchyContextActions(state, options) {
1106
+ return (options.hierarchy?.contextActions ?? []).map((registration) => ({
1107
+ id: registration.id,
1108
+ label: registration.label,
1109
+ shortcut: registration.shortcut,
1110
+ danger: registration.danger,
1111
+ placement: registration.placement,
1112
+ separatorBefore: registration.separatorBefore,
1113
+ visible: (browserContext) => {
1114
+ const context = createHarnessHierarchyContextActionContext(state, options, browserContext);
1115
+ if (!context)
1116
+ return false;
1117
+ return registration.visible?.(context) ?? true;
1118
+ },
1119
+ disabled: (browserContext) => {
1120
+ const context = createHarnessHierarchyContextActionContext(state, options, browserContext);
1121
+ if (!context)
1122
+ return 'No editable document is loaded.';
1123
+ return registration.disabled?.(context) ?? false;
1124
+ },
1125
+ }));
1126
+ }
1127
+ function createHarnessHierarchyContextActionContext(state, options, browserContext, payload) {
1128
+ const document = state.session?.getState().workingDocument ?? null;
1129
+ if (!document)
1130
+ return null;
1131
+ const activeId = browserContext.activeId;
1132
+ return {
1133
+ document,
1134
+ contextNodeId: browserContext.contextNodeId,
1135
+ targetIds: browserContext.targetIds,
1136
+ activeId,
1137
+ hierarchyItem: browserContext.node,
1138
+ projectionNode: browserContext.contextNodeId
1139
+ ? options.documentAdapter.getProjectionNode(document, browserContext.contextNodeId)
1140
+ : null,
1141
+ hostServices: options.hostServices ?? null,
1142
+ payload,
1143
+ browserContext,
1144
+ };
1145
+ }
1146
+ function createBrowserContextForCustomAction(state, options, action) {
1147
+ const document = state.session?.getState().workingDocument ?? null;
1148
+ if (!document)
1149
+ return null;
1150
+ const uiState = createUiState(state, options);
1151
+ const node = action.contextNodeId
1152
+ ? uiState.hierarchy.find(item => item.id === action.contextNodeId) ?? null
1153
+ : null;
1154
+ return {
1155
+ state: uiState,
1156
+ menuKind: node ? 'node' : 'blank',
1157
+ node,
1158
+ contextNodeId: action.contextNodeId,
1159
+ targetIds: action.targetIds,
1160
+ activeId: action.activeId,
1161
+ };
1162
+ }
1163
+ function runHierarchyCustomContextAction(state, options, action) {
1164
+ const registration = options.hierarchy?.contextActions?.find(candidate => candidate.id === action.id);
1165
+ if (!registration)
1166
+ return false;
1167
+ const browserContext = createBrowserContextForCustomAction(state, options, action);
1168
+ const context = browserContext
1169
+ ? createHarnessHierarchyContextActionContext(state, options, browserContext, action.payload)
1170
+ : null;
1171
+ if (!context)
1172
+ return false;
1173
+ try {
1174
+ return registration.run(context) === true;
1175
+ }
1176
+ catch (error) {
1177
+ const message = error instanceof Error ? error.message : String(error);
1178
+ state.status = `${registration.label} failed`;
1179
+ state.statusTone = 'error';
1180
+ state.statusToneStatus = state.status;
1181
+ state.statusDetails = message;
1182
+ console.error('[LocalEditorHarness] hierarchy context action failed', error);
1183
+ return true;
1184
+ }
1185
+ }
991
1186
  function handleContextAction(state, options, action) {
992
1187
  if (action.region !== 'hierarchy')
993
1188
  return false;
1189
+ if (action.action === 'custom')
1190
+ return runHierarchyCustomContextAction(state, options, action);
994
1191
  if (action.action === 'focus') {
995
1192
  const activeId = action.activeId ?? action.targetIds[action.targetIds.length - 1] ?? null;
996
1193
  const selectionChanged = activeId && !state.session?.getState().selection.selectedIds.includes(activeId)
@@ -1399,6 +1596,13 @@ function syncSelectionToProjection(state, selection) {
1399
1596
  state.projection?.syncSelection(selection);
1400
1597
  state.gizmo?.setSelection(selection);
1401
1598
  state.gizmo?.refreshSelection();
1599
+ requestEditorSceneFrame(state, 'projection-selection');
1600
+ }
1601
+ function invalidateEditorScene(state, reason, options) {
1602
+ if (options && state.mainCameraPreviewEnabled) {
1603
+ syncMainCameraPreview(state, options);
1604
+ }
1605
+ state.viewportRenderCoordinator?.invalidateScene(reason);
1402
1606
  }
1403
1607
  function executeTransformAction(state, options, action) {
1404
1608
  if (state.mode !== 'editor' || !state.session)
@@ -1501,11 +1705,12 @@ function armAssetPlacement(state, options, assetId) {
1501
1705
  if (state.mode !== 'editor')
1502
1706
  return false;
1503
1707
  cancelActiveOperation(state);
1504
- const asset = state.assets.find(candidate => resolveAssetId(options, candidate) === assetId);
1708
+ const asset = findAssetByResolvedId(state, options, assetId);
1505
1709
  if (!asset)
1506
1710
  return false;
1507
1711
  state.armedPlacement = { assetId, asset };
1508
1712
  state.gizmo?.setPlacementMarker(null);
1713
+ requestEditorSceneFrame(state, 'placement-arm-change');
1509
1714
  state.status = `Placement armed: ${formatAssetLabel(asset, assetId)} (${state.transformOperationSettings.placementMode})`;
1510
1715
  return true;
1511
1716
  }
@@ -1535,6 +1740,7 @@ function commitArmedPlacement(state, options, event) {
1535
1740
  if (!armed || !hit) {
1536
1741
  state.gizmo?.setPlacementMarker(null);
1537
1742
  state.status = 'Placement rejected: no hit';
1743
+ requestEditorSceneFrame(state, 'placement-marker-clear');
1538
1744
  return true;
1539
1745
  }
1540
1746
  const patch = options.documentAdapter.createPlacedAssetPatch?.({
@@ -1584,8 +1790,11 @@ function pickArmedPlacementHit(state, event) {
1584
1790
  : state.gizmo?.pickPlacementHit(event.clientX, event.clientY, mode) ?? null;
1585
1791
  }
1586
1792
  function clearArmedPlacement(state) {
1793
+ const hadArmedPlacement = !!state.armedPlacement;
1587
1794
  state.armedPlacement = null;
1588
1795
  state.gizmo?.setPlacementMarker(null);
1796
+ if (hadArmedPlacement)
1797
+ requestEditorSceneFrame(state, 'placement-clear');
1589
1798
  }
1590
1799
  function addAssetToDocument(state, options, assetId, createOptions = {}) {
1591
1800
  if (state.mode !== 'editor') {
@@ -1599,7 +1808,7 @@ function addAssetToDocument(state, options, assetId, createOptions = {}) {
1599
1808
  state.status = 'Asset creation rejected: document is not loaded';
1600
1809
  return { ok: false, assetId, changed: false, status: state.status, error: 'document_not_loaded' };
1601
1810
  }
1602
- const asset = state.assets.find(candidate => resolveAssetId(options, candidate) === assetId);
1811
+ const asset = findAssetByResolvedId(state, options, assetId);
1603
1812
  if (!asset) {
1604
1813
  state.status = `Asset creation rejected: ${assetId} not found`;
1605
1814
  return { ok: false, assetId, changed: false, status: state.status, error: 'asset_not_found' };
@@ -1636,6 +1845,7 @@ function addAssetToDocument(state, options, assetId, createOptions = {}) {
1636
1845
  state.projection?.projectNode(projectedNode);
1637
1846
  if (selectionResult)
1638
1847
  syncSelectionToProjection(state, selectionResult.selection);
1848
+ invalidateEditorScene(state, 'projection-project-node', options);
1639
1849
  }
1640
1850
  }
1641
1851
  return {
@@ -1976,6 +2186,8 @@ function focusProjectionNode(state, nodeId) {
1976
2186
  state.status = focused
1977
2187
  ? `Focused ${nodeId} · ${formatEditorStatusTime(Date.now())}`
1978
2188
  : `Focus failed: camera could not frame ${nodeId}`;
2189
+ if (focused)
2190
+ requestEditorSceneFrame(state, 'viewport-focus-selection');
1979
2191
  return true;
1980
2192
  }
1981
2193
  function formatEditorStatusTime(timestamp) {
@@ -2021,6 +2233,7 @@ function rebuildProjectionFromDocument(state, options, document, selection) {
2021
2233
  state.projection?.rebuild(options.documentAdapter.getProjectionNodes(document));
2022
2234
  const sanitized = sanitizeSelection(state, options, document, selection);
2023
2235
  syncSelectionToProjection(state, sanitized ?? selection);
2236
+ invalidateEditorScene(state, 'projection-rebuild', options);
2024
2237
  }
2025
2238
  function syncProjectionForDispatchResult(state, options, result, changedId) {
2026
2239
  if (result.documentChanged && changedId && result.workingDocument) {
@@ -2036,6 +2249,9 @@ function syncProjectionForDispatchResult(state, options, result, changedId) {
2036
2249
  const sanitized = result.workingDocument ? sanitizeSelection(state, options, result.workingDocument, selection) : null;
2037
2250
  syncSelectionToProjection(state, sanitized ?? selection);
2038
2251
  }
2252
+ if (result.documentChanged || result.selectionChanged) {
2253
+ invalidateEditorScene(state, 'projection-dispatch-result', options);
2254
+ }
2039
2255
  }
2040
2256
  function syncProjectionForChangedIds(state, options, document, changedIds) {
2041
2257
  for (const changedId of changedIds) {
@@ -2045,6 +2261,7 @@ function syncProjectionForChangedIds(state, options, document, changedIds) {
2045
2261
  }
2046
2262
  const selection = state.session?.getState().selection ?? { selectedIds: [], activeId: null };
2047
2263
  syncSelectionToProjection(state, selection);
2264
+ invalidateEditorScene(state, 'projection-sync-changed-ids', options);
2048
2265
  }
2049
2266
  function reprojectProjectionForChangedIds(state, options, document, changedIds) {
2050
2267
  for (const changedId of changedIds) {
@@ -2055,24 +2272,28 @@ function reprojectProjectionForChangedIds(state, options, document, changedIds)
2055
2272
  const selection = state.session?.getState().selection ?? { selectedIds: [], activeId: null };
2056
2273
  syncSelectionToProjection(state, selection);
2057
2274
  state.gizmo?.refreshSelection();
2275
+ invalidateEditorScene(state, 'projection-reproject-changed-ids', options);
2058
2276
  }
2059
- function setSceneCameraPreviewEnabled(state, options, enabled) {
2277
+ function setMainCameraPreviewEnabled(state, options, enabled) {
2060
2278
  if (!enabled) {
2061
- state.sceneCameraPreviewEnabled = false;
2062
- state.sceneCameraPreview?.setActive(false);
2063
- state.status = 'Scene Camera preview disabled';
2279
+ state.mainCameraPreviewEnabled = false;
2280
+ state.mainCameraPreview?.setActive(false);
2281
+ state.status = 'Main Camera preview disabled';
2282
+ requestEditorSceneFrame(state, 'main-camera-preview-toggle');
2064
2283
  return true;
2065
2284
  }
2066
- state.sceneCameraPreviewEnabled = true;
2067
- if (!syncSceneCameraPreview(state, options)) {
2068
- state.sceneCameraPreviewEnabled = false;
2069
- state.status = 'Scene Camera preview unavailable';
2285
+ state.mainCameraPreviewEnabled = true;
2286
+ if (!syncMainCameraPreview(state, options)) {
2287
+ state.mainCameraPreviewEnabled = false;
2288
+ state.status = 'Main Camera preview unavailable';
2070
2289
  state.statusTone = 'warning';
2071
2290
  state.statusToneStatus = state.status;
2072
2291
  state.statusDetails = 'The current document did not provide a Main Camera preview rig.';
2292
+ requestEditorSceneFrame(state, 'main-camera-preview-toggle');
2073
2293
  return true;
2074
2294
  }
2075
- state.status = 'Scene Camera preview enabled';
2295
+ state.status = 'Main Camera preview enabled';
2296
+ requestEditorSceneFrame(state, 'main-camera-preview-toggle');
2076
2297
  return true;
2077
2298
  }
2078
2299
  function setGridVisible(state, visible) {
@@ -2085,18 +2306,19 @@ function setGridVisible(state, visible) {
2085
2306
  state.statusTone = 'default';
2086
2307
  state.statusToneStatus = state.status;
2087
2308
  state.statusDetails = '';
2309
+ requestEditorSceneFrame(state, 'grid-visible-change');
2088
2310
  return true;
2089
2311
  }
2090
- function syncSceneCameraPreview(state, options) {
2091
- const controller = state.sceneCameraPreview;
2312
+ function syncMainCameraPreview(state, options) {
2313
+ const controller = state.mainCameraPreview;
2092
2314
  if (!controller)
2093
2315
  return false;
2094
- if (!state.sceneCameraPreviewEnabled) {
2316
+ if (!state.mainCameraPreviewEnabled) {
2095
2317
  controller.setActive(false);
2096
2318
  return false;
2097
2319
  }
2098
2320
  const document = state.session?.getState().workingDocument ?? null;
2099
- const rig = document ? options.documentAdapter.getSceneCameraPreviewRig?.(document) ?? null : null;
2321
+ const rig = document ? options.documentAdapter.getMainCameraPreviewRig?.(document) ?? null : null;
2100
2322
  if (!rig) {
2101
2323
  controller.setActive(false);
2102
2324
  return false;
@@ -2115,6 +2337,11 @@ function syncViewportCameraState(state) {
2115
2337
  };
2116
2338
  }
2117
2339
  function syncViewportSpatialOverlay(state) {
2340
+ const settings = state.viewportTools.overlay;
2341
+ if (!settings.bounds && !settings.dimensions && !settings.edgeLengths && !settings.anchor) {
2342
+ state.viewportSpatialOverlay = createEmptyEditorViewportSpatialOverlayState();
2343
+ return;
2344
+ }
2118
2345
  const overlay = state.sceneViewSpatialOverlay;
2119
2346
  const selection = state.session?.getState().selection;
2120
2347
  const activeId = selection?.activeId ?? null;
@@ -2128,7 +2355,7 @@ function syncViewportSpatialOverlay(state) {
2128
2355
  nodeId: activeId,
2129
2356
  bounds,
2130
2357
  anchor,
2131
- settings: state.viewportTools.overlay,
2358
+ settings,
2132
2359
  });
2133
2360
  }
2134
2361
  function syncViewportMeasurementState(state) {
@@ -2138,9 +2365,9 @@ function syncViewportMeasurementState(state) {
2138
2365
  if (measurement)
2139
2366
  state.viewportMeasurement = measurement;
2140
2367
  }
2141
- function hasSceneCameraPreviewRig(state, options) {
2368
+ function hasMainCameraPreviewRig(state, options) {
2142
2369
  const document = state.session?.getState().workingDocument ?? null;
2143
- return !!document && !!options.documentAdapter.getSceneCameraPreviewRig?.(document);
2370
+ return !!document && !!options.documentAdapter.getMainCameraPreviewRig?.(document);
2144
2371
  }
2145
2372
  function createUiState(state, options) {
2146
2373
  const sessionState = state.session?.getState();
@@ -2165,6 +2392,8 @@ function createUiState(state, options) {
2165
2392
  const inspectorMultiObject = document && inspectorMultiObjectBase
2166
2393
  ? withRuntimeInspectorSections(state, options, document, inspectorMultiObjectBase)
2167
2394
  : inspectorMultiObjectBase;
2395
+ const assets = dedupeLocalEditorBrowserAssetItems(state.assets
2396
+ .map(asset => toBrowserAssetItem(options, asset)));
2168
2397
  return {
2169
2398
  mode: state.mode,
2170
2399
  busy: state.busy,
@@ -2173,10 +2402,8 @@ function createUiState(state, options) {
2173
2402
  statusDetails: state.statusToneStatus === state.status ? state.statusDetails : '',
2174
2403
  summary: state.summary,
2175
2404
  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`,
2405
+ assets,
2406
+ assetCountLabel: `${assets.length} assets`,
2180
2407
  hierarchy: document ? options.documentAdapter.getHierarchyItems(document) : [],
2181
2408
  selectedIds,
2182
2409
  activeId,
@@ -2209,9 +2436,12 @@ function createUiState(state, options) {
2209
2436
  viewportTools: cloneEditorViewportToolState(state.viewportTools),
2210
2437
  viewportMeasurement: cloneViewportMeasurement(state.viewportMeasurement),
2211
2438
  viewportSpatialOverlay: cloneEditorViewportSpatialOverlayState(state.viewportSpatialOverlay),
2212
- sceneCameraPreview: {
2213
- enabled: state.sceneCameraPreviewEnabled,
2214
- available: hasSceneCameraPreviewRig(state, options),
2439
+ sceneFrameStats: state.sceneFrameStats
2440
+ ? { ...state.sceneFrameStats, activeReasons: [...state.sceneFrameStats.activeReasons] }
2441
+ : null,
2442
+ mainCameraPreview: {
2443
+ enabled: state.mainCameraPreviewEnabled,
2444
+ available: hasMainCameraPreviewRig(state, options),
2215
2445
  },
2216
2446
  grid: {
2217
2447
  visible: state.gridVisible,
@@ -2242,6 +2472,8 @@ function createSceneViewCoordinateAxesState(state) {
2242
2472
  if (!right || !up || !forward)
2243
2473
  return null;
2244
2474
  return {
2475
+ projectionMode: readSceneViewFreeCameraProjectionMode(state, camera),
2476
+ projectionToggleDisabled: state.mainCameraPreviewEnabled,
2245
2477
  axes: [
2246
2478
  createSceneViewCoordinateAxis('x', 'X', '#ff4b70', { x: 1, y: 0, z: 0 }, right, up, forward),
2247
2479
  createSceneViewCoordinateAxis('y', 'Y', '#75ff42', { x: 0, y: 1, z: 0 }, right, up, forward),
@@ -2249,6 +2481,14 @@ function createSceneViewCoordinateAxesState(state) {
2249
2481
  ],
2250
2482
  };
2251
2483
  }
2484
+ function readSceneViewFreeCameraProjectionMode(state, camera) {
2485
+ return state.sceneViewCamera?.getState().projectionMode
2486
+ ?? readSceneViewCameraProjectionMode(state, state.world?.camera ?? camera);
2487
+ }
2488
+ function readSceneViewCameraProjectionMode(state, camera) {
2489
+ const orthoMode = state.babylon?.Camera?.ORTHOGRAPHIC_CAMERA ?? 1;
2490
+ return camera?.mode === orthoMode ? 'orthographic' : 'perspective';
2491
+ }
2252
2492
  function createSceneViewCoordinateAxis(id, label, color, worldAxis, cameraRight, cameraUp, cameraForward) {
2253
2493
  const screenX = dotSceneViewAxisVec3(worldAxis, cameraRight);
2254
2494
  const screenY = -dotSceneViewAxisVec3(worldAxis, cameraUp);
@@ -2382,9 +2622,10 @@ function createDefaultRuntimeInspectorSections(context) {
2382
2622
  const properties = [];
2383
2623
  if (projectionNode) {
2384
2624
  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));
2625
+ const projectionAsset = projectionNode.asset;
2626
+ const assetId = projectionAsset?.assetId ?? projectionAsset?.id ?? '';
2627
+ if (assetId)
2628
+ properties.push(createRuntimeInspectorProperty('runtime.projection.assetId', 'Asset ID', assetId, properties.length));
2388
2629
  }
2389
2630
  const runtimeClass = readRuntimeClassName(root);
2390
2631
  if (runtimeClass)
@@ -2501,15 +2742,114 @@ function isNodeSelectableInDocument(options, document, id) {
2501
2742
  }
2502
2743
  function resolveAssetId(options, asset) {
2503
2744
  return options.worldAdapter.resolveAssetId?.(asset)
2745
+ ?? asset.assetId
2504
2746
  ?? asset.id;
2505
2747
  }
2748
+ function findAssetByResolvedId(state, options, assetId) {
2749
+ let selected = null;
2750
+ for (const asset of state.assets) {
2751
+ if (resolveAssetId(options, asset) !== assetId)
2752
+ continue;
2753
+ const item = toBrowserAssetItem(options, asset);
2754
+ if (!selected || shouldReplaceBrowserAssetItem(selected.item, item)) {
2755
+ selected = { asset, item };
2756
+ }
2757
+ }
2758
+ return selected?.asset;
2759
+ }
2506
2760
  function toBrowserAssetItem(options, asset) {
2507
2761
  return options.worldAdapter.toBrowserAssetItem?.(asset)
2508
2762
  ?? {
2509
- id: asset.id,
2510
- label: asset.label,
2763
+ id: asset.assetId ?? asset.id,
2764
+ label: asset.displayName ?? asset.label,
2765
+ guid: asset.guid,
2766
+ assetId: asset.assetId,
2767
+ kind: asset.kind,
2768
+ external: asset.external,
2769
+ origin: asset.origin,
2770
+ dedupeKey: asset.dedupeKey,
2771
+ placeable: asset.placeable,
2511
2772
  meta: asset.meta,
2512
- disabled: asset.placeable === false,
2773
+ disabled: asset.disabled ?? asset.placeable === false,
2513
2774
  };
2514
2775
  }
2776
+ export function dedupeLocalEditorBrowserAssetItems(items) {
2777
+ const byKey = new Map();
2778
+ for (const item of items) {
2779
+ const key = getBrowserAssetCanonicalKey(item);
2780
+ const existing = byKey.get(key);
2781
+ if (!existing || shouldReplaceBrowserAssetItem(existing, item)) {
2782
+ byKey.set(key, item);
2783
+ }
2784
+ }
2785
+ const projectDuplicateKeys = new Set();
2786
+ for (const item of byKey.values()) {
2787
+ if (!item.guid || item.origin !== 'project')
2788
+ continue;
2789
+ for (const duplicateKey of getBrowserAssetDuplicateSuppressionKeys(item)) {
2790
+ projectDuplicateKeys.add(duplicateKey);
2791
+ }
2792
+ }
2793
+ return [...byKey.values()].filter((item) => {
2794
+ if (item.guid)
2795
+ return true;
2796
+ return !getBrowserAssetDuplicateSuppressionKeys(item)
2797
+ .some(duplicateKey => projectDuplicateKeys.has(duplicateKey));
2798
+ });
2799
+ }
2800
+ function getBrowserAssetCanonicalKey(item) {
2801
+ const guid = normalizeBrowserAssetString(item.guid);
2802
+ if (guid)
2803
+ return `guid:${guid}`;
2804
+ const platformAssetId = normalizeBrowserAssetString(item.external?.platformAssetId);
2805
+ if (platformAssetId)
2806
+ return `external:${platformAssetId}`;
2807
+ const dedupeKey = normalizeBrowserAssetString(item.dedupeKey);
2808
+ if (dedupeKey)
2809
+ return `dedupe:${dedupeKey}`;
2810
+ const assetId = normalizeBrowserAssetString(item.assetId);
2811
+ if (assetId)
2812
+ return `asset:${assetId}`;
2813
+ return `id:${normalizeBrowserAssetString(item.id) ?? item.id}`;
2814
+ }
2815
+ function getBrowserAssetDuplicateSuppressionKeys(item) {
2816
+ const keys = [];
2817
+ pushBrowserAssetDuplicateKey(keys, 'external', item.external?.platformAssetId);
2818
+ pushBrowserAssetDuplicateKey(keys, 'dedupe', item.dedupeKey);
2819
+ pushBrowserAssetDuplicateKey(keys, 'asset', item.assetId);
2820
+ pushBrowserAssetDuplicateKey(keys, 'id', item.id);
2821
+ return keys;
2822
+ }
2823
+ function pushBrowserAssetDuplicateKey(keys, prefix, value) {
2824
+ const normalized = normalizeBrowserAssetString(value);
2825
+ if (normalized)
2826
+ keys.push(`${prefix}:${normalized}`);
2827
+ }
2828
+ function shouldReplaceBrowserAssetItem(current, candidate) {
2829
+ const currentScore = getBrowserAssetPreferenceScore(current);
2830
+ const candidateScore = getBrowserAssetPreferenceScore(candidate);
2831
+ if (candidateScore !== currentScore)
2832
+ return candidateScore > currentScore;
2833
+ return getBrowserAssetDetailScore(candidate) > getBrowserAssetDetailScore(current);
2834
+ }
2835
+ function getBrowserAssetPreferenceScore(item) {
2836
+ return (item.origin === 'project' ? 1000 : 0)
2837
+ + (item.guid ? 100 : 0)
2838
+ + (item.assetId ? 40 : 0)
2839
+ + (item.placeable === false ? 0 : 10)
2840
+ + (item.disabled ? 0 : 5);
2841
+ }
2842
+ function getBrowserAssetDetailScore(item) {
2843
+ return [
2844
+ item.label,
2845
+ item.meta,
2846
+ item.kind,
2847
+ item.external?.platformAssetId,
2848
+ item.external?.assetPath,
2849
+ item.external?.assetUrl,
2850
+ ].filter(value => typeof value === 'string' && value.trim()).length;
2851
+ }
2852
+ function normalizeBrowserAssetString(value) {
2853
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
2854
+ }
2515
2855
  //# sourceMappingURL=local-editor-harness.js.map