@fps-games/editor 0.1.1-beta.1 → 0.1.1-beta.11

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 (162) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/local-editor-harness.d.ts +127 -6
  5. package/dist/local-editor-harness.d.ts.map +1 -1
  6. package/dist/local-editor-harness.js +1364 -66
  7. package/dist/local-editor-harness.js.map +1 -1
  8. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts +33 -0
  9. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts.map +1 -0
  10. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js +231 -0
  11. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js.map +1 -0
  12. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts +3 -0
  13. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts.map +1 -1
  14. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js +8 -0
  15. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js.map +1 -1
  16. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts +5 -0
  17. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts.map +1 -1
  18. package/node_modules/@fps-games/editor-babylon/dist/index.js +5 -0
  19. package/node_modules/@fps-games/editor-babylon/dist/index.js.map +1 -1
  20. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts +40 -0
  21. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts.map +1 -0
  22. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js +237 -0
  23. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js.map +1 -0
  24. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.d.ts +1 -1
  25. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.d.ts.map +1 -1
  26. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.js +15 -50
  27. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.js.map +1 -1
  28. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts +27 -0
  29. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts.map +1 -1
  30. package/node_modules/@fps-games/editor-babylon/dist/projection.js +368 -14
  31. package/node_modules/@fps-games/editor-babylon/dist/projection.js.map +1 -1
  32. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.d.ts +25 -0
  33. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.d.ts.map +1 -0
  34. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.js +176 -0
  35. package/node_modules/@fps-games/editor-babylon/dist/scene-camera-preview.js.map +1 -0
  36. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts +18 -0
  37. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts.map +1 -1
  38. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js +142 -0
  39. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js.map +1 -1
  40. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts +2 -0
  41. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts.map +1 -1
  42. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js +6 -0
  43. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js.map +1 -1
  44. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.d.ts +20 -0
  45. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.d.ts.map +1 -0
  46. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.js +187 -0
  47. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.js.map +1 -0
  48. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.d.ts +20 -0
  49. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.d.ts.map +1 -0
  50. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.js +188 -0
  51. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.js.map +1 -0
  52. package/node_modules/@fps-games/editor-babylon/dist/transform-gizmo-controller.d.ts +22 -2
  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 +469 -90
  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/types.d.ts +20 -0
  57. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  58. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  59. package/node_modules/@fps-games/editor-browser/dist/index.d.ts +2 -0
  60. package/node_modules/@fps-games/editor-browser/dist/index.d.ts.map +1 -1
  61. package/node_modules/@fps-games/editor-browser/dist/index.js +1 -0
  62. package/node_modules/@fps-games/editor-browser/dist/index.js.map +1 -1
  63. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.d.ts +3 -1
  64. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.d.ts.map +1 -1
  65. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.js +103 -48
  66. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.js.map +1 -1
  67. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts +17 -1
  68. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts.map +1 -1
  69. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js +327 -33
  70. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js.map +1 -1
  71. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts +3 -1
  72. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts.map +1 -1
  73. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js +143 -17
  74. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js.map +1 -1
  75. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-tree.js +0 -2
  76. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-tree.js.map +1 -1
  77. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.d.ts.map +1 -1
  78. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js +27 -20
  79. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js.map +1 -1
  80. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts +10 -0
  81. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts.map +1 -0
  82. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js +253 -0
  83. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js.map +1 -0
  84. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts +8 -1
  85. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts.map +1 -1
  86. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js +414 -111
  87. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js.map +1 -1
  88. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-primitives.d.ts +13 -3
  89. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-primitives.d.ts.map +1 -1
  90. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-primitives.js +107 -14
  91. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-primitives.js.map +1 -1
  92. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts +20 -3
  93. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts.map +1 -1
  94. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js +127 -16
  95. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js.map +1 -1
  96. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.d.ts.map +1 -1
  97. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js +13 -4
  98. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js.map +1 -1
  99. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.d.ts +4 -0
  100. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.d.ts.map +1 -1
  101. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.js +114 -9
  102. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.js.map +1 -1
  103. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.d.ts +11 -0
  104. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.d.ts.map +1 -0
  105. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js +148 -0
  106. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js.map +1 -0
  107. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +153 -3
  108. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  109. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts +35 -0
  110. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts.map +1 -1
  111. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js +376 -2
  112. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js.map +1 -1
  113. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +11 -6
  114. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  115. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +1617 -110
  116. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  117. package/node_modules/@fps-games/editor-browser/package.json +4 -1
  118. package/node_modules/@fps-games/editor-core/dist/editor-session.d.ts +4 -1
  119. package/node_modules/@fps-games/editor-core/dist/editor-session.d.ts.map +1 -1
  120. package/node_modules/@fps-games/editor-core/dist/editor-session.js +13 -3
  121. package/node_modules/@fps-games/editor-core/dist/editor-session.js.map +1 -1
  122. package/node_modules/@fps-games/editor-core/dist/index.d.ts +3 -0
  123. package/node_modules/@fps-games/editor-core/dist/index.d.ts.map +1 -1
  124. package/node_modules/@fps-games/editor-core/dist/index.js +3 -0
  125. package/node_modules/@fps-games/editor-core/dist/index.js.map +1 -1
  126. package/node_modules/@fps-games/editor-core/dist/inspector.d.ts +6 -1
  127. package/node_modules/@fps-games/editor-core/dist/inspector.d.ts.map +1 -1
  128. package/node_modules/@fps-games/editor-core/dist/inspector.js +6 -1
  129. package/node_modules/@fps-games/editor-core/dist/inspector.js.map +1 -1
  130. package/node_modules/@fps-games/editor-core/dist/scene-graph.d.ts +7 -0
  131. package/node_modules/@fps-games/editor-core/dist/scene-graph.d.ts.map +1 -1
  132. package/node_modules/@fps-games/editor-core/dist/scene-graph.js.map +1 -1
  133. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts +1 -1
  134. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts.map +1 -1
  135. package/node_modules/@fps-games/editor-core/dist/selection-strategy.d.ts +12 -0
  136. package/node_modules/@fps-games/editor-core/dist/selection-strategy.d.ts.map +1 -0
  137. package/node_modules/@fps-games/editor-core/dist/selection-strategy.js +52 -0
  138. package/node_modules/@fps-games/editor-core/dist/selection-strategy.js.map +1 -0
  139. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.d.ts +78 -2
  140. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.d.ts.map +1 -1
  141. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.js +81 -1
  142. package/node_modules/@fps-games/editor-core/dist/transform-gizmo.js.map +1 -1
  143. package/node_modules/@fps-games/editor-core/dist/transform-operations.d.ts +32 -0
  144. package/node_modules/@fps-games/editor-core/dist/transform-operations.d.ts.map +1 -0
  145. package/node_modules/@fps-games/editor-core/dist/transform-operations.js +125 -0
  146. package/node_modules/@fps-games/editor-core/dist/transform-operations.js.map +1 -0
  147. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts +94 -0
  148. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts.map +1 -0
  149. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js +58 -0
  150. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js.map +1 -0
  151. package/node_modules/@fps-games/editor-core/package.json +2 -2
  152. package/node_modules/@fps-games/editor-forge-play/dist/index.d.ts +1 -1
  153. package/node_modules/@fps-games/editor-forge-play/dist/index.d.ts.map +1 -1
  154. package/node_modules/@fps-games/editor-forge-play/dist/index.js +5 -1
  155. package/node_modules/@fps-games/editor-forge-play/dist/index.js.map +1 -1
  156. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  157. package/node_modules/@fps-games/editor-protocol/dist/index.d.ts +4 -0
  158. package/node_modules/@fps-games/editor-protocol/dist/index.d.ts.map +1 -1
  159. package/node_modules/@fps-games/editor-protocol/dist/index.js +4 -0
  160. package/node_modules/@fps-games/editor-protocol/dist/index.js.map +1 -1
  161. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  162. package/package.json +6 -6
@@ -1,6 +1,6 @@
1
- import { createEditorSession, createInspectorRegistry, createInspectorEditPayload, mergeInspectorSections, serializedMultiObjectToInspectorObject, serializedObjectToInspectorObject, validateSceneGraphDelete, validateSceneGraphDrop, validateSceneGraphGroupSelection, validateSceneGraphMove, validateSceneGraphRename, } from '@fps-games/editor-core';
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, createBabylonSceneViewCameraController, createBabylonSceneViewInputController, createBabylonTransformGizmoController, focusEditorViewportSelection, } from '@fps-games/editor-babylon';
3
+ import { createBabylonEditorProjection, createBabylonEditorWorld, createBabylonProjectionSelectionController, createBabylonSceneCameraPreviewController, createBabylonSceneViewCameraController, createBabylonSceneViewInputController, createBabylonSceneViewMeasurementController, createBabylonSceneViewSpatialOverlayController, createBabylonTransformGizmoController, focusEditorViewportSelection, } from '@fps-games/editor-babylon';
4
4
  export function createLocalEditorHarness(options) {
5
5
  const root = options.root ?? document.body;
6
6
  const state = {
@@ -13,15 +13,27 @@ export function createLocalEditorHarness(options) {
13
13
  babylon: null,
14
14
  engine: null,
15
15
  world: null,
16
+ grid: null,
17
+ gridVisible: true,
16
18
  projection: null,
17
19
  gizmo: null,
20
+ sceneCameraPreview: null,
21
+ sceneCameraPreviewEnabled: false,
18
22
  sceneViewInput: null,
19
23
  sceneViewCamera: null,
24
+ sceneViewMeasurement: null,
25
+ sceneViewSpatialOverlay: null,
20
26
  selectionController: null,
21
27
  boxSelection: null,
22
28
  transformTool: 'select',
23
29
  transformSpace: 'world',
24
30
  transformConstraint: 'axis',
31
+ transformOperationSettings: cloneTransformOperationSettings(DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS),
32
+ viewportTools: cloneEditorViewportToolState(DEFAULT_EDITOR_VIEWPORT_TOOL_STATE),
33
+ viewportMeasurement: createEmptyViewportMeasurement(),
34
+ viewportSpatialOverlay: createEmptyEditorViewportSpatialOverlayState(),
35
+ duplicateDrag: null,
36
+ armedPlacement: null,
25
37
  resizeHandler: null,
26
38
  status: 'Game running',
27
39
  statusTone: 'default',
@@ -32,7 +44,12 @@ export function createLocalEditorHarness(options) {
32
44
  let harness;
33
45
  const ui = createLocalEditorBrowserUi({
34
46
  root,
47
+ theme: options.theme,
48
+ localTestActions: options.localTestActions,
35
49
  inspector: options.inspector,
50
+ hierarchy: {
51
+ contextActions: createBrowserHierarchyContextActions(state, options),
52
+ },
36
53
  callbacks: {
37
54
  onEnterEditor: () => {
38
55
  void runExclusive(state, harness.render, () => harness.enterEditor());
@@ -55,13 +72,17 @@ export function createLocalEditorHarness(options) {
55
72
  harness.render();
56
73
  },
57
74
  onCreateFromAsset: (assetId) => {
58
- if (addAssetToDocument(state, options, assetId))
75
+ if (createAssetFromBrowserIntent(state, options, assetId))
59
76
  harness.render();
60
77
  },
61
78
  onSelectHierarchyItem: (input) => {
62
79
  if (selectItem(state, options, input))
63
80
  harness.render();
64
81
  },
82
+ onSelectionCommand: (command) => {
83
+ if (dispatchSelectionCommand(state, options, command))
84
+ harness.render();
85
+ },
65
86
  onSceneGraphRename: (intent) => {
66
87
  if (renameSceneGraphNode(state, options, intent))
67
88
  harness.render();
@@ -70,10 +91,25 @@ export function createLocalEditorHarness(options) {
70
91
  if (createSceneGraphGroup(state, options, intent))
71
92
  harness.render();
72
93
  },
94
+ onSceneGraphCreatePrimitive: options.documentAdapter.createSceneGraphCreatePrimitivePatch
95
+ ? (intent) => {
96
+ if (createSceneGraphPrimitive(state, options, intent))
97
+ harness.render();
98
+ }
99
+ : undefined,
73
100
  onSceneGraphDelete: (intent) => {
74
101
  if (deleteSceneGraphNodes(state, options, intent))
75
102
  harness.render();
76
103
  },
104
+ onSceneGraphDuplicate: options.documentAdapter.createDuplicateSelectionPatch
105
+ ? (intent) => {
106
+ if (duplicateSceneGraphNodes(state, options, {
107
+ targetIds: intent.targetIds,
108
+ activeId: intent.activeId ?? null,
109
+ }))
110
+ harness.render();
111
+ }
112
+ : undefined,
77
113
  onSceneGraphDrop: (intent) => {
78
114
  if (dropSceneGraphNode(state, options, intent))
79
115
  harness.render();
@@ -102,7 +138,9 @@ export function createLocalEditorHarness(options) {
102
138
  },
103
139
  onTransformToolChange: (tool) => {
104
140
  state.transformTool = tool;
141
+ state.transformConstraint = normalizeTransformConstraint(tool, state.transformConstraint);
105
142
  state.gizmo?.setTool(tool);
143
+ state.gizmo?.setConstraint(state.transformConstraint);
106
144
  harness.render();
107
145
  },
108
146
  onTransformSpaceChange: (space) => {
@@ -111,14 +149,87 @@ export function createLocalEditorHarness(options) {
111
149
  harness.render();
112
150
  },
113
151
  onTransformConstraintChange: (constraint) => {
114
- state.transformConstraint = constraint;
115
- state.gizmo?.setConstraint(constraint);
152
+ state.transformConstraint = normalizeTransformConstraint(state.transformTool, constraint);
153
+ state.gizmo?.setConstraint(state.transformConstraint);
154
+ harness.render();
155
+ },
156
+ onTransformSnapEnabledChange: (enabled) => {
157
+ state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, {
158
+ snap: {
159
+ ...state.transformOperationSettings.snap,
160
+ enabled,
161
+ },
162
+ });
163
+ state.gizmo?.setOperationSettings(state.transformOperationSettings);
164
+ state.status = enabled ? 'Transform snap enabled' : 'Transform snap disabled';
116
165
  harness.render();
117
166
  },
167
+ onTransformSnapStepChange: (input) => {
168
+ const value = normalizePositiveStep(input.value);
169
+ if (value == null)
170
+ return;
171
+ const snap = { ...state.transformOperationSettings.snap };
172
+ if (input.kind === 'move')
173
+ snap.moveStep = value;
174
+ else if (input.kind === 'rotate')
175
+ snap.rotateStepDegrees = value;
176
+ else
177
+ snap.scaleStep = value;
178
+ state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, { snap });
179
+ state.gizmo?.setOperationSettings(state.transformOperationSettings);
180
+ state.status = `Transform snap ${input.kind} step ${value}`;
181
+ harness.render();
182
+ },
183
+ onPlacementModeChange: (mode) => {
184
+ state.transformOperationSettings = updateTransformOperationSettings(state.transformOperationSettings, {
185
+ placementMode: normalizePlacementMode(mode),
186
+ });
187
+ state.gizmo?.setOperationSettings(state.transformOperationSettings);
188
+ if (state.transformOperationSettings.placementMode === 'off')
189
+ clearArmedPlacement(state);
190
+ state.status = `Placement mode: ${state.transformOperationSettings.placementMode}`;
191
+ harness.render();
192
+ },
193
+ onTransformAction: (action) => {
194
+ if (executeTransformAction(state, options, action))
195
+ harness.render();
196
+ },
197
+ onViewportViewPresetChange: (preset) => {
198
+ if (setViewportViewPreset(state, preset))
199
+ harness.render();
200
+ },
201
+ onViewportProjectionModeChange: (mode) => {
202
+ if (setViewportProjectionMode(state, mode))
203
+ harness.render();
204
+ },
205
+ onViewportOverlaySettingsChange: (settings) => {
206
+ if (setViewportOverlaySettings(state, settings))
207
+ harness.render();
208
+ },
209
+ onViewportUtilityToolChange: (tool) => {
210
+ if (setViewportUtilityTool(state, tool))
211
+ harness.render();
212
+ },
213
+ onViewportMeasurementClear: () => {
214
+ if (clearViewportMeasurement(state))
215
+ harness.render();
216
+ },
217
+ onSceneCameraPreviewToggle: (enabled) => {
218
+ if (setSceneCameraPreviewEnabled(state, options, enabled))
219
+ harness.render();
220
+ },
221
+ onGridVisibleChange: (visible) => {
222
+ if (setGridVisible(state, visible))
223
+ harness.render();
224
+ },
118
225
  onFocusSelection: () => {
119
226
  if (focusSelectedProjection(state))
120
227
  harness.render();
121
228
  },
229
+ onCancelEditorIntent: () => {
230
+ if (cancelEditorIntent(state, options))
231
+ harness.render();
232
+ },
122
233
  onCancelActiveOperation: () => {
123
234
  cancelActiveOperation(state);
124
235
  harness.render();
@@ -127,14 +238,59 @@ export function createLocalEditorHarness(options) {
127
238
  });
128
239
  harness = {
129
240
  render() {
241
+ syncSceneCameraPreview(state, options);
242
+ syncViewportCameraState(state);
243
+ syncViewportMeasurementState(state);
244
+ syncViewportSpatialOverlay(state);
130
245
  ui.update(createUiState(state, options));
131
246
  },
247
+ setTheme(theme) {
248
+ ui.setTheme(theme);
249
+ },
250
+ getTheme() {
251
+ return ui.getTheme();
252
+ },
132
253
  getHostServices() {
133
254
  return options.hostServices ?? null;
134
255
  },
135
256
  getWorkingDocument() {
136
257
  return state.session?.getState().workingDocument ?? null;
137
258
  },
259
+ async reloadAssets() {
260
+ try {
261
+ const assets = await options.persistenceAdapter.loadAssets();
262
+ state.assets = assets;
263
+ clearArmedPlacement(state);
264
+ state.status = `Assets reloaded; assets=${assets.length}`;
265
+ state.statusTone = 'success';
266
+ state.statusToneStatus = state.status;
267
+ state.statusDetails = '';
268
+ harness.render();
269
+ return {
270
+ ok: true,
271
+ assetCount: assets.length,
272
+ status: state.status,
273
+ };
274
+ }
275
+ catch (error) {
276
+ state.status = 'Asset reload failed';
277
+ state.statusTone = 'error';
278
+ state.statusToneStatus = state.status;
279
+ state.statusDetails = error instanceof Error ? error.message : String(error);
280
+ harness.render();
281
+ return {
282
+ ok: false,
283
+ assetCount: state.assets.length,
284
+ status: state.status,
285
+ error: state.statusDetails,
286
+ };
287
+ }
288
+ },
289
+ createAssetFromAssetId(assetId, createOptions) {
290
+ const result = addAssetToDocument(state, options, assetId, createOptions);
291
+ harness.render();
292
+ return result;
293
+ },
138
294
  async enterEditor() {
139
295
  const loadedSource = options.persistenceAdapter.loadAuthoringSource
140
296
  ? await options.persistenceAdapter.loadAuthoringSource()
@@ -268,6 +424,267 @@ export function mergeLocalEditorHarnessInspectorComponentSections(input) {
268
424
  }),
269
425
  };
270
426
  }
427
+ function normalizeTransformConstraint(tool, constraint) {
428
+ return normalizeEditorTransformConstraint(tool, constraint) ?? 'axis';
429
+ }
430
+ function cloneTransformOperationSettings(settings) {
431
+ return {
432
+ snap: {
433
+ enabled: settings.snap.enabled,
434
+ moveStep: settings.snap.moveStep,
435
+ rotateStepDegrees: settings.snap.rotateStepDegrees,
436
+ scaleStep: settings.snap.scaleStep,
437
+ },
438
+ placementMode: settings.placementMode,
439
+ };
440
+ }
441
+ function updateTransformOperationSettings(current, patch) {
442
+ return {
443
+ ...current,
444
+ ...patch,
445
+ snap: patch.snap
446
+ ? { ...current.snap, ...patch.snap }
447
+ : { ...current.snap },
448
+ };
449
+ }
450
+ function normalizePositiveStep(value) {
451
+ if (!Number.isFinite(value) || value <= 0)
452
+ return null;
453
+ return Number(value.toFixed(4));
454
+ }
455
+ function normalizePlacementMode(mode) {
456
+ return mode === 'ground' || mode === 'surface' ? mode : 'off';
457
+ }
458
+ function normalizeViewportViewPreset(preset) {
459
+ return preset === 'top' || preset === 'front' || preset === 'right' ? preset : 'perspective';
460
+ }
461
+ function normalizeViewportProjectionMode(mode) {
462
+ return mode === 'orthographic' ? 'orthographic' : 'perspective';
463
+ }
464
+ function createEmptyViewportMeasurement() {
465
+ return {
466
+ active: false,
467
+ start: null,
468
+ end: null,
469
+ preview: null,
470
+ distance: null,
471
+ screenStart: null,
472
+ screenEnd: null,
473
+ screenPreview: null,
474
+ label: null,
475
+ };
476
+ }
477
+ function cloneViewportMeasurement(measurement) {
478
+ return {
479
+ active: measurement.active,
480
+ start: measurement.start ? { ...measurement.start } : null,
481
+ end: measurement.end ? { ...measurement.end } : null,
482
+ preview: measurement.preview ? { ...measurement.preview } : null,
483
+ distance: measurement.distance,
484
+ screenStart: measurement.screenStart ? { ...measurement.screenStart } : null,
485
+ screenEnd: measurement.screenEnd ? { ...measurement.screenEnd } : null,
486
+ screenPreview: measurement.screenPreview ? { ...measurement.screenPreview } : null,
487
+ label: measurement.label ? { ...measurement.label } : null,
488
+ };
489
+ }
490
+ function setViewportViewPreset(state, preset) {
491
+ if (state.sceneCameraPreviewEnabled) {
492
+ state.status = 'Viewport view unavailable during Scene Camera preview';
493
+ state.statusTone = 'warning';
494
+ state.statusToneStatus = state.status;
495
+ state.statusDetails = 'Disable Scene Camera preview before switching editor viewport views.';
496
+ return true;
497
+ }
498
+ const nextPreset = normalizeViewportViewPreset(preset);
499
+ const target = resolveViewportPresetTarget(state);
500
+ const radius = resolveViewportPresetRadius(state);
501
+ const changed = state.sceneViewCamera?.setViewPreset(nextPreset, { target, radius }) ?? false;
502
+ if (!changed)
503
+ return false;
504
+ const cameraState = state.sceneViewCamera?.getState();
505
+ state.viewportTools = {
506
+ ...state.viewportTools,
507
+ viewPreset: cameraState?.viewPreset ?? nextPreset,
508
+ projectionMode: cameraState?.projectionMode ?? (nextPreset === 'perspective' ? 'perspective' : 'orthographic'),
509
+ };
510
+ state.status = `Viewport view: ${state.viewportTools.viewPreset}`;
511
+ state.statusTone = 'default';
512
+ state.statusToneStatus = state.status;
513
+ state.statusDetails = '';
514
+ return true;
515
+ }
516
+ function setViewportProjectionMode(state, mode) {
517
+ if (state.sceneCameraPreviewEnabled) {
518
+ state.status = 'Viewport projection unavailable during Scene Camera preview';
519
+ state.statusTone = 'warning';
520
+ state.statusToneStatus = state.status;
521
+ state.statusDetails = 'Disable Scene Camera preview before switching editor viewport projection.';
522
+ return true;
523
+ }
524
+ const nextMode = normalizeViewportProjectionMode(mode);
525
+ const changed = state.sceneViewCamera?.setProjectionMode(nextMode) ?? false;
526
+ if (!changed)
527
+ return false;
528
+ const cameraState = state.sceneViewCamera?.getState();
529
+ state.viewportTools = {
530
+ ...state.viewportTools,
531
+ projectionMode: cameraState?.projectionMode ?? nextMode,
532
+ };
533
+ state.status = `Viewport projection: ${state.viewportTools.projectionMode}`;
534
+ state.statusTone = 'default';
535
+ state.statusToneStatus = state.status;
536
+ state.statusDetails = '';
537
+ return true;
538
+ }
539
+ function setViewportOverlaySettings(state, settings) {
540
+ state.viewportTools = {
541
+ ...state.viewportTools,
542
+ overlay: {
543
+ ...state.viewportTools.overlay,
544
+ bounds: settings.bounds ?? state.viewportTools.overlay.bounds,
545
+ dimensions: settings.dimensions ?? state.viewportTools.overlay.dimensions,
546
+ edgeLengths: settings.edgeLengths ?? state.viewportTools.overlay.edgeLengths,
547
+ anchor: settings.anchor ?? state.viewportTools.overlay.anchor,
548
+ },
549
+ };
550
+ state.status = 'Viewport overlay settings updated';
551
+ state.statusTone = 'default';
552
+ state.statusToneStatus = state.status;
553
+ state.statusDetails = '';
554
+ return true;
555
+ }
556
+ function setViewportUtilityTool(state, tool) {
557
+ const nextTool = tool === 'measure-distance' ? 'measure-distance' : 'none';
558
+ state.viewportTools = {
559
+ ...state.viewportTools,
560
+ activeUtilityTool: nextTool,
561
+ };
562
+ if (nextTool === 'measure-distance') {
563
+ state.viewportMeasurement = {
564
+ ...cloneViewportMeasurement(state.viewportMeasurement),
565
+ active: !!state.viewportMeasurement.start && !state.viewportMeasurement.end,
566
+ };
567
+ state.status = 'Measure distance: pick first XZ ground point';
568
+ }
569
+ else {
570
+ const unfinishedMeasurement = state.viewportMeasurement.active
571
+ || (!!state.viewportMeasurement.start && !state.viewportMeasurement.end);
572
+ state.viewportMeasurement = unfinishedMeasurement
573
+ ? state.sceneViewMeasurement?.clear() ?? createEmptyViewportMeasurement()
574
+ : {
575
+ ...cloneViewportMeasurement(state.viewportMeasurement),
576
+ active: false,
577
+ preview: null,
578
+ screenPreview: null,
579
+ };
580
+ state.status = 'Viewport utility: none';
581
+ }
582
+ state.statusTone = 'default';
583
+ state.statusToneStatus = state.status;
584
+ state.statusDetails = '';
585
+ return true;
586
+ }
587
+ function clearViewportMeasurement(state) {
588
+ state.viewportMeasurement = state.sceneViewMeasurement?.clear() ?? createEmptyViewportMeasurement();
589
+ state.viewportTools = {
590
+ ...state.viewportTools,
591
+ activeUtilityTool: 'none',
592
+ };
593
+ state.status = 'Measurement cleared';
594
+ state.statusTone = 'default';
595
+ state.statusToneStatus = state.status;
596
+ state.statusDetails = '';
597
+ return true;
598
+ }
599
+ export function applyLocalEditorHarnessViewportMeasurementPointerStart(state, event) {
600
+ if (state.viewportTools.activeUtilityTool !== 'measure-distance')
601
+ return false;
602
+ if (!state.viewportMeasurement.start || state.viewportMeasurement.end) {
603
+ const next = state.sceneViewMeasurement?.beginAt(event.clientX, event.clientY) ?? null;
604
+ if (!next)
605
+ return false;
606
+ state.viewportMeasurement = next;
607
+ state.status = 'Measure distance: pick second XZ ground point';
608
+ state.statusTone = 'default';
609
+ state.statusToneStatus = state.status;
610
+ state.statusDetails = '';
611
+ return true;
612
+ }
613
+ const next = state.sceneViewMeasurement?.completeAt(event.clientX, event.clientY) ?? null;
614
+ if (!next)
615
+ return false;
616
+ state.viewportMeasurement = next;
617
+ state.viewportTools = {
618
+ ...state.viewportTools,
619
+ activeUtilityTool: 'none',
620
+ };
621
+ state.status = `Measured distance: ${formatViewportMeasurementDistance(next.distance)} units`;
622
+ state.statusTone = 'default';
623
+ state.statusToneStatus = state.status;
624
+ state.statusDetails = next.start && next.end
625
+ ? `A (${formatViewportMeasurementDistance(next.start.x)}, ${formatViewportMeasurementDistance(next.start.z)}) · B (${formatViewportMeasurementDistance(next.end.x)}, ${formatViewportMeasurementDistance(next.end.z)})`
626
+ : '';
627
+ return true;
628
+ }
629
+ export function applyLocalEditorHarnessViewportMeasurementPointerMove(state, event) {
630
+ if (state.viewportTools.activeUtilityTool !== 'measure-distance')
631
+ return false;
632
+ const next = state.sceneViewMeasurement?.previewAt(event.clientX, event.clientY) ?? null;
633
+ if (!next)
634
+ return false;
635
+ state.viewportMeasurement = next;
636
+ return true;
637
+ }
638
+ export function applyLocalEditorHarnessViewportMeasurementPointerEnd(_state) {
639
+ return false;
640
+ }
641
+ function handleViewportMeasurementStart(state, event) {
642
+ return applyLocalEditorHarnessViewportMeasurementPointerStart(state, event);
643
+ }
644
+ function handleViewportMeasurementMove(state, event) {
645
+ return applyLocalEditorHarnessViewportMeasurementPointerMove(state, event);
646
+ }
647
+ function handleViewportMeasurementEnd(state) {
648
+ return applyLocalEditorHarnessViewportMeasurementPointerEnd(state);
649
+ }
650
+ function formatViewportMeasurementDistance(value) {
651
+ if (!Number.isFinite(value))
652
+ return '0';
653
+ const rounded = Math.abs(value) < 0.005 ? 0 : value;
654
+ return rounded.toFixed(2).replace(/\.?0+$/, '');
655
+ }
656
+ function resolveViewportPresetTarget(state) {
657
+ const selection = state.session?.getState().selection;
658
+ const activeId = selection?.activeId ?? null;
659
+ if (!activeId || selection?.selectedIds.length !== 1)
660
+ return null;
661
+ const bounds = state.projection?.getSelectionBounds([activeId]) ?? null;
662
+ return bounds?.center ?? null;
663
+ }
664
+ function resolveViewportPresetRadius(state) {
665
+ const selection = state.session?.getState().selection;
666
+ const activeId = selection?.activeId ?? null;
667
+ if (!activeId || selection?.selectedIds.length !== 1)
668
+ return undefined;
669
+ const bounds = state.projection?.getSelectionBounds([activeId]) ?? null;
670
+ if (!bounds)
671
+ return undefined;
672
+ const size = bounds.size;
673
+ const radius = Math.hypot(size.x, size.y, size.z);
674
+ return Number.isFinite(radius) && radius > 0 ? Math.max(radius * 1.5, 2) : undefined;
675
+ }
676
+ function validateTransformActionSelection(state, action) {
677
+ const selection = state.session?.getState().selection;
678
+ const selectedCount = selection?.selectedIds.length ?? 0;
679
+ if (action.startsWith('align-')) {
680
+ return selectedCount >= 2 && selection?.activeId
681
+ ? { ok: true }
682
+ : { ok: false, message: 'Align needs at least 2 selected objects and an active object' };
683
+ }
684
+ return selectedCount >= 3
685
+ ? { ok: true }
686
+ : { ok: false, message: 'Distribute needs at least 3 selected objects' };
687
+ }
271
688
  async function createEditorWorld(state, options, render) {
272
689
  disposeEditorWorld(state);
273
690
  const canvas = options.worldAdapter.getCanvas();
@@ -282,10 +699,12 @@ async function createEditorWorld(state, options, render) {
282
699
  cameraTarget: options.world?.cameraTarget,
283
700
  cameraRadius: options.world?.cameraRadius,
284
701
  clearColor: options.world?.clearColor,
702
+ sky: options.world?.sky,
285
703
  useRightHandedSystem: options.world?.useRightHandedSystem,
286
704
  enableDefaultCameraControls: false,
287
705
  });
288
- options.createGrid?.(babylon, world.scene);
706
+ const grid = options.createGrid?.(babylon, world.scene) ?? null;
707
+ grid?.setVisible(state.gridVisible);
289
708
  const projection = createBabylonEditorProjection({
290
709
  babylon,
291
710
  scene: world.scene,
@@ -301,8 +720,8 @@ async function createEditorWorld(state, options, render) {
301
720
  logger: console,
302
721
  onDragStart(event) {
303
722
  state.status = event.targetIds.length > 1
304
- ? `Dragging ${event.tool} ${event.targetIds.length} objects`
305
- : `Dragging ${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
723
+ ? `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.targetIds.length} objects`
724
+ : `Dragging ${event.duplicate ? 'duplicate ' : ''}${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
306
725
  render();
307
726
  },
308
727
  onDragUpdate() {
@@ -313,12 +732,20 @@ async function createEditorWorld(state, options, render) {
313
732
  render();
314
733
  },
315
734
  onDragCancel(event) {
735
+ if (event.duplicate && cancelDuplicateDrag(state, options)) {
736
+ render();
737
+ return;
738
+ }
316
739
  state.status = event.targetIds.length > 1
317
740
  ? `Canceled ${event.tool} ${event.targetIds.length} objects`
318
741
  : `Canceled ${event.tool} ${event.nodeId ?? event.activeId ?? 'selection'}`;
319
742
  render();
320
743
  },
744
+ onDuplicateDragStart(input) {
745
+ return beginDuplicateDrag(state, options, input);
746
+ },
321
747
  });
748
+ gizmo.setOperationSettings(state.transformOperationSettings);
322
749
  gizmo.setConstraint(state.transformConstraint);
323
750
  const selectionController = createBabylonProjectionSelectionController({
324
751
  scene: world.scene,
@@ -342,13 +769,33 @@ async function createEditorWorld(state, options, render) {
342
769
  render();
343
770
  },
344
771
  });
772
+ const sceneViewMeasurement = createBabylonSceneViewMeasurementController({
773
+ babylon,
774
+ scene: world.scene,
775
+ });
345
776
  const sceneViewInput = createBabylonSceneViewInputController({
346
777
  canvas,
347
778
  isEnabled: () => state.mode === 'editor',
348
779
  isGizmoDragCandidate: (event) => gizmo.isGizmoDragCandidate(event),
349
780
  isBoxSelectCandidate: (event) => selectionController.isBoxSelectionCandidate(event),
350
781
  isViewPlaneMoveCandidate: (event) => gizmo.isViewPlaneMoveCandidate(event),
782
+ isPlacementCandidate: () => isPlacementArmed(state),
783
+ isMeasurementCandidate: () => state.viewportTools.activeUtilityTool === 'measure-distance',
351
784
  onPointerIntentStart(event) {
785
+ if (event.state.intent === 'measurement') {
786
+ if (handleViewportMeasurementStart(state, event.originalEvent))
787
+ render();
788
+ return;
789
+ }
790
+ if (event.state.intent === 'gizmo-drag') {
791
+ gizmo.preparePointerDrag(event.originalEvent);
792
+ return;
793
+ }
794
+ if (event.state.intent === 'placement') {
795
+ if (previewArmedPlacement(state, event.originalEvent))
796
+ render();
797
+ return;
798
+ }
352
799
  if (event.state.intent === 'view-plane-move') {
353
800
  if (gizmo.beginViewPlaneMove(event.originalEvent))
354
801
  render();
@@ -359,12 +806,22 @@ async function createEditorWorld(state, options, render) {
359
806
  }
360
807
  },
361
808
  onPointerIntentMove(event) {
809
+ if (event.state.intent === 'measurement') {
810
+ if (handleViewportMeasurementMove(state, event.originalEvent))
811
+ render();
812
+ return;
813
+ }
814
+ if (event.state.intent === 'placement') {
815
+ if (previewArmedPlacement(state, event.originalEvent))
816
+ render();
817
+ return;
818
+ }
362
819
  if (event.state.intent === 'view-plane-move') {
363
820
  if (gizmo.updateViewPlaneMove(event.originalEvent))
364
821
  render();
365
822
  return;
366
823
  }
367
- if (state.sceneViewCamera?.handlePointerIntentMove(event)) {
824
+ if (!state.sceneCameraPreviewEnabled && state.sceneViewCamera?.handlePointerIntentMove(event)) {
368
825
  render();
369
826
  return;
370
827
  }
@@ -373,6 +830,16 @@ async function createEditorWorld(state, options, render) {
373
830
  }
374
831
  },
375
832
  onPointerIntentEnd(event) {
833
+ if (event.state.intent === 'measurement') {
834
+ if (handleViewportMeasurementEnd(state))
835
+ render();
836
+ return;
837
+ }
838
+ if (event.state.intent === 'placement') {
839
+ if (commitArmedPlacement(state, options, event.originalEvent))
840
+ render();
841
+ return;
842
+ }
376
843
  if (event.state.intent === 'view-plane-move') {
377
844
  if (gizmo.endViewPlaneMove(event.originalEvent))
378
845
  render();
@@ -383,6 +850,15 @@ async function createEditorWorld(state, options, render) {
383
850
  }
384
851
  },
385
852
  onPointerIntentCancel(event) {
853
+ if (event.state.intent === 'measurement') {
854
+ render();
855
+ return;
856
+ }
857
+ if (event.state.intent === 'placement') {
858
+ state.gizmo?.setPlacementMarker(null);
859
+ render();
860
+ return;
861
+ }
386
862
  if (event.state.intent === 'view-plane-move') {
387
863
  gizmo.cancelDrag();
388
864
  render();
@@ -397,7 +873,7 @@ async function createEditorWorld(state, options, render) {
397
873
  selectionController.handleDoubleClick(event);
398
874
  },
399
875
  onWheel(event) {
400
- if (state.sceneViewCamera?.handleWheel(event))
876
+ if (!state.sceneCameraPreviewEnabled && state.sceneViewCamera?.handleWheel(event))
401
877
  render();
402
878
  },
403
879
  });
@@ -407,6 +883,15 @@ async function createEditorWorld(state, options, render) {
407
883
  camera: world.camera,
408
884
  input: sceneViewInput,
409
885
  });
886
+ const sceneCameraPreview = createBabylonSceneCameraPreviewController({
887
+ babylon,
888
+ scene: world.scene,
889
+ editorCamera: world.camera,
890
+ });
891
+ const sceneViewSpatialOverlay = createBabylonSceneViewSpatialOverlayController({
892
+ babylon,
893
+ scene: world.scene,
894
+ });
410
895
  const document = state.session?.getState().workingDocument;
411
896
  if (document) {
412
897
  projection.projectNodes(options.documentAdapter.getProjectionNodes(document));
@@ -420,10 +905,14 @@ async function createEditorWorld(state, options, render) {
420
905
  state.babylon = babylon;
421
906
  state.engine = engine;
422
907
  state.world = world;
908
+ state.grid = grid;
423
909
  state.projection = projection;
424
910
  state.gizmo = gizmo;
911
+ state.sceneCameraPreview = sceneCameraPreview;
425
912
  state.sceneViewInput = sceneViewInput;
426
913
  state.sceneViewCamera = sceneViewCamera;
914
+ state.sceneViewMeasurement = sceneViewMeasurement;
915
+ state.sceneViewSpatialOverlay = sceneViewSpatialOverlay;
427
916
  state.selectionController = selectionController;
428
917
  state.resizeHandler = resize;
429
918
  }
@@ -432,17 +921,32 @@ function disposeEditorWorld(state) {
432
921
  window.removeEventListener('resize', state.resizeHandler);
433
922
  state.resizeHandler = null;
434
923
  }
924
+ state.sceneCameraPreview?.dispose();
925
+ state.sceneCameraPreview = null;
926
+ state.sceneCameraPreviewEnabled = false;
435
927
  state.sceneViewCamera?.dispose();
436
928
  state.sceneViewCamera = null;
929
+ state.sceneViewMeasurement?.dispose();
930
+ state.sceneViewMeasurement = null;
931
+ state.sceneViewSpatialOverlay?.dispose();
932
+ state.sceneViewSpatialOverlay = null;
437
933
  state.sceneViewInput?.dispose();
438
934
  state.sceneViewInput = null;
439
935
  state.selectionController?.dispose();
440
936
  state.selectionController = null;
441
937
  state.boxSelection = null;
938
+ state.viewportSpatialOverlay = createEmptyEditorViewportSpatialOverlayState();
939
+ state.viewportMeasurement = createEmptyViewportMeasurement();
940
+ state.viewportTools = {
941
+ ...state.viewportTools,
942
+ activeUtilityTool: 'none',
943
+ };
442
944
  state.gizmo?.dispose();
443
945
  state.gizmo = null;
444
946
  state.projection?.dispose();
445
947
  state.projection = null;
948
+ state.grid?.dispose();
949
+ state.grid = null;
446
950
  state.engine?.stopRenderLoop?.();
447
951
  state.world?.dispose();
448
952
  state.engine?.dispose?.();
@@ -475,31 +979,104 @@ function selectItem(state, options, input) {
475
979
  return false;
476
980
  if (!isDocumentNodeSelectable(state, options, input.id))
477
981
  return false;
478
- const command = input.toggle
479
- ? {
480
- type: 'selection.toggle',
481
- selectedIds: [input.id],
482
- activeId: input.id,
483
- label: 'Toggle Selection',
484
- }
485
- : input.additive
486
- ? {
487
- type: 'selection.add',
488
- selectedIds: [input.id],
489
- activeId: input.id,
490
- label: 'Add Selection',
491
- }
492
- : {
493
- type: 'selection.replace',
494
- selectedIds: [input.id],
495
- activeId: input.id,
496
- label: 'Select Item',
497
- };
982
+ const selection = state.session?.getState().selection ?? { selectedIds: [], activeId: null };
983
+ const command = resolveEditorSelectionCommand({
984
+ selection,
985
+ targetIds: [input.id],
986
+ activeId: input.id,
987
+ gesture: 'click',
988
+ modifier: input.toggle ? 'toggle' : input.additive ? 'additive' : 'replace',
989
+ });
990
+ if (!command)
991
+ return false;
498
992
  return dispatchSelectionCommand(state, options, command);
499
993
  }
994
+ function createBrowserHierarchyContextActions(state, options) {
995
+ return (options.hierarchy?.contextActions ?? []).map((registration) => ({
996
+ id: registration.id,
997
+ label: registration.label,
998
+ shortcut: registration.shortcut,
999
+ danger: registration.danger,
1000
+ placement: registration.placement,
1001
+ separatorBefore: registration.separatorBefore,
1002
+ visible: (browserContext) => {
1003
+ const context = createHarnessHierarchyContextActionContext(state, options, browserContext);
1004
+ if (!context)
1005
+ return false;
1006
+ return registration.visible?.(context) ?? true;
1007
+ },
1008
+ disabled: (browserContext) => {
1009
+ const context = createHarnessHierarchyContextActionContext(state, options, browserContext);
1010
+ if (!context)
1011
+ return 'No editable document is loaded.';
1012
+ return registration.disabled?.(context) ?? false;
1013
+ },
1014
+ }));
1015
+ }
1016
+ function createHarnessHierarchyContextActionContext(state, options, browserContext, payload) {
1017
+ const document = state.session?.getState().workingDocument ?? null;
1018
+ if (!document)
1019
+ return null;
1020
+ const activeId = browserContext.activeId;
1021
+ return {
1022
+ document,
1023
+ contextNodeId: browserContext.contextNodeId,
1024
+ targetIds: browserContext.targetIds,
1025
+ activeId,
1026
+ hierarchyItem: browserContext.node,
1027
+ projectionNode: browserContext.contextNodeId
1028
+ ? options.documentAdapter.getProjectionNode(document, browserContext.contextNodeId)
1029
+ : null,
1030
+ hostServices: options.hostServices ?? null,
1031
+ payload,
1032
+ browserContext,
1033
+ };
1034
+ }
1035
+ function createBrowserContextForCustomAction(state, options, action) {
1036
+ const document = state.session?.getState().workingDocument ?? null;
1037
+ if (!document)
1038
+ return null;
1039
+ const uiState = createUiState(state, options);
1040
+ const node = action.contextNodeId
1041
+ ? uiState.hierarchy.find(item => item.id === action.contextNodeId) ?? null
1042
+ : null;
1043
+ return {
1044
+ state: uiState,
1045
+ menuKind: node ? 'node' : 'blank',
1046
+ node,
1047
+ contextNodeId: action.contextNodeId,
1048
+ targetIds: action.targetIds,
1049
+ activeId: action.activeId,
1050
+ };
1051
+ }
1052
+ function runHierarchyCustomContextAction(state, options, action) {
1053
+ const registration = options.hierarchy?.contextActions?.find(candidate => candidate.id === action.id);
1054
+ if (!registration)
1055
+ return false;
1056
+ const browserContext = createBrowserContextForCustomAction(state, options, action);
1057
+ const context = browserContext
1058
+ ? createHarnessHierarchyContextActionContext(state, options, browserContext, action.payload)
1059
+ : null;
1060
+ if (!context)
1061
+ return false;
1062
+ try {
1063
+ return registration.run(context) === true;
1064
+ }
1065
+ catch (error) {
1066
+ const message = error instanceof Error ? error.message : String(error);
1067
+ state.status = `${registration.label} failed`;
1068
+ state.statusTone = 'error';
1069
+ state.statusToneStatus = state.status;
1070
+ state.statusDetails = message;
1071
+ console.error('[LocalEditorHarness] hierarchy context action failed', error);
1072
+ return true;
1073
+ }
1074
+ }
500
1075
  function handleContextAction(state, options, action) {
501
1076
  if (action.region !== 'hierarchy')
502
1077
  return false;
1078
+ if (action.action === 'custom')
1079
+ return runHierarchyCustomContextAction(state, options, action);
503
1080
  if (action.action === 'focus') {
504
1081
  const activeId = action.activeId ?? action.targetIds[action.targetIds.length - 1] ?? null;
505
1082
  const selectionChanged = activeId && !state.session?.getState().selection.selectedIds.includes(activeId)
@@ -518,7 +1095,15 @@ function handleContextAction(state, options, action) {
518
1095
  return createSceneGraphGroup(state, options, {
519
1096
  parentId: action.parentId ?? null,
520
1097
  activeId: action.activeId ?? null,
521
- name: 'Group',
1098
+ name: 'Empty',
1099
+ });
1100
+ }
1101
+ if (action.action === 'create-primitive') {
1102
+ return createSceneGraphPrimitive(state, options, {
1103
+ parentId: action.parentId ?? null,
1104
+ activeId: action.activeId ?? null,
1105
+ shape: action.shape,
1106
+ name: action.name,
522
1107
  });
523
1108
  }
524
1109
  if (action.action === 'delete') {
@@ -527,6 +1112,18 @@ function handleContextAction(state, options, action) {
527
1112
  activeId: action.activeId ?? null,
528
1113
  });
529
1114
  }
1115
+ if (action.action === 'duplicate') {
1116
+ return duplicateSceneGraphNodes(state, options, {
1117
+ targetIds: action.targetIds,
1118
+ activeId: action.activeId ?? null,
1119
+ });
1120
+ }
1121
+ if (action.action === 'paste') {
1122
+ return duplicateSceneGraphNodes(state, options, {
1123
+ targetIds: action.sourceIds,
1124
+ activeId: action.activeId ?? null,
1125
+ });
1126
+ }
530
1127
  return false;
531
1128
  }
532
1129
  function dispatchSelectionCommand(state, options, command) {
@@ -579,17 +1176,17 @@ function createSceneGraphGroup(state, options, intent) {
579
1176
  cancelActiveOperation(state);
580
1177
  const patch = options.documentAdapter.createSceneGraphCreateGroupPatch?.(document, intent);
581
1178
  if (!patch) {
582
- state.status = 'Create group rejected';
1179
+ state.status = 'Create empty rejected';
583
1180
  return true;
584
1181
  }
585
1182
  const result = state.session.dispatch({
586
1183
  type: 'document.patch',
587
- label: patch.label ?? 'Create Empty Group',
1184
+ label: patch.label ?? 'Create Empty',
588
1185
  patch: patch.patch,
589
1186
  targetId: patch.createdId ?? undefined,
590
1187
  });
591
1188
  if (!result.documentChanged) {
592
- state.status = 'Create group unchanged';
1189
+ state.status = 'Create empty unchanged';
593
1190
  return true;
594
1191
  }
595
1192
  const createdId = patch.createdId ?? null;
@@ -599,12 +1196,47 @@ function createSceneGraphGroup(state, options, intent) {
599
1196
  type: 'selection.replace',
600
1197
  selectedIds: [createdId],
601
1198
  activeId: createdId,
602
- label: 'Select Created Group',
1199
+ label: 'Select Created Empty',
603
1200
  }).selection;
604
1201
  }
605
1202
  rebuildProjectionFromDocument(state, options, result.workingDocument, selection);
606
1203
  state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
607
- state.status = patch.label ?? (createdId ? `Created group ${createdId}` : 'Created group');
1204
+ state.status = patch.label ?? (createdId ? `Created empty ${createdId}` : 'Created empty');
1205
+ return true;
1206
+ }
1207
+ function createSceneGraphPrimitive(state, options, intent) {
1208
+ const document = state.session?.getState().workingDocument;
1209
+ if (state.mode !== 'editor' || !state.session || !document)
1210
+ return false;
1211
+ cancelActiveOperation(state);
1212
+ const patch = options.documentAdapter.createSceneGraphCreatePrimitivePatch?.(document, intent);
1213
+ if (!patch) {
1214
+ state.status = `Create ${intent.shape} rejected`;
1215
+ return true;
1216
+ }
1217
+ const result = state.session.dispatch({
1218
+ type: 'document.patch',
1219
+ label: patch.label ?? `Create ${intent.shape}`,
1220
+ patch: patch.patch,
1221
+ targetId: patch.createdId ?? undefined,
1222
+ });
1223
+ if (!result.documentChanged) {
1224
+ state.status = `Create ${intent.shape} unchanged`;
1225
+ return true;
1226
+ }
1227
+ const createdId = patch.createdId ?? null;
1228
+ let selection = result.selection;
1229
+ if (createdId && isNodeSelectableInDocument(options, result.workingDocument, createdId)) {
1230
+ selection = state.session.dispatch({
1231
+ type: 'selection.replace',
1232
+ selectedIds: [createdId],
1233
+ activeId: createdId,
1234
+ label: 'Select Created Primitive',
1235
+ }).selection;
1236
+ }
1237
+ rebuildProjectionFromDocument(state, options, result.workingDocument, selection);
1238
+ state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1239
+ state.status = patch.label ?? (createdId ? `Created ${intent.shape} ${createdId}` : `Created ${intent.shape}`);
608
1240
  return true;
609
1241
  }
610
1242
  function deleteSceneGraphNodes(state, options, intent) {
@@ -650,6 +1282,57 @@ function deleteSceneGraphNodes(state, options, intent) {
650
1282
  state.status = patch.label ?? `Deleted ${patch.deletedIds?.length ?? intent.ids.length} node(s)`;
651
1283
  return true;
652
1284
  }
1285
+ function duplicateSceneGraphNodes(state, options, intent) {
1286
+ const document = state.session?.getState().workingDocument;
1287
+ if (state.mode !== 'editor' || !state.session || !document)
1288
+ return false;
1289
+ cancelActiveOperation(state);
1290
+ const targetIds = Array.from(new Set(intent.targetIds.filter(Boolean)));
1291
+ if (targetIds.length === 0) {
1292
+ state.status = 'Duplicate rejected: empty selection';
1293
+ return true;
1294
+ }
1295
+ const patch = options.documentAdapter.createDuplicateSelectionPatch?.({
1296
+ document,
1297
+ targetIds,
1298
+ activeId: intent.activeId && targetIds.includes(intent.activeId) ? intent.activeId : targetIds[targetIds.length - 1] ?? null,
1299
+ transforms: {},
1300
+ });
1301
+ if (!patch || patch.createdIds.length === 0) {
1302
+ state.status = 'Duplicate rejected';
1303
+ return true;
1304
+ }
1305
+ const result = state.session.dispatch({
1306
+ type: 'document.patch',
1307
+ label: patch.label ?? `Duplicate ${targetIds.length} object(s)`,
1308
+ patch: patch.patch,
1309
+ targetId: patch.activeId ?? patch.createdIds[patch.createdIds.length - 1] ?? undefined,
1310
+ });
1311
+ if (!result.documentChanged) {
1312
+ state.status = 'Duplicate unchanged';
1313
+ return true;
1314
+ }
1315
+ const createdIds = patch.createdIds.filter(id => isNodeSelectableInDocument(options, result.workingDocument, id));
1316
+ const activeId = patch.activeId && createdIds.includes(patch.activeId)
1317
+ ? patch.activeId
1318
+ : createdIds[createdIds.length - 1] ?? null;
1319
+ const selection = createdIds.length > 0
1320
+ ? state.session.dispatch({
1321
+ type: 'selection.replace',
1322
+ selectedIds: createdIds,
1323
+ activeId,
1324
+ label: 'Select Duplicated Nodes',
1325
+ }).selection
1326
+ : sanitizeSelection(state, options, result.workingDocument, result.selection) ?? result.selection;
1327
+ rebuildProjectionFromDocument(state, options, result.workingDocument, selection);
1328
+ if (patch.reprojectIds?.length)
1329
+ reprojectProjectionForChangedIds(state, options, result.workingDocument, patch.reprojectIds);
1330
+ else
1331
+ syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds ?? createdIds);
1332
+ state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1333
+ state.status = patch.label ?? `Duplicated ${createdIds.length} object(s)`;
1334
+ return true;
1335
+ }
653
1336
  function dropSceneGraphNode(state, options, intent) {
654
1337
  const document = state.session?.getState().workingDocument;
655
1338
  if (state.mode !== 'editor' || !state.session || !document)
@@ -743,27 +1426,27 @@ function groupSceneGraphSelection(state, options, intent) {
743
1426
  const hierarchy = options.documentAdapter.getHierarchyItems(document);
744
1427
  const coreValidation = validateSceneGraphGroupSelection(hierarchy, intent);
745
1428
  if (!coreValidation.ok) {
746
- state.status = `Group selection rejected: ${coreValidation.reason ?? 'invalid scene graph group selection'}`;
1429
+ state.status = `Parent selection rejected: ${coreValidation.reason ?? 'invalid scene graph parent selection'}`;
747
1430
  return true;
748
1431
  }
749
1432
  const projectValidation = options.documentAdapter.validateSceneGraphGroupSelection?.(document, intent);
750
1433
  if (projectValidation && !projectValidation.ok) {
751
- state.status = `Group selection rejected: ${projectValidation.reason ?? 'project validation failed'}`;
1434
+ state.status = `Parent selection rejected: ${projectValidation.reason ?? 'project validation failed'}`;
752
1435
  return true;
753
1436
  }
754
1437
  const patch = options.documentAdapter.createSceneGraphGroupSelectionPatch?.(document, intent);
755
1438
  if (!patch) {
756
- state.status = 'Group selection rejected';
1439
+ state.status = 'Parent selection rejected';
757
1440
  return true;
758
1441
  }
759
1442
  const result = state.session.dispatch({
760
1443
  type: 'document.patch',
761
- label: patch.label ?? 'Group Selection',
1444
+ label: patch.label ?? 'Parent Selection',
762
1445
  patch: patch.patch,
763
1446
  targetId: patch.createdId,
764
1447
  });
765
1448
  if (!result.documentChanged) {
766
- state.status = 'Group selection unchanged';
1449
+ state.status = 'Parent selection unchanged';
767
1450
  return true;
768
1451
  }
769
1452
  let selection = result.selection;
@@ -772,7 +1455,7 @@ function groupSceneGraphSelection(state, options, intent) {
772
1455
  type: 'selection.replace',
773
1456
  selectedIds: [patch.createdId],
774
1457
  activeId: patch.createdId,
775
- label: 'Select Created Group',
1458
+ label: 'Select Created Parent',
776
1459
  }).selection;
777
1460
  }
778
1461
  else {
@@ -780,7 +1463,7 @@ function groupSceneGraphSelection(state, options, intent) {
780
1463
  }
781
1464
  rebuildProjectionFromDocument(state, options, result.workingDocument, selection);
782
1465
  state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
783
- state.status = patch.label ?? `Grouped ${patch.changedIds?.length ?? intent.ids.length} node(s)`;
1466
+ state.status = patch.label ?? `Parented ${patch.changedIds?.length ?? intent.ids.length} node(s)`;
784
1467
  return true;
785
1468
  }
786
1469
  function sanitizeSelection(state, options, document, selection) {
@@ -803,18 +1486,216 @@ function syncSelectionToProjection(state, selection) {
803
1486
  state.gizmo?.setSelection(selection);
804
1487
  state.gizmo?.refreshSelection();
805
1488
  }
806
- function addAssetToDocument(state, options, assetId) {
807
- if (state.mode !== 'editor')
1489
+ function executeTransformAction(state, options, action) {
1490
+ if (state.mode !== 'editor' || !state.session)
808
1491
  return false;
1492
+ const document = state.session.getState().workingDocument;
1493
+ const selection = state.session.getState().selection;
809
1494
  cancelActiveOperation(state);
810
- const session = state.session;
811
- const beforeDocument = session?.getState().workingDocument;
812
- if (!session || !beforeDocument)
1495
+ const validation = validateTransformActionSelection(state, action);
1496
+ if (!validation.ok) {
1497
+ state.status = validation.message;
1498
+ return true;
1499
+ }
1500
+ const beforeTransforms = state.projection?.readNodeTransforms(selection.selectedIds) ?? {};
1501
+ const transformTargets = selection.selectedIds
1502
+ .map((id) => {
1503
+ const transform = beforeTransforms[id];
1504
+ return transform ? { id, transform } : null;
1505
+ })
1506
+ .filter((target) => !!target);
1507
+ const targets = computeEditorTransformActionTargets({
1508
+ action,
1509
+ activeId: selection.activeId,
1510
+ targets: transformTargets,
1511
+ });
1512
+ if (targets.length === 0) {
1513
+ state.status = `Transform action rejected: ${action}`;
1514
+ return true;
1515
+ }
1516
+ const changedTargets = targets.filter(target => !editorTransformSnapshotsEqual(target.before, target.after));
1517
+ if (changedTargets.length === 0) {
1518
+ state.status = `Transform action unchanged: ${action}`;
1519
+ return true;
1520
+ }
1521
+ const patch = options.documentAdapter.createTransformBatchPatch?.({
1522
+ document,
1523
+ targetIds: changedTargets.map(target => target.id),
1524
+ activeId: selection.activeId,
1525
+ tool: 'move',
1526
+ space: 'world',
1527
+ constraint: action === 'align-all' ? 'free' : 'axis',
1528
+ pivot: createTransformActionPivot(targets),
1529
+ targets: changedTargets,
1530
+ });
1531
+ if (!patch) {
1532
+ state.status = `Transform action ignored: ${action}`;
1533
+ return true;
1534
+ }
1535
+ const result = state.session.dispatch({
1536
+ type: 'document.patch',
1537
+ label: patch.label ?? formatTransformActionStatus(action, changedTargets.length),
1538
+ patch: patch.patch,
1539
+ targetId: selection.activeId ?? changedTargets[0]?.id,
1540
+ });
1541
+ if (!result.documentChanged) {
1542
+ state.status = `Transform action unchanged: ${action}`;
1543
+ return true;
1544
+ }
1545
+ const changedIds = patch.changedIds ?? changedTargets.map(target => target.id);
1546
+ syncProjectionForChangedIds(state, options, result.workingDocument, changedIds);
1547
+ state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1548
+ state.status = patch.label ?? formatTransformActionStatus(action, changedTargets.length);
1549
+ return true;
1550
+ }
1551
+ function createTransformActionPivot(targets) {
1552
+ const center = averageTransformTargetPositions(targets);
1553
+ return {
1554
+ mode: 'selection-center',
1555
+ position: center,
1556
+ };
1557
+ }
1558
+ function averageTransformTargetPositions(targets) {
1559
+ if (targets.length === 0)
1560
+ return { x: 0, y: 0, z: 0 };
1561
+ return {
1562
+ x: targets.reduce((sum, target) => sum + target.before.position.x, 0) / targets.length,
1563
+ y: targets.reduce((sum, target) => sum + target.before.position.y, 0) / targets.length,
1564
+ z: targets.reduce((sum, target) => sum + target.before.position.z, 0) / targets.length,
1565
+ };
1566
+ }
1567
+ function editorTransformSnapshotsEqual(left, right) {
1568
+ return editorVec3Equal(left.position, right.position)
1569
+ && editorVec3Equal(left.rotation, right.rotation)
1570
+ && editorVec3Equal(left.scale, right.scale);
1571
+ }
1572
+ function editorVec3Equal(left, right) {
1573
+ return Math.abs(left.x - right.x) < 0.000000001
1574
+ && Math.abs(left.y - right.y) < 0.000000001
1575
+ && Math.abs(left.z - right.z) < 0.000000001;
1576
+ }
1577
+ function formatTransformActionStatus(action, count) {
1578
+ return `${action} ${count} object${count === 1 ? '' : 's'}`;
1579
+ }
1580
+ function createAssetFromBrowserIntent(state, options, assetId) {
1581
+ if (state.transformOperationSettings.placementMode === 'off') {
1582
+ return addAssetToDocument(state, options, assetId).ok;
1583
+ }
1584
+ return armAssetPlacement(state, options, assetId);
1585
+ }
1586
+ function armAssetPlacement(state, options, assetId) {
1587
+ if (state.mode !== 'editor')
813
1588
  return false;
814
- const asset = state.assets.find(candidate => resolveAssetId(options, candidate) === assetId);
1589
+ cancelActiveOperation(state);
1590
+ const asset = findAssetByResolvedId(state, options, assetId);
815
1591
  if (!asset)
816
1592
  return false;
817
- const patch = options.documentAdapter.createPatchFromAsset(asset);
1593
+ state.armedPlacement = { assetId, asset };
1594
+ state.gizmo?.setPlacementMarker(null);
1595
+ state.status = `Placement armed: ${formatAssetLabel(asset, assetId)} (${state.transformOperationSettings.placementMode})`;
1596
+ return true;
1597
+ }
1598
+ function isPlacementArmed(state) {
1599
+ return state.mode === 'editor'
1600
+ && !!state.armedPlacement
1601
+ && state.transformOperationSettings.placementMode !== 'off';
1602
+ }
1603
+ function previewArmedPlacement(state, event) {
1604
+ if (!isPlacementArmed(state))
1605
+ return false;
1606
+ const hit = pickArmedPlacementHit(state, event);
1607
+ state.gizmo?.setPlacementMarker(hit);
1608
+ const mode = state.transformOperationSettings.placementMode;
1609
+ state.status = hit
1610
+ ? `Placement ${mode}: ${formatVec3(hit.position)}`
1611
+ : `Placement ${mode}: no hit`;
1612
+ return true;
1613
+ }
1614
+ function commitArmedPlacement(state, options, event) {
1615
+ if (!isPlacementArmed(state) || !state.session)
1616
+ return false;
1617
+ const armed = state.armedPlacement;
1618
+ const session = state.session;
1619
+ const beforeDocument = session.getState().workingDocument;
1620
+ const hit = pickArmedPlacementHit(state, event);
1621
+ if (!armed || !hit) {
1622
+ state.gizmo?.setPlacementMarker(null);
1623
+ state.status = 'Placement rejected: no hit';
1624
+ return true;
1625
+ }
1626
+ const patch = options.documentAdapter.createPlacedAssetPatch?.({
1627
+ document: beforeDocument,
1628
+ asset: armed.asset,
1629
+ hit,
1630
+ });
1631
+ if (!patch) {
1632
+ state.status = 'Placement rejected: document adapter does not support placed assets';
1633
+ return true;
1634
+ }
1635
+ const result = session.dispatch({
1636
+ type: 'document.patch',
1637
+ label: patch.label ?? `Place ${armed.assetId}`,
1638
+ patch: patch.patch,
1639
+ targetId: patch.createdId ?? undefined,
1640
+ });
1641
+ if (!result.documentChanged) {
1642
+ state.status = 'Placement unchanged';
1643
+ return true;
1644
+ }
1645
+ const createdId = patch.createdId
1646
+ ?? options.documentAdapter.findCreatedId?.(beforeDocument, result.workingDocument)
1647
+ ?? null;
1648
+ let selection = result.selection;
1649
+ if (createdId && isNodeSelectableInDocument(options, result.workingDocument, createdId)) {
1650
+ selection = session.dispatch({
1651
+ type: 'selection.replace',
1652
+ selectedIds: [createdId],
1653
+ activeId: createdId,
1654
+ label: 'Select Placed Item',
1655
+ }).selection;
1656
+ }
1657
+ else {
1658
+ selection = sanitizeSelection(state, options, result.workingDocument, selection) ?? selection;
1659
+ }
1660
+ clearArmedPlacement(state);
1661
+ rebuildProjectionFromDocument(state, options, result.workingDocument, selection);
1662
+ state.summary = summarizeDocument(options, result.workingDocument, session.getSource());
1663
+ state.status = patch.label ?? `Placed ${formatAssetLabel(armed.asset, armed.assetId)} at ${formatVec3(hit.position)}`;
1664
+ return true;
1665
+ }
1666
+ function pickArmedPlacementHit(state, event) {
1667
+ const mode = state.transformOperationSettings.placementMode;
1668
+ return mode === 'off'
1669
+ ? null
1670
+ : state.gizmo?.pickPlacementHit(event.clientX, event.clientY, mode) ?? null;
1671
+ }
1672
+ function clearArmedPlacement(state) {
1673
+ state.armedPlacement = null;
1674
+ state.gizmo?.setPlacementMarker(null);
1675
+ }
1676
+ function addAssetToDocument(state, options, assetId, createOptions = {}) {
1677
+ if (state.mode !== 'editor') {
1678
+ state.status = 'Asset creation rejected: editor is not active';
1679
+ return { ok: false, assetId, changed: false, status: state.status, error: 'editor_not_active' };
1680
+ }
1681
+ cancelActiveOperation(state);
1682
+ const session = state.session;
1683
+ const beforeDocument = session?.getState().workingDocument;
1684
+ if (!session || !beforeDocument) {
1685
+ state.status = 'Asset creation rejected: document is not loaded';
1686
+ return { ok: false, assetId, changed: false, status: state.status, error: 'document_not_loaded' };
1687
+ }
1688
+ const asset = findAssetByResolvedId(state, options, assetId);
1689
+ if (!asset) {
1690
+ state.status = `Asset creation rejected: ${assetId} not found`;
1691
+ return { ok: false, assetId, changed: false, status: state.status, error: 'asset_not_found' };
1692
+ }
1693
+ const patch = options.documentAdapter.createPatchFromAsset(asset, {
1694
+ document: beforeDocument,
1695
+ asset,
1696
+ assetId,
1697
+ placement: createOptions.placement,
1698
+ });
818
1699
  const result = session.dispatch({
819
1700
  type: 'document.patch',
820
1701
  label: patch.label ?? 'Create Object From Asset',
@@ -832,6 +1713,9 @@ function addAssetToDocument(state, options, assetId) {
832
1713
  }
833
1714
  state.summary = summarizeDocument(options, result.workingDocument, session.getSource());
834
1715
  state.status = `Added ${assetId}`;
1716
+ state.statusTone = 'success';
1717
+ state.statusToneStatus = state.status;
1718
+ state.statusDetails = '';
835
1719
  if (createdId) {
836
1720
  const projectedNode = options.documentAdapter.getProjectionNode(result.workingDocument, createdId);
837
1721
  if (projectedNode) {
@@ -840,7 +1724,13 @@ function addAssetToDocument(state, options, assetId) {
840
1724
  syncSelectionToProjection(state, selectionResult.selection);
841
1725
  }
842
1726
  }
843
- return true;
1727
+ return {
1728
+ ok: true,
1729
+ assetId,
1730
+ changed: result.documentChanged,
1731
+ status: state.status,
1732
+ createdId: createdId ?? null,
1733
+ };
844
1734
  }
845
1735
  function patchSerializedProperty(state, options, input) {
846
1736
  if (state.mode !== 'editor')
@@ -971,6 +1861,86 @@ function findInspectorPropertyByPath(inspector, path) {
971
1861
  }
972
1862
  return null;
973
1863
  }
1864
+ function beginDuplicateDrag(state, options, input) {
1865
+ if (state.mode !== 'editor' || !state.session)
1866
+ return null;
1867
+ if (state.duplicateDrag)
1868
+ return null;
1869
+ const document = state.session.getState().workingDocument;
1870
+ const patch = options.documentAdapter.createDuplicateSelectionPatch?.({
1871
+ document,
1872
+ targetIds: input.targetIds,
1873
+ activeId: input.activeId,
1874
+ transforms: input.beforeTransforms,
1875
+ });
1876
+ if (!patch || patch.createdIds.length === 0) {
1877
+ state.status = 'Duplicate drag rejected';
1878
+ return null;
1879
+ }
1880
+ const originalSelection = state.session.getSelection();
1881
+ const result = state.session.dispatch({
1882
+ type: 'document.patch',
1883
+ label: patch.label ?? `Duplicate ${input.targetIds.length} object(s)`,
1884
+ patch: patch.patch,
1885
+ targetId: patch.activeId ?? patch.createdIds[patch.createdIds.length - 1] ?? undefined,
1886
+ });
1887
+ if (!result.documentChanged) {
1888
+ state.status = 'Duplicate drag unchanged';
1889
+ return null;
1890
+ }
1891
+ const createdIds = patch.createdIds.filter(id => isNodeSelectableInDocument(options, result.workingDocument, id));
1892
+ if (createdIds.length === 0) {
1893
+ const undone = state.session.undo();
1894
+ if (undone)
1895
+ rebuildProjectionFromDocument(state, options, undone.workingDocument, originalSelection);
1896
+ state.status = 'Duplicate drag rejected: duplicated selection is not selectable';
1897
+ return null;
1898
+ }
1899
+ const activeId = patch.activeId && createdIds.includes(patch.activeId)
1900
+ ? patch.activeId
1901
+ : createdIds[createdIds.length - 1] ?? null;
1902
+ const selectionResult = state.session.dispatch({
1903
+ type: 'selection.replace',
1904
+ selectedIds: createdIds,
1905
+ activeId,
1906
+ label: 'Select Duplicate Drag Targets',
1907
+ });
1908
+ state.duplicateDrag = {
1909
+ originalSelection,
1910
+ createdIds,
1911
+ activeId,
1912
+ };
1913
+ rebuildProjectionFromDocument(state, options, result.workingDocument, selectionResult.selection);
1914
+ if (patch.reprojectIds?.length)
1915
+ reprojectProjectionForChangedIds(state, options, result.workingDocument, patch.reprojectIds);
1916
+ else
1917
+ syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds ?? createdIds);
1918
+ state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1919
+ state.status = patch.label ?? `Duplicated ${createdIds.length} object(s)`;
1920
+ return {
1921
+ targetIds: createdIds,
1922
+ activeId,
1923
+ };
1924
+ }
1925
+ function cancelDuplicateDrag(state, options) {
1926
+ const duplicate = state.duplicateDrag;
1927
+ if (!duplicate || !state.session)
1928
+ return false;
1929
+ state.duplicateDrag = null;
1930
+ const undone = state.session.undo();
1931
+ if (!undone)
1932
+ return false;
1933
+ const selectionResult = state.session.dispatch({
1934
+ type: 'selection.replace',
1935
+ selectedIds: duplicate.originalSelection.selectedIds,
1936
+ activeId: duplicate.originalSelection.activeId,
1937
+ label: 'Restore Duplicate Drag Selection',
1938
+ });
1939
+ rebuildProjectionFromDocument(state, options, undone.workingDocument, selectionResult.selection);
1940
+ state.summary = summarizeDocument(options, undone.workingDocument, state.session.getSource());
1941
+ state.status = `Canceled duplicate drag ${duplicate.createdIds.length} object(s)`;
1942
+ return true;
1943
+ }
974
1944
  function commitGizmoTransform(state, options, event) {
975
1945
  if (state.mode !== 'editor' || !state.session)
976
1946
  return false;
@@ -981,8 +1951,10 @@ function commitGizmoTransform(state, options, event) {
981
1951
  document,
982
1952
  });
983
1953
  if (!patch) {
984
- restoreBatchTransformPreview(state, event.targets);
985
- state.status = `Ignored ${event.tool} ${event.targetIds.length} objects`;
1954
+ if (!cancelDuplicateDrag(state, options)) {
1955
+ restoreBatchTransformPreview(state, event.targets);
1956
+ state.status = `Ignored ${event.tool} ${event.targetIds.length} objects`;
1957
+ }
986
1958
  return false;
987
1959
  }
988
1960
  const result = state.session.dispatch({
@@ -990,11 +1962,14 @@ function commitGizmoTransform(state, options, event) {
990
1962
  label: patch.label ?? `${event.tool} ${event.targetIds.length} objects`,
991
1963
  patch: patch.patch,
992
1964
  targetId: event.activeId ?? undefined,
1965
+ }, {
1966
+ mergeWithPrevious: event.targetIds.some(id => state.duplicateDrag?.createdIds.includes(id)) === true,
993
1967
  });
994
1968
  syncProjectionForDispatchResult(state, options, result);
995
1969
  syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds ?? event.targetIds);
996
1970
  state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
997
1971
  state.status = patch.label ?? `${event.tool} ${event.targetIds.length} objects`;
1972
+ state.duplicateDrag = null;
998
1973
  return result.documentChanged;
999
1974
  }
1000
1975
  const patch = options.documentAdapter.createTransformPatch?.({
@@ -1002,12 +1977,15 @@ function commitGizmoTransform(state, options, event) {
1002
1977
  targetId: event.nodeId,
1003
1978
  tool: event.tool,
1004
1979
  space: event.space,
1980
+ constraint: event.constraint,
1005
1981
  before: event.before,
1006
1982
  after: event.after,
1007
1983
  });
1008
1984
  if (!patch) {
1009
- state.projection?.setNodeTransformPreview(event.nodeId, event.before);
1010
- state.status = `Ignored ${event.tool} ${event.nodeId}`;
1985
+ if (!cancelDuplicateDrag(state, options)) {
1986
+ state.projection?.setNodeTransformPreview(event.nodeId, event.before);
1987
+ state.status = `Ignored ${event.tool} ${event.nodeId}`;
1988
+ }
1011
1989
  return false;
1012
1990
  }
1013
1991
  const result = state.session.dispatch({
@@ -1015,6 +1993,8 @@ function commitGizmoTransform(state, options, event) {
1015
1993
  label: patch.label ?? `${event.tool} ${event.nodeId}`,
1016
1994
  patch: patch.patch,
1017
1995
  targetId: event.nodeId,
1996
+ }, {
1997
+ mergeWithPrevious: state.duplicateDrag?.createdIds.includes(event.nodeId) === true,
1018
1998
  });
1019
1999
  if (patch.changedIds)
1020
2000
  syncProjectionForChangedIds(state, options, result.workingDocument, patch.changedIds);
@@ -1022,6 +2002,7 @@ function commitGizmoTransform(state, options, event) {
1022
2002
  syncProjectionForDispatchResult(state, options, result, patch.changedId ?? event.nodeId);
1023
2003
  state.summary = summarizeDocument(options, result.workingDocument, state.session.getSource());
1024
2004
  state.status = patch.label ?? `${event.tool} ${event.nodeId}`;
2005
+ state.duplicateDrag = null;
1025
2006
  return result.documentChanged;
1026
2007
  }
1027
2008
  function isTransformBatchCommit(event) {
@@ -1040,6 +2021,22 @@ function cancelActiveOperation(state) {
1040
2021
  state.sceneViewInput?.cancelActiveIntent();
1041
2022
  state.selectionController?.cancelBoxSelection();
1042
2023
  cancelActiveGizmoDrag(state);
2024
+ clearArmedPlacement(state);
2025
+ }
2026
+ function cancelEditorIntent(state, options) {
2027
+ if (state.mode !== 'editor')
2028
+ return false;
2029
+ if (state.viewportTools.activeUtilityTool === 'measure-distance'
2030
+ || state.viewportMeasurement.start
2031
+ || state.viewportMeasurement.end) {
2032
+ return clearViewportMeasurement(state);
2033
+ }
2034
+ const selection = state.session?.getSelection();
2035
+ if (selection && selection.selectedIds.length > 0) {
2036
+ return dispatchSelectionCommand(state, options, { type: 'selection.clear', label: 'Clear Selection' });
2037
+ }
2038
+ cancelActiveOperation(state);
2039
+ return true;
1043
2040
  }
1044
2041
  function focusSelectedProjection(state) {
1045
2042
  if (state.mode !== 'editor')
@@ -1072,6 +2069,17 @@ function formatEditorStatusTime(timestamp) {
1072
2069
  const pad = (value) => String(value).padStart(2, '0');
1073
2070
  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1074
2071
  }
2072
+ function formatVec3(value) {
2073
+ return `${formatPlacementNumber(value.x)}, ${formatPlacementNumber(value.y)}, ${formatPlacementNumber(value.z)}`;
2074
+ }
2075
+ function formatPlacementNumber(value) {
2076
+ return Number.isFinite(value) ? Number(value.toFixed(3)).toString() : String(value);
2077
+ }
2078
+ function formatAssetLabel(asset, fallback) {
2079
+ const record = isObjectRecord(asset) ? asset : null;
2080
+ const label = record?.label ?? record?.displayName ?? record?.name;
2081
+ return typeof label === 'string' && label.trim() ? label : fallback;
2082
+ }
1075
2083
  function undoSessionChange(state, options) {
1076
2084
  cancelActiveOperation(state);
1077
2085
  const result = state.session?.undo();
@@ -1083,7 +2091,7 @@ function undoSessionChange(state, options) {
1083
2091
  return true;
1084
2092
  }
1085
2093
  function redoSessionChange(state, options) {
1086
- cancelActiveGizmoDrag(state);
2094
+ cancelActiveOperation(state);
1087
2095
  const result = state.session?.redo();
1088
2096
  if (!result)
1089
2097
  return false;
@@ -1134,6 +2142,92 @@ function reprojectProjectionForChangedIds(state, options, document, changedIds)
1134
2142
  syncSelectionToProjection(state, selection);
1135
2143
  state.gizmo?.refreshSelection();
1136
2144
  }
2145
+ function setSceneCameraPreviewEnabled(state, options, enabled) {
2146
+ if (!enabled) {
2147
+ state.sceneCameraPreviewEnabled = false;
2148
+ state.sceneCameraPreview?.setActive(false);
2149
+ state.status = 'Scene Camera preview disabled';
2150
+ return true;
2151
+ }
2152
+ state.sceneCameraPreviewEnabled = true;
2153
+ if (!syncSceneCameraPreview(state, options)) {
2154
+ state.sceneCameraPreviewEnabled = false;
2155
+ state.status = 'Scene Camera preview unavailable';
2156
+ state.statusTone = 'warning';
2157
+ state.statusToneStatus = state.status;
2158
+ state.statusDetails = 'The current document did not provide a Main Camera preview rig.';
2159
+ return true;
2160
+ }
2161
+ state.status = 'Scene Camera preview enabled';
2162
+ return true;
2163
+ }
2164
+ function setGridVisible(state, visible) {
2165
+ const nextVisible = visible === true;
2166
+ if (state.gridVisible === nextVisible)
2167
+ return false;
2168
+ state.gridVisible = nextVisible;
2169
+ state.grid?.setVisible(nextVisible);
2170
+ state.status = nextVisible ? 'Grid visible' : 'Grid hidden';
2171
+ state.statusTone = 'default';
2172
+ state.statusToneStatus = state.status;
2173
+ state.statusDetails = '';
2174
+ return true;
2175
+ }
2176
+ function syncSceneCameraPreview(state, options) {
2177
+ const controller = state.sceneCameraPreview;
2178
+ if (!controller)
2179
+ return false;
2180
+ if (!state.sceneCameraPreviewEnabled) {
2181
+ controller.setActive(false);
2182
+ return false;
2183
+ }
2184
+ const document = state.session?.getState().workingDocument ?? null;
2185
+ const rig = document ? options.documentAdapter.getSceneCameraPreviewRig?.(document) ?? null : null;
2186
+ if (!rig) {
2187
+ controller.setActive(false);
2188
+ return false;
2189
+ }
2190
+ controller.setActive(true, rig);
2191
+ return controller.isActive();
2192
+ }
2193
+ function syncViewportCameraState(state) {
2194
+ const cameraState = state.sceneViewCamera?.getState();
2195
+ if (!cameraState)
2196
+ return;
2197
+ state.viewportTools = {
2198
+ ...state.viewportTools,
2199
+ viewPreset: cameraState.viewPreset,
2200
+ projectionMode: cameraState.projectionMode,
2201
+ };
2202
+ }
2203
+ function syncViewportSpatialOverlay(state) {
2204
+ const overlay = state.sceneViewSpatialOverlay;
2205
+ const selection = state.session?.getState().selection;
2206
+ const activeId = selection?.activeId ?? null;
2207
+ if (state.mode !== 'editor' || !overlay || !activeId || selection?.selectedIds.length !== 1) {
2208
+ state.viewportSpatialOverlay = createEmptyEditorViewportSpatialOverlayState();
2209
+ return;
2210
+ }
2211
+ const bounds = state.projection?.getSelectionBounds([activeId]) ?? null;
2212
+ const anchor = state.projection?.readNodeTransform(activeId)?.position ?? bounds?.center ?? null;
2213
+ state.viewportSpatialOverlay = overlay.compute({
2214
+ nodeId: activeId,
2215
+ bounds,
2216
+ anchor,
2217
+ settings: state.viewportTools.overlay,
2218
+ });
2219
+ }
2220
+ function syncViewportMeasurementState(state) {
2221
+ if (state.mode !== 'editor')
2222
+ return;
2223
+ const measurement = state.sceneViewMeasurement?.getState();
2224
+ if (measurement)
2225
+ state.viewportMeasurement = measurement;
2226
+ }
2227
+ function hasSceneCameraPreviewRig(state, options) {
2228
+ const document = state.session?.getState().workingDocument ?? null;
2229
+ return !!document && !!options.documentAdapter.getSceneCameraPreviewRig?.(document);
2230
+ }
1137
2231
  function createUiState(state, options) {
1138
2232
  const sessionState = state.session?.getState();
1139
2233
  const document = sessionState?.workingDocument ?? null;
@@ -1157,6 +2251,8 @@ function createUiState(state, options) {
1157
2251
  const inspectorMultiObject = document && inspectorMultiObjectBase
1158
2252
  ? withRuntimeInspectorSections(state, options, document, inspectorMultiObjectBase)
1159
2253
  : inspectorMultiObjectBase;
2254
+ const assets = dedupeLocalEditorBrowserAssetItems(state.assets
2255
+ .map(asset => toBrowserAssetItem(options, asset)));
1160
2256
  return {
1161
2257
  mode: state.mode,
1162
2258
  busy: state.busy,
@@ -1165,10 +2261,8 @@ function createUiState(state, options) {
1165
2261
  statusDetails: state.statusToneStatus === state.status ? state.statusDetails : '',
1166
2262
  summary: state.summary,
1167
2263
  assetFilter: state.assetFilter,
1168
- assets: state.assets
1169
- .filter(asset => asset.placeable !== false)
1170
- .map(asset => toBrowserAssetItem(options, asset)),
1171
- assetCountLabel: `${state.assets.length} assets`,
2264
+ assets,
2265
+ assetCountLabel: `${assets.length} assets`,
1172
2266
  hierarchy: document ? options.documentAdapter.getHierarchyItems(document) : [],
1173
2267
  selectedIds,
1174
2268
  activeId,
@@ -1181,6 +2275,9 @@ function createUiState(state, options) {
1181
2275
  inspectorObject,
1182
2276
  inspectorMultiObject,
1183
2277
  boxSelection: state.boxSelection,
2278
+ coordinateAxes: options.world?.coordinateAxes === false
2279
+ ? null
2280
+ : createSceneViewCoordinateAxesState(state),
1184
2281
  transformTool: {
1185
2282
  activeTool: state.gizmo?.getState().tool ?? state.transformTool,
1186
2283
  activeSpace: state.gizmo?.getState().space ?? state.transformSpace,
@@ -1188,6 +2285,24 @@ function createUiState(state, options) {
1188
2285
  dragPhase: state.gizmo?.getState().dragPhase ?? 'idle',
1189
2286
  draggingNodeId: state.gizmo?.getState().draggingNodeId ?? null,
1190
2287
  },
2288
+ transformOperations: {
2289
+ settings: cloneTransformOperationSettings(state.transformOperationSettings),
2290
+ selectedCount: selectedIds.length,
2291
+ activeId,
2292
+ canAlign: selectedIds.length >= 2 && activeId != null,
2293
+ canDistribute: selectedIds.length >= 3,
2294
+ },
2295
+ viewportTools: cloneEditorViewportToolState(state.viewportTools),
2296
+ viewportMeasurement: cloneViewportMeasurement(state.viewportMeasurement),
2297
+ viewportSpatialOverlay: cloneEditorViewportSpatialOverlayState(state.viewportSpatialOverlay),
2298
+ sceneCameraPreview: {
2299
+ enabled: state.sceneCameraPreviewEnabled,
2300
+ available: hasSceneCameraPreviewRig(state, options),
2301
+ },
2302
+ grid: {
2303
+ visible: state.gridVisible,
2304
+ available: !!state.grid,
2305
+ },
1191
2306
  session: sessionState
1192
2307
  ? {
1193
2308
  source: sessionState.source,
@@ -1199,6 +2314,89 @@ function createUiState(state, options) {
1199
2314
  : null,
1200
2315
  };
1201
2316
  }
2317
+ function createSceneViewCoordinateAxesState(state) {
2318
+ if (state.mode !== 'editor')
2319
+ return null;
2320
+ const scene = state.world?.scene ?? null;
2321
+ const camera = scene?.activeCamera ?? state.world?.camera ?? null;
2322
+ const Vector3 = state.babylon?.Vector3;
2323
+ if (!scene || !camera || !Vector3)
2324
+ return null;
2325
+ const right = readSceneViewCameraDirection(camera, Vector3, { x: 1, y: 0, z: 0 });
2326
+ const up = readSceneViewCameraDirection(camera, Vector3, { x: 0, y: 1, z: 0 });
2327
+ const forward = readSceneViewCameraForward(scene, camera, Vector3);
2328
+ if (!right || !up || !forward)
2329
+ return null;
2330
+ return {
2331
+ axes: [
2332
+ createSceneViewCoordinateAxis('x', 'X', '#ff4b70', { x: 1, y: 0, z: 0 }, right, up, forward),
2333
+ createSceneViewCoordinateAxis('y', 'Y', '#75ff42', { x: 0, y: 1, z: 0 }, right, up, forward),
2334
+ createSceneViewCoordinateAxis('z', 'Z', '#4f86ff', { x: 0, y: 0, z: 1 }, right, up, forward),
2335
+ ],
2336
+ };
2337
+ }
2338
+ function createSceneViewCoordinateAxis(id, label, color, worldAxis, cameraRight, cameraUp, cameraForward) {
2339
+ const screenX = dotSceneViewAxisVec3(worldAxis, cameraRight);
2340
+ const screenY = -dotSceneViewAxisVec3(worldAxis, cameraUp);
2341
+ const projectedLength = Math.hypot(screenX, screenY);
2342
+ const fallback = getSceneViewAxisFallbackDirection(id);
2343
+ const direction = projectedLength > 0.0001
2344
+ ? { x: screenX / projectedLength, y: screenY / projectedLength }
2345
+ : fallback;
2346
+ return {
2347
+ id,
2348
+ label,
2349
+ color,
2350
+ x: direction.x,
2351
+ y: direction.y,
2352
+ depth: dotSceneViewAxisVec3(worldAxis, cameraForward),
2353
+ scale: clampNumber(projectedLength, 0.36, 1),
2354
+ };
2355
+ }
2356
+ function readSceneViewCameraDirection(camera, Vector3, local) {
2357
+ if (!camera?.getDirection)
2358
+ return null;
2359
+ try {
2360
+ return normalizeSceneViewAxisVec3(camera.getDirection(new Vector3(local.x, local.y, local.z)));
2361
+ }
2362
+ catch {
2363
+ return null;
2364
+ }
2365
+ }
2366
+ function readSceneViewCameraForward(scene, camera, Vector3) {
2367
+ const rayDirection = normalizeSceneViewAxisVec3(camera?.getForwardRay?.()?.direction);
2368
+ if (rayDirection)
2369
+ return rayDirection;
2370
+ return readSceneViewCameraDirection(camera, Vector3, {
2371
+ x: 0,
2372
+ y: 0,
2373
+ z: scene?.useRightHandedSystem ? -1 : 1,
2374
+ });
2375
+ }
2376
+ function normalizeSceneViewAxisVec3(value) {
2377
+ const x = Number(value?.x) || 0;
2378
+ const y = Number(value?.y) || 0;
2379
+ const z = Number(value?.z) || 0;
2380
+ const length = Math.hypot(x, y, z);
2381
+ if (!Number.isFinite(length) || length <= 0.000001)
2382
+ return null;
2383
+ return { x: x / length, y: y / length, z: z / length };
2384
+ }
2385
+ function dotSceneViewAxisVec3(left, right) {
2386
+ return left.x * right.x + left.y * right.y + left.z * right.z;
2387
+ }
2388
+ function getSceneViewAxisFallbackDirection(id) {
2389
+ if (id === 'x')
2390
+ return { x: 1, y: 0 };
2391
+ if (id === 'y')
2392
+ return { x: 0, y: -1 };
2393
+ return { x: -1, y: 0 };
2394
+ }
2395
+ function clampNumber(value, min, max) {
2396
+ if (!Number.isFinite(value))
2397
+ return min;
2398
+ return Math.min(max, Math.max(min, value));
2399
+ }
1202
2400
  function withRuntimeInspectorSections(state, options, document, inspectorObject) {
1203
2401
  const baseInspectorObject = withInspectorComponentSections(state, options, document, inspectorObject);
1204
2402
  const activeId = inspectorObject.activeId;
@@ -1270,9 +2468,10 @@ function createDefaultRuntimeInspectorSections(context) {
1270
2468
  const properties = [];
1271
2469
  if (projectionNode) {
1272
2470
  properties.push(createRuntimeInspectorProperty('runtime.projection.nodeId', 'Projected ID', projectionNode.id, properties.length));
1273
- const assetSource = projectionNode.asset?.sourceId ?? projectionNode.asset?.id ?? '';
1274
- if (assetSource)
1275
- properties.push(createRuntimeInspectorProperty('runtime.projection.assetSource', 'Asset Source', assetSource, properties.length));
2471
+ const projectionAsset = projectionNode.asset;
2472
+ const assetId = projectionAsset?.assetId ?? projectionAsset?.id ?? '';
2473
+ if (assetId)
2474
+ properties.push(createRuntimeInspectorProperty('runtime.projection.assetId', 'Asset ID', assetId, properties.length));
1276
2475
  }
1277
2476
  const runtimeClass = readRuntimeClassName(root);
1278
2477
  if (runtimeClass)
@@ -1389,15 +2588,114 @@ function isNodeSelectableInDocument(options, document, id) {
1389
2588
  }
1390
2589
  function resolveAssetId(options, asset) {
1391
2590
  return options.worldAdapter.resolveAssetId?.(asset)
2591
+ ?? asset.assetId
1392
2592
  ?? asset.id;
1393
2593
  }
2594
+ function findAssetByResolvedId(state, options, assetId) {
2595
+ let selected = null;
2596
+ for (const asset of state.assets) {
2597
+ if (resolveAssetId(options, asset) !== assetId)
2598
+ continue;
2599
+ const item = toBrowserAssetItem(options, asset);
2600
+ if (!selected || shouldReplaceBrowserAssetItem(selected.item, item)) {
2601
+ selected = { asset, item };
2602
+ }
2603
+ }
2604
+ return selected?.asset;
2605
+ }
1394
2606
  function toBrowserAssetItem(options, asset) {
1395
2607
  return options.worldAdapter.toBrowserAssetItem?.(asset)
1396
2608
  ?? {
1397
- id: asset.id,
1398
- label: asset.label,
2609
+ id: asset.assetId ?? asset.id,
2610
+ label: asset.displayName ?? asset.label,
2611
+ guid: asset.guid,
2612
+ assetId: asset.assetId,
2613
+ kind: asset.kind,
2614
+ external: asset.external,
2615
+ origin: asset.origin,
2616
+ dedupeKey: asset.dedupeKey,
2617
+ placeable: asset.placeable,
1399
2618
  meta: asset.meta,
1400
- disabled: asset.placeable === false,
2619
+ disabled: asset.disabled ?? asset.placeable === false,
1401
2620
  };
1402
2621
  }
2622
+ export function dedupeLocalEditorBrowserAssetItems(items) {
2623
+ const byKey = new Map();
2624
+ for (const item of items) {
2625
+ const key = getBrowserAssetCanonicalKey(item);
2626
+ const existing = byKey.get(key);
2627
+ if (!existing || shouldReplaceBrowserAssetItem(existing, item)) {
2628
+ byKey.set(key, item);
2629
+ }
2630
+ }
2631
+ const projectDuplicateKeys = new Set();
2632
+ for (const item of byKey.values()) {
2633
+ if (!item.guid || item.origin !== 'project')
2634
+ continue;
2635
+ for (const duplicateKey of getBrowserAssetDuplicateSuppressionKeys(item)) {
2636
+ projectDuplicateKeys.add(duplicateKey);
2637
+ }
2638
+ }
2639
+ return [...byKey.values()].filter((item) => {
2640
+ if (item.guid)
2641
+ return true;
2642
+ return !getBrowserAssetDuplicateSuppressionKeys(item)
2643
+ .some(duplicateKey => projectDuplicateKeys.has(duplicateKey));
2644
+ });
2645
+ }
2646
+ function getBrowserAssetCanonicalKey(item) {
2647
+ const guid = normalizeBrowserAssetString(item.guid);
2648
+ if (guid)
2649
+ return `guid:${guid}`;
2650
+ const platformAssetId = normalizeBrowserAssetString(item.external?.platformAssetId);
2651
+ if (platformAssetId)
2652
+ return `external:${platformAssetId}`;
2653
+ const dedupeKey = normalizeBrowserAssetString(item.dedupeKey);
2654
+ if (dedupeKey)
2655
+ return `dedupe:${dedupeKey}`;
2656
+ const assetId = normalizeBrowserAssetString(item.assetId);
2657
+ if (assetId)
2658
+ return `asset:${assetId}`;
2659
+ return `id:${normalizeBrowserAssetString(item.id) ?? item.id}`;
2660
+ }
2661
+ function getBrowserAssetDuplicateSuppressionKeys(item) {
2662
+ const keys = [];
2663
+ pushBrowserAssetDuplicateKey(keys, 'external', item.external?.platformAssetId);
2664
+ pushBrowserAssetDuplicateKey(keys, 'dedupe', item.dedupeKey);
2665
+ pushBrowserAssetDuplicateKey(keys, 'asset', item.assetId);
2666
+ pushBrowserAssetDuplicateKey(keys, 'id', item.id);
2667
+ return keys;
2668
+ }
2669
+ function pushBrowserAssetDuplicateKey(keys, prefix, value) {
2670
+ const normalized = normalizeBrowserAssetString(value);
2671
+ if (normalized)
2672
+ keys.push(`${prefix}:${normalized}`);
2673
+ }
2674
+ function shouldReplaceBrowserAssetItem(current, candidate) {
2675
+ const currentScore = getBrowserAssetPreferenceScore(current);
2676
+ const candidateScore = getBrowserAssetPreferenceScore(candidate);
2677
+ if (candidateScore !== currentScore)
2678
+ return candidateScore > currentScore;
2679
+ return getBrowserAssetDetailScore(candidate) > getBrowserAssetDetailScore(current);
2680
+ }
2681
+ function getBrowserAssetPreferenceScore(item) {
2682
+ return (item.origin === 'project' ? 1000 : 0)
2683
+ + (item.guid ? 100 : 0)
2684
+ + (item.assetId ? 40 : 0)
2685
+ + (item.placeable === false ? 0 : 10)
2686
+ + (item.disabled ? 0 : 5);
2687
+ }
2688
+ function getBrowserAssetDetailScore(item) {
2689
+ return [
2690
+ item.label,
2691
+ item.meta,
2692
+ item.kind,
2693
+ item.external?.platformAssetId,
2694
+ item.external?.assetPath,
2695
+ item.external?.assetUrl,
2696
+ ].filter(value => typeof value === 'string' && value.trim()).length;
2697
+ }
2698
+ function normalizeBrowserAssetString(value) {
2699
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
2700
+ }
1403
2701
  //# sourceMappingURL=local-editor-harness.js.map