@fps-games/editor 0.1.1-beta.6 → 0.1.1-beta.8

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 (115) hide show
  1. package/dist/local-editor-harness.d.ts +26 -3
  2. package/dist/local-editor-harness.d.ts.map +1 -1
  3. package/dist/local-editor-harness.js +534 -36
  4. package/dist/local-editor-harness.js.map +1 -1
  5. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts +33 -0
  6. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts.map +1 -0
  7. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js +231 -0
  8. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js.map +1 -0
  9. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts +3 -0
  10. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts.map +1 -1
  11. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js +8 -0
  12. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js.map +1 -1
  13. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts +4 -0
  14. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts.map +1 -1
  15. package/node_modules/@fps-games/editor-babylon/dist/index.js +4 -0
  16. package/node_modules/@fps-games/editor-babylon/dist/index.js.map +1 -1
  17. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts +40 -0
  18. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts.map +1 -0
  19. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js +237 -0
  20. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js.map +1 -0
  21. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.d.ts +1 -1
  22. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.d.ts.map +1 -1
  23. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.js +15 -50
  24. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.js.map +1 -1
  25. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts +5 -0
  26. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts.map +1 -1
  27. package/node_modules/@fps-games/editor-babylon/dist/projection.js +61 -8
  28. package/node_modules/@fps-games/editor-babylon/dist/projection.js.map +1 -1
  29. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts +18 -0
  30. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts.map +1 -1
  31. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js +142 -0
  32. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js.map +1 -1
  33. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts +1 -0
  34. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts.map +1 -1
  35. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js +3 -1
  36. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js.map +1 -1
  37. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.d.ts +20 -0
  38. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.d.ts.map +1 -0
  39. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.js +187 -0
  40. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.js.map +1 -0
  41. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.d.ts +20 -0
  42. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.d.ts.map +1 -0
  43. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.js +188 -0
  44. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.js.map +1 -0
  45. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts +11 -0
  46. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  47. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  48. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.js +97 -46
  49. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.js.map +1 -1
  50. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts +5 -0
  51. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts.map +1 -1
  52. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js +57 -7
  53. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js.map +1 -1
  54. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts.map +1 -1
  55. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js +61 -5
  56. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js.map +1 -1
  57. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-tree.js +0 -2
  58. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-tree.js.map +1 -1
  59. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.d.ts.map +1 -1
  60. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js +6 -2
  61. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js.map +1 -1
  62. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts +1 -1
  63. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts.map +1 -1
  64. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js +58 -0
  65. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js.map +1 -1
  66. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts +8 -1
  67. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts.map +1 -1
  68. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js +358 -53
  69. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js.map +1 -1
  70. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts +5 -0
  71. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts.map +1 -1
  72. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js +28 -8
  73. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js.map +1 -1
  74. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.d.ts.map +1 -1
  75. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js +4 -1
  76. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js.map +1 -1
  77. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.d.ts.map +1 -1
  78. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.js +14 -0
  79. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.js.map +1 -1
  80. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.d.ts +11 -0
  81. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.d.ts.map +1 -0
  82. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js +148 -0
  83. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js.map +1 -0
  84. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +57 -1
  85. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  86. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +5 -4
  87. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  88. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +1108 -205
  89. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  90. package/node_modules/@fps-games/editor-browser/package.json +2 -2
  91. package/node_modules/@fps-games/editor-core/dist/index.d.ts +2 -0
  92. package/node_modules/@fps-games/editor-core/dist/index.d.ts.map +1 -1
  93. package/node_modules/@fps-games/editor-core/dist/index.js +2 -0
  94. package/node_modules/@fps-games/editor-core/dist/index.js.map +1 -1
  95. package/node_modules/@fps-games/editor-core/dist/inspector.d.ts +6 -1
  96. package/node_modules/@fps-games/editor-core/dist/inspector.d.ts.map +1 -1
  97. package/node_modules/@fps-games/editor-core/dist/inspector.js +6 -1
  98. package/node_modules/@fps-games/editor-core/dist/inspector.js.map +1 -1
  99. package/node_modules/@fps-games/editor-core/dist/scene-graph.d.ts +7 -0
  100. package/node_modules/@fps-games/editor-core/dist/scene-graph.d.ts.map +1 -1
  101. package/node_modules/@fps-games/editor-core/dist/scene-graph.js.map +1 -1
  102. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts +1 -1
  103. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts.map +1 -1
  104. package/node_modules/@fps-games/editor-core/dist/selection-strategy.d.ts +12 -0
  105. package/node_modules/@fps-games/editor-core/dist/selection-strategy.d.ts.map +1 -0
  106. package/node_modules/@fps-games/editor-core/dist/selection-strategy.js +52 -0
  107. package/node_modules/@fps-games/editor-core/dist/selection-strategy.js.map +1 -0
  108. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts +94 -0
  109. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts.map +1 -0
  110. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js +58 -0
  111. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js.map +1 -0
  112. package/node_modules/@fps-games/editor-core/package.json +2 -2
  113. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  114. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  115. package/package.json +6 -6
@@ -1,18 +1,21 @@
1
1
  import * as LocalEditorPanels from './local-editor-ui-panels';
2
2
  import { createLocalEditorHierarchyController } from './local-editor-ui-hierarchy-controller';
3
3
  import { createLocalEditorContextMenuController } from './local-editor-ui-context-menu';
4
+ import { createLocalEditorTooltipController } from './local-editor-ui-tooltip';
4
5
  import { DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS, DEFAULT_EDITOR_TRANSFORM_TOOL_DESCRIPTORS, } from '@fps-games/editor-core';
5
- export { createLocalEditorHierarchyBlankMenu, createLocalEditorHierarchyCopyShortcutAction, createLocalEditorHierarchyDeleteShortcutAction, createLocalEditorHierarchyDuplicateShortcutAction, createLocalEditorHierarchyNodeMenu, createLocalEditorHierarchyPasteShortcutAction, } from './local-editor-ui-hierarchy-actions';
6
+ export { createLocalEditorHierarchyBlankMenu, createLocalEditorHierarchyCopyShortcutAction, createLocalEditorHierarchyDeleteShortcutAction, createLocalEditorHierarchyDuplicateShortcutAction, createLocalEditorHierarchyNodeMenu, createLocalEditorHierarchyPasteShortcutAction, createLocalEditorHierarchySelectAllShortcutAction, } from './local-editor-ui-hierarchy-actions';
6
7
  export { createLocalEditorHierarchyController, } from './local-editor-ui-hierarchy-controller';
7
8
  export { canLocalEditorHierarchyNodeHaveChildren, createLocalEditorHierarchyTreeModel, isLocalEditorHierarchyNodeMovable, } from './local-editor-ui-hierarchy-tree';
8
9
  export { createLocalEditorContextMenuController, } from './local-editor-ui-context-menu';
10
+ export { createLocalEditorTooltipController, } from './local-editor-ui-tooltip';
9
11
  import { createLocalEditorWorkbenchInputRouter } from './local-editor-ui-input-router';
10
12
  import { createLocalEditorPanelRegistry } from './local-editor-ui-panel-registry';
11
13
  import * as LocalEditorShared from './local-editor-ui-shared';
12
14
  import { createShortcutHelpPanel as createLocalEditorShortcutHelpPanel } from './local-editor-ui-shortcuts';
13
15
  import { applyLocalEditorTheme, ensureLocalEditorTheme, LOCAL_EDITOR_THEME_CLASS, normalizeLocalEditorThemeName, } from './local-editor-ui-theme';
14
16
  import { createDefaultLocalEditorWorkbenchLayout, createLocalEditorWorkbenchLayoutController, createLocalEditorWorkbench, createSceneHeaderToolbar, createWorkbenchPanelContent, } from './local-editor-ui-workbench';
15
- export { applyLocalEditorBrowserInspectorControlBinding, createLocalEditorBrowserInspectorControlRegistry, formatLocalEditorBrowserInspectorValue, resolveLocalEditorBrowserInspectorControlRegistration, } from './local-editor-ui-panels';
17
+ import { createLocalEditorIcon } from './local-editor-ui-icons';
18
+ export { applyLocalEditorBrowserInspectorControlBinding, createLocalEditorBrowserInspectorControlRegistry, formatLocalEditorBrowserInspectorValue, resolveLocalEditorBrowserInspectorSectionStatus, resolveLocalEditorBrowserInspectorControlRegistration, } from './local-editor-ui-panels';
16
19
  export { DEFAULT_LOCAL_EDITOR_THEME, LOCAL_EDITOR_THEME_CLASS, applyLocalEditorTheme, normalizeLocalEditorThemeName, } from './local-editor-ui-theme';
17
20
  export { LOCAL_EDITOR_ICON_NAMES, createLocalEditorIcon, isLocalEditorIconName, resolveLocalEditorIconName, } from './local-editor-ui-icons';
18
21
  function readInspectorInputValue(input) {
@@ -67,6 +70,84 @@ const PLACEMENT_MODE_ICONS = {
67
70
  ground: 'place-ground',
68
71
  surface: 'place-surface',
69
72
  };
73
+ const PLACEMENT_MODE_DESCRIPTIONS = {
74
+ off: '关闭放置模式',
75
+ ground: '放置到 XZ 地面',
76
+ surface: '放置到场景表面',
77
+ };
78
+ const VIEW_PRESET_ICONS = {
79
+ perspective: 'view-perspective',
80
+ top: 'view-top',
81
+ front: 'view-front',
82
+ right: 'view-right',
83
+ };
84
+ const VIEW_PRESET_TOOLTIPS = {
85
+ perspective: '透视视图',
86
+ top: '顶视图 · 自动切换正交',
87
+ front: '前视图 · 自动切换正交',
88
+ right: '右视图 · 自动切换正交',
89
+ };
90
+ const VIEWPORT_OVERLAY_TOGGLE_LABELS = {
91
+ bounds: '包围盒',
92
+ dimensions: '尺寸摘要',
93
+ edgeLengths: '边长标注',
94
+ anchor: '锚点坐标',
95
+ };
96
+ const VIEWPORT_PROJECTION_LABELS = {
97
+ perspective: '透视',
98
+ orthographic: '正交',
99
+ };
100
+ const TRANSFORM_ACTION_GROUPS = [
101
+ {
102
+ title: '对齐到 Active Object',
103
+ hint: '需要至少 2 个选中对象',
104
+ actions: ['align-x', 'align-y', 'align-z', 'align-all'],
105
+ },
106
+ {
107
+ title: '均匀分布',
108
+ hint: '需要至少 3 个选中对象',
109
+ actions: ['distribute-x', 'distribute-y', 'distribute-z'],
110
+ },
111
+ ];
112
+ const TRANSFORM_ACTION_LABELS = {
113
+ 'align-x': '对齐 X',
114
+ 'align-y': '对齐 Y',
115
+ 'align-z': '对齐 Z',
116
+ 'align-all': '对齐全',
117
+ 'distribute-x': '分布 X',
118
+ 'distribute-y': '分布 Y',
119
+ 'distribute-z': '分布 Z',
120
+ };
121
+ function createTransformToolTooltip(descriptor) {
122
+ const toolLabel = descriptor.shortcut
123
+ ? `${descriptor.label}工具 (${descriptor.shortcut})`
124
+ : `${descriptor.label}工具`;
125
+ return `${toolLabel} · ${LocalEditorShared.toTransformMouseHint(descriptor.tool)}`;
126
+ }
127
+ function createToolbarIconButton(doc, label, icon, tooltip = label, toolbarRole = 'command') {
128
+ return LocalEditorShared.createButton(doc, label, {
129
+ icon,
130
+ labelMode: 'icon-only',
131
+ variant: 'toolbar-icon',
132
+ tooltip,
133
+ ariaLabel: tooltip,
134
+ toolbarRole,
135
+ });
136
+ }
137
+ function setToolbarButtonTooltip(button, tooltip) {
138
+ button.dataset.editorTooltip = tooltip;
139
+ button.setAttribute('aria-label', tooltip);
140
+ }
141
+ function setToolbarButtonIcon(doc, button, icon) {
142
+ const currentIcon = button.querySelector('[data-editor-icon]');
143
+ if (currentIcon?.dataset.editorIcon === icon)
144
+ return;
145
+ const nextIcon = createLocalEditorIcon(doc, icon);
146
+ if (currentIcon)
147
+ currentIcon.replaceWith(nextIcon);
148
+ else
149
+ button.prepend(nextIcon);
150
+ }
70
151
  function readInspectorVectorInputValue(input) {
71
152
  const wrapper = input.closest('[data-inspector-vector-control]');
72
153
  const values = {};
@@ -112,6 +193,377 @@ function readInspectorImmediateSource(input) {
112
193
  return 'color';
113
194
  return 'input';
114
195
  }
196
+ function createCoordinateAxesOverlay(doc) {
197
+ const root = doc.createElement('div');
198
+ root.dataset.editorCoordinateAxesOverlay = 'true';
199
+ root.style.cssText = [
200
+ 'position:absolute',
201
+ 'right:14px',
202
+ 'bottom:14px',
203
+ 'width:112px',
204
+ 'height:112px',
205
+ 'display:none',
206
+ 'z-index:2',
207
+ 'box-sizing:border-box',
208
+ 'border:1px solid color-mix(in srgb, var(--fps-editor-border) 78%, transparent)',
209
+ 'border-radius:8px',
210
+ 'background:color-mix(in srgb, var(--fps-editor-chrome) 58%, transparent)',
211
+ 'box-shadow:var(--fps-editor-shadow-panel)',
212
+ 'pointer-events:none',
213
+ 'overflow:hidden',
214
+ ].join(';');
215
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
216
+ svg.setAttribute('viewBox', '0 0 112 112');
217
+ svg.setAttribute('aria-hidden', 'true');
218
+ svg.style.cssText = 'display:block;width:100%;height:100%';
219
+ root.appendChild(svg);
220
+ const lineLayer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
221
+ const labelLayer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
222
+ svg.appendChild(lineLayer);
223
+ svg.appendChild(labelLayer);
224
+ const axisGroups = new Map();
225
+ const axisLines = new Map();
226
+ const axisLabels = new Map();
227
+ const axisDiscs = new Map();
228
+ for (const axis of ['x', 'y', 'z']) {
229
+ const group = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
230
+ const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line');
231
+ line.setAttribute('stroke-width', '3.2');
232
+ line.setAttribute('stroke-linecap', 'round');
233
+ line.setAttribute('x1', '56');
234
+ line.setAttribute('y1', '56');
235
+ const disc = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
236
+ disc.setAttribute('r', '7');
237
+ const label = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
238
+ label.setAttribute('text-anchor', 'middle');
239
+ label.setAttribute('dominant-baseline', 'middle');
240
+ label.setAttribute('dy', '0.04em');
241
+ label.style.cssText = [
242
+ 'fill:#172033',
243
+ 'font-size:10px',
244
+ 'font-weight:900',
245
+ 'font-family:Arial, Helvetica, sans-serif',
246
+ 'letter-spacing:0',
247
+ ].join(';');
248
+ group.appendChild(disc);
249
+ group.appendChild(label);
250
+ lineLayer.appendChild(line);
251
+ labelLayer.appendChild(group);
252
+ axisGroups.set(axis, group);
253
+ axisLines.set(axis, line);
254
+ axisLabels.set(axis, label);
255
+ axisDiscs.set(axis, disc);
256
+ }
257
+ const centerDot = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
258
+ centerDot.setAttribute('cx', '56');
259
+ centerDot.setAttribute('cy', '56');
260
+ centerDot.setAttribute('r', '3.2');
261
+ centerDot.setAttribute('fill', 'color-mix(in srgb, var(--fps-editor-text) 82%, transparent)');
262
+ labelLayer.appendChild(centerDot);
263
+ return {
264
+ root,
265
+ lineLayer,
266
+ labelLayer,
267
+ axisGroups,
268
+ axisLines,
269
+ axisLabels,
270
+ axisDiscs,
271
+ centerDot,
272
+ };
273
+ }
274
+ function renderCoordinateAxesOverlay(overlay, state) {
275
+ if (!state?.axes?.length) {
276
+ overlay.root.style.display = 'none';
277
+ return;
278
+ }
279
+ overlay.root.style.display = '';
280
+ const center = 56;
281
+ const lineLength = 38;
282
+ const sortedAxes = [...state.axes].sort((left, right) => right.depth - left.depth);
283
+ for (const axis of sortedAxes) {
284
+ const group = overlay.axisGroups.get(axis.id);
285
+ const line = overlay.axisLines.get(axis.id);
286
+ const disc = overlay.axisDiscs.get(axis.id);
287
+ const label = overlay.axisLabels.get(axis.id);
288
+ if (!group || !line || !disc || !label)
289
+ continue;
290
+ const length = lineLength * axis.scale;
291
+ const endX = center + axis.x * length;
292
+ const endY = center + axis.y * length;
293
+ line.setAttribute('x2', formatSvgNumber(endX));
294
+ line.setAttribute('y2', formatSvgNumber(endY));
295
+ line.setAttribute('stroke', axis.color);
296
+ disc.setAttribute('cx', formatSvgNumber(endX));
297
+ disc.setAttribute('cy', formatSvgNumber(endY));
298
+ disc.setAttribute('fill', axis.color);
299
+ label.setAttribute('x', formatSvgNumber(endX));
300
+ label.setAttribute('y', formatSvgNumber(endY));
301
+ label.textContent = axis.label;
302
+ group.style.opacity = String(0.72 + axis.scale * 0.28);
303
+ overlay.lineLayer.appendChild(line);
304
+ overlay.labelLayer.appendChild(group);
305
+ }
306
+ overlay.labelLayer.appendChild(overlay.centerDot);
307
+ }
308
+ function createSpatialOverlay(doc) {
309
+ const root = doc.createElement('div');
310
+ root.classList.add(LOCAL_EDITOR_THEME_CLASS);
311
+ root.dataset.editorSpatialOverlay = 'true';
312
+ root.style.cssText = [
313
+ 'position:absolute',
314
+ 'top:38px',
315
+ 'left:0',
316
+ 'right:0',
317
+ 'bottom:0',
318
+ 'z-index:1',
319
+ 'display:none',
320
+ 'pointer-events:none',
321
+ 'font-family:var(--fps-editor-font)',
322
+ 'color:var(--fps-editor-text)',
323
+ 'overflow:hidden',
324
+ ].join(';');
325
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
326
+ svg.setAttribute('aria-hidden', 'true');
327
+ svg.style.cssText = [
328
+ 'position:absolute',
329
+ 'inset:0',
330
+ 'width:100%',
331
+ 'height:100%',
332
+ 'overflow:hidden',
333
+ ].join(';');
334
+ const lineLayer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
335
+ const markerLayer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
336
+ svg.appendChild(lineLayer);
337
+ svg.appendChild(markerLayer);
338
+ root.appendChild(svg);
339
+ const labelLayer = doc.createElement('div');
340
+ labelLayer.style.cssText = 'position:absolute;inset:0;pointer-events:none';
341
+ root.appendChild(labelLayer);
342
+ return {
343
+ root,
344
+ svg,
345
+ lineLayer,
346
+ markerLayer,
347
+ labelLayer,
348
+ lines: new Map(),
349
+ markers: new Map(),
350
+ labels: new Map(),
351
+ };
352
+ }
353
+ function renderSpatialOverlay(overlay, state) {
354
+ if (!state?.active) {
355
+ overlay.root.style.display = 'none';
356
+ clearSpatialOverlayElements(overlay);
357
+ return;
358
+ }
359
+ overlay.root.style.display = '';
360
+ const rect = overlay.root.getBoundingClientRect();
361
+ overlay.svg.setAttribute('width', formatSvgNumber(rect.width));
362
+ overlay.svg.setAttribute('height', formatSvgNumber(rect.height));
363
+ overlay.svg.setAttribute('viewBox', `0 0 ${formatSvgNumber(rect.width)} ${formatSvgNumber(rect.height)}`);
364
+ const visibleLineIds = new Set();
365
+ for (const lineState of state.lines) {
366
+ const line = ensureSpatialOverlayLine(overlay, lineState.id);
367
+ line.setAttribute('x1', formatSvgNumber(lineState.start.x - rect.left));
368
+ line.setAttribute('y1', formatSvgNumber(lineState.start.y - rect.top));
369
+ line.setAttribute('x2', formatSvgNumber(lineState.end.x - rect.left));
370
+ line.setAttribute('y2', formatSvgNumber(lineState.end.y - rect.top));
371
+ visibleLineIds.add(lineState.id);
372
+ }
373
+ for (const [id, line] of overlay.lines) {
374
+ if (!visibleLineIds.has(id)) {
375
+ line.remove();
376
+ overlay.lines.delete(id);
377
+ }
378
+ }
379
+ const visibleMarkerIds = new Set();
380
+ for (const markerState of state.markers) {
381
+ const marker = ensureSpatialOverlayMarker(overlay, markerState.id);
382
+ marker.setAttribute('cx', formatSvgNumber(markerState.position.x - rect.left));
383
+ marker.setAttribute('cy', formatSvgNumber(markerState.position.y - rect.top));
384
+ visibleMarkerIds.add(markerState.id);
385
+ }
386
+ for (const [id, marker] of overlay.markers) {
387
+ if (!visibleMarkerIds.has(id)) {
388
+ marker.remove();
389
+ overlay.markers.delete(id);
390
+ }
391
+ }
392
+ const visibleLabelIds = new Set();
393
+ for (const labelState of state.labels) {
394
+ const label = ensureSpatialOverlayLabel(overlay, labelState.id, labelState.kind);
395
+ label.textContent = labelState.text;
396
+ label.style.left = `${labelState.x - rect.left}px`;
397
+ label.style.top = `${labelState.y - rect.top}px`;
398
+ visibleLabelIds.add(labelState.id);
399
+ }
400
+ for (const [id, label] of overlay.labels) {
401
+ if (!visibleLabelIds.has(id)) {
402
+ label.remove();
403
+ overlay.labels.delete(id);
404
+ }
405
+ }
406
+ }
407
+ function clearSpatialOverlayElements(overlay) {
408
+ for (const line of overlay.lines.values())
409
+ line.remove();
410
+ for (const marker of overlay.markers.values())
411
+ marker.remove();
412
+ for (const label of overlay.labels.values())
413
+ label.remove();
414
+ overlay.lines.clear();
415
+ overlay.markers.clear();
416
+ overlay.labels.clear();
417
+ }
418
+ function ensureSpatialOverlayLine(overlay, id) {
419
+ const existing = overlay.lines.get(id);
420
+ if (existing)
421
+ return existing;
422
+ const line = overlay.root.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'line');
423
+ line.setAttribute('stroke', 'color-mix(in srgb, var(--fps-editor-drop-target) 82%, white)');
424
+ line.setAttribute('stroke-width', '1.4');
425
+ line.setAttribute('stroke-linecap', 'round');
426
+ line.setAttribute('vector-effect', 'non-scaling-stroke');
427
+ line.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,0.45))';
428
+ overlay.lineLayer.appendChild(line);
429
+ overlay.lines.set(id, line);
430
+ return line;
431
+ }
432
+ function ensureSpatialOverlayMarker(overlay, id) {
433
+ const existing = overlay.markers.get(id);
434
+ if (existing)
435
+ return existing;
436
+ const marker = overlay.root.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'circle');
437
+ marker.setAttribute('r', '4.5');
438
+ marker.setAttribute('fill', 'var(--fps-editor-drop-target)');
439
+ marker.setAttribute('stroke', 'var(--fps-editor-panel)');
440
+ marker.setAttribute('stroke-width', '2');
441
+ marker.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))';
442
+ overlay.markerLayer.appendChild(marker);
443
+ overlay.markers.set(id, marker);
444
+ return marker;
445
+ }
446
+ function ensureSpatialOverlayLabel(overlay, id, kind) {
447
+ const existing = overlay.labels.get(id);
448
+ if (existing)
449
+ return existing;
450
+ const label = overlay.root.ownerDocument.createElement('div');
451
+ label.dataset.editorSpatialOverlayLabel = kind;
452
+ label.style.cssText = [
453
+ 'position:absolute',
454
+ 'transform:translate(-50%, -50%)',
455
+ 'max-width:220px',
456
+ 'padding:2px 5px',
457
+ 'border:1px solid color-mix(in srgb, var(--fps-editor-border) 82%, transparent)',
458
+ 'border-radius:3px',
459
+ 'background:color-mix(in srgb, var(--fps-editor-panel) 88%, transparent)',
460
+ 'box-shadow:0 1px 4px rgba(0,0,0,0.22)',
461
+ 'color:var(--fps-editor-text-strong)',
462
+ 'font-size:10px',
463
+ 'font-weight:900',
464
+ 'line-height:1.25',
465
+ 'letter-spacing:0',
466
+ 'white-space:nowrap',
467
+ ].join(';');
468
+ if (kind === 'anchor')
469
+ label.style.color = 'var(--fps-editor-drop-target)';
470
+ overlay.labelLayer.appendChild(label);
471
+ overlay.labels.set(id, label);
472
+ return label;
473
+ }
474
+ function createMeasurementOverlay(doc) {
475
+ const root = doc.createElement('div');
476
+ root.classList.add(LOCAL_EDITOR_THEME_CLASS);
477
+ root.dataset.editorMeasurementOverlay = 'true';
478
+ root.style.cssText = [
479
+ 'position:absolute',
480
+ 'top:38px',
481
+ 'left:0',
482
+ 'right:0',
483
+ 'bottom:0',
484
+ 'z-index:2',
485
+ 'display:none',
486
+ 'pointer-events:none',
487
+ 'overflow:hidden',
488
+ 'font-family:var(--fps-editor-font)',
489
+ ].join(';');
490
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
491
+ svg.setAttribute('aria-hidden', 'true');
492
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;overflow:hidden';
493
+ const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line');
494
+ line.setAttribute('stroke', 'var(--fps-editor-drop-target)');
495
+ line.setAttribute('stroke-width', '2');
496
+ line.setAttribute('stroke-linecap', 'round');
497
+ line.setAttribute('vector-effect', 'non-scaling-stroke');
498
+ line.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))';
499
+ const startMarker = createMeasurementMarker(doc);
500
+ const endMarker = createMeasurementMarker(doc);
501
+ svg.appendChild(line);
502
+ svg.appendChild(startMarker);
503
+ svg.appendChild(endMarker);
504
+ root.appendChild(svg);
505
+ const label = doc.createElement('div');
506
+ label.dataset.editorMeasurementLabel = 'true';
507
+ label.style.cssText = [
508
+ 'position:absolute',
509
+ 'transform:translate(-50%, -50%)',
510
+ 'padding:3px 6px',
511
+ 'border:1px solid color-mix(in srgb, var(--fps-editor-drop-target) 72%, var(--fps-editor-border))',
512
+ 'border-radius:3px',
513
+ 'background:color-mix(in srgb, var(--fps-editor-panel) 90%, transparent)',
514
+ 'box-shadow:0 1px 4px rgba(0,0,0,0.24)',
515
+ 'color:var(--fps-editor-text-strong)',
516
+ 'font-size:10px',
517
+ 'font-weight:900',
518
+ 'line-height:1.25',
519
+ 'letter-spacing:0',
520
+ 'white-space:nowrap',
521
+ ].join(';');
522
+ root.appendChild(label);
523
+ return { root, svg, line, startMarker, endMarker, label };
524
+ }
525
+ function createMeasurementMarker(doc) {
526
+ const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
527
+ marker.setAttribute('r', '4');
528
+ marker.setAttribute('fill', 'var(--fps-editor-drop-target)');
529
+ marker.setAttribute('stroke', 'var(--fps-editor-panel)');
530
+ marker.setAttribute('stroke-width', '2');
531
+ return marker;
532
+ }
533
+ function renderMeasurementOverlay(overlay, state) {
534
+ const start = state?.screenStart ?? null;
535
+ const end = state?.screenEnd ?? state?.screenPreview ?? null;
536
+ if (!start || !end) {
537
+ overlay.root.style.display = 'none';
538
+ return;
539
+ }
540
+ overlay.root.style.display = '';
541
+ const rect = overlay.root.getBoundingClientRect();
542
+ overlay.svg.setAttribute('width', formatSvgNumber(rect.width));
543
+ overlay.svg.setAttribute('height', formatSvgNumber(rect.height));
544
+ overlay.svg.setAttribute('viewBox', `0 0 ${formatSvgNumber(rect.width)} ${formatSvgNumber(rect.height)}`);
545
+ const startX = start.x - rect.left;
546
+ const startY = start.y - rect.top;
547
+ const endX = end.x - rect.left;
548
+ const endY = end.y - rect.top;
549
+ overlay.line.setAttribute('x1', formatSvgNumber(startX));
550
+ overlay.line.setAttribute('y1', formatSvgNumber(startY));
551
+ overlay.line.setAttribute('x2', formatSvgNumber(endX));
552
+ overlay.line.setAttribute('y2', formatSvgNumber(endY));
553
+ overlay.startMarker.setAttribute('cx', formatSvgNumber(startX));
554
+ overlay.startMarker.setAttribute('cy', formatSvgNumber(startY));
555
+ overlay.endMarker.setAttribute('cx', formatSvgNumber(endX));
556
+ overlay.endMarker.setAttribute('cy', formatSvgNumber(endY));
557
+ overlay.label.textContent = state?.label?.text ?? '';
558
+ overlay.label.style.display = state?.label ? '' : 'none';
559
+ if (state?.label) {
560
+ overlay.label.style.left = `${state.label.x - rect.left}px`;
561
+ overlay.label.style.top = `${state.label.y - rect.top}px`;
562
+ }
563
+ }
564
+ function formatSvgNumber(value) {
565
+ return Number.isFinite(value) ? value.toFixed(2) : '0';
566
+ }
115
567
  export function createLocalEditorBrowserUi(options = {}) {
116
568
  const doc = options.document ?? document;
117
569
  const root = options.root ?? doc.body;
@@ -123,6 +575,10 @@ export function createLocalEditorBrowserUi(options = {}) {
123
575
  const contextMenu = createLocalEditorContextMenuController(doc, (open) => {
124
576
  inputRouter.setContextMenuOpen(open);
125
577
  }, activeTheme);
578
+ const tooltipSurfaces = new Set();
579
+ const tooltipController = createLocalEditorTooltipController(doc, root, activeTheme, {
580
+ scope: () => tooltipSurfaces,
581
+ });
126
582
  let currentState = null;
127
583
  let inspectorFilter = '';
128
584
  const workbenchLayout = createDefaultLocalEditorWorkbenchLayout();
@@ -161,26 +617,29 @@ export function createLocalEditorBrowserUi(options = {}) {
161
617
  hostChrome.appendChild(hostModeLabel);
162
618
  hostChrome.appendChild(enterEditorButton);
163
619
  root.appendChild(hostChrome);
620
+ tooltipSurfaces.add(hostChrome);
164
621
  const dirtyBadge = doc.createElement('span');
622
+ dirtyBadge.dataset.editorDirtyBadge = 'true';
623
+ dirtyBadge.dataset.editorTooltip = '有未保存更改';
624
+ dirtyBadge.setAttribute('role', 'status');
625
+ dirtyBadge.setAttribute('aria-label', '有未保存更改');
165
626
  dirtyBadge.style.cssText = [
166
627
  'display:none',
167
- 'height:20px',
628
+ 'width:8px',
629
+ 'height:8px',
168
630
  'align-items:center',
169
- 'padding:0 7px',
631
+ 'justify-content:center',
632
+ 'padding:0',
170
633
  'border-radius:999px',
171
- 'background:var(--fps-editor-danger-soft)',
634
+ 'background:var(--fps-editor-danger-strong)',
172
635
  'border:1px solid var(--fps-editor-danger-border)',
173
- 'color:var(--fps-editor-danger-text)',
174
- 'font-size:11px',
175
- 'font-weight:800',
636
+ 'box-shadow:0 0 0 3px var(--fps-editor-danger-soft)',
176
637
  ].join(';');
177
- dirtyBadge.textContent = '未保存';
178
638
  const saveButton = LocalEditorShared.createButton(doc, '保存场景', { icon: 'save' });
179
639
  const saveAndRunButton = LocalEditorShared.createButton(doc, '保存并运行', { icon: 'execute' });
180
640
  const discardRunButton = LocalEditorShared.createButton(doc, '放弃并运行', { icon: 'discard' });
181
- const localTestButton = LocalEditorShared.createButton(doc, '本地测试', { icon: 'execute' });
641
+ const localTestButton = createToolbarIconButton(doc, '本地测试', 'execute', '打开本地测试操作');
182
642
  localTestButton.dataset.editorLocalTestToggle = 'true';
183
- localTestButton.title = '打开本地测试操作';
184
643
  const localTestMenu = doc.createElement('div');
185
644
  localTestMenu.classList.add(LOCAL_EDITOR_THEME_CLASS);
186
645
  localTestMenu.dataset.editorLocalTestMenu = 'true';
@@ -210,20 +669,12 @@ export function createLocalEditorBrowserUi(options = {}) {
210
669
  'gap:4px',
211
670
  ].join(';');
212
671
  localTestGroup.appendChild(localTestButton);
213
- const undoButton = LocalEditorShared.createButton(doc, '撤销', { icon: 'undo' });
214
- const redoButton = LocalEditorShared.createButton(doc, '重做', { icon: 'redo' });
672
+ const undoButton = createToolbarIconButton(doc, '撤销', 'undo', '撤销');
673
+ const redoButton = createToolbarIconButton(doc, '重做', 'redo', '重做');
215
674
  let helpOpen = false;
216
- const status = doc.createElement('span');
217
- status.style.cssText = [
218
- 'flex:0 1 300px',
219
- 'min-width:120px',
220
- 'max-width:320px',
221
- 'overflow:hidden',
222
- 'text-overflow:ellipsis',
223
- 'white-space:nowrap',
224
- 'color:var(--fps-editor-muted)',
225
- 'font-size:11px',
226
- ].join(';');
675
+ const editorStatusButton = createToolbarIconButton(doc, '编辑器状态', 'status', '编辑器状态');
676
+ editorStatusButton.dataset.editorStatusButton = 'true';
677
+ editorStatusButton.style.cursor = 'help';
227
678
  const workbench = createLocalEditorWorkbench(doc);
228
679
  const hierarchyPanel = createWorkbenchPanelContent(doc);
229
680
  const assetPanel = workbench.bottomDock;
@@ -231,6 +682,7 @@ export function createLocalEditorBrowserUi(options = {}) {
231
682
  workbench.leftDock.appendChild(hierarchyPanel);
232
683
  workbench.rightDock.appendChild(inspectorPanel);
233
684
  root.appendChild(workbench.root);
685
+ tooltipSurfaces.add(workbench.root);
234
686
  const workbenchLayoutController = createLocalEditorWorkbenchLayoutController(doc, workbench);
235
687
  const sceneToolOverlay = createSceneHeaderToolbar(doc);
236
688
  const sceneTitle = doc.createElement('div');
@@ -268,14 +720,15 @@ export function createLocalEditorBrowserUi(options = {}) {
268
720
  const toolButtons = new Map();
269
721
  const transformToolDescriptors = Object.values(DEFAULT_EDITOR_TRANSFORM_TOOL_DESCRIPTORS);
270
722
  for (const descriptor of transformToolDescriptors) {
271
- const shortcut = descriptor.shortcut ? `${descriptor.shortcut} ` : '';
272
- const button = LocalEditorShared.createButton(doc, `${shortcut}${descriptor.label}`, {
723
+ const tooltip = createTransformToolTooltip(descriptor);
724
+ const button = LocalEditorShared.createButton(doc, descriptor.label, {
273
725
  icon: TRANSFORM_TOOL_ICONS[descriptor.tool],
726
+ labelMode: 'icon-only',
727
+ variant: 'toolbar-icon',
728
+ tooltip,
729
+ ariaLabel: tooltip,
274
730
  });
275
731
  button.dataset.transformTool = descriptor.tool;
276
- button.title = descriptor.handles.length > 0
277
- ? `${descriptor.label} · ${descriptor.handles.map(handle => handle.label).join(' / ')}`
278
- : descriptor.label;
279
732
  toolButtons.set(descriptor.tool, button);
280
733
  toolGroup.appendChild(button);
281
734
  }
@@ -288,21 +741,13 @@ export function createLocalEditorBrowserUi(options = {}) {
288
741
  'border-left:1px solid var(--fps-editor-divider)',
289
742
  ].join(';');
290
743
  const spaceButtons = {
291
- world: LocalEditorShared.createButton(doc, '世界', { icon: TRANSFORM_SPACE_ICONS.world }),
292
- local: LocalEditorShared.createButton(doc, '本地', { icon: TRANSFORM_SPACE_ICONS.local }),
744
+ world: createToolbarIconButton(doc, '世界', TRANSFORM_SPACE_ICONS.world, '世界坐标空间'),
745
+ local: createToolbarIconButton(doc, '本地', TRANSFORM_SPACE_ICONS.local, '本地坐标空间'),
293
746
  };
294
747
  for (const [space, button] of Object.entries(spaceButtons)) {
295
748
  button.dataset.transformSpace = space;
296
749
  spaceGroup.appendChild(button);
297
750
  }
298
- const handleGroup = doc.createElement('div');
299
- handleGroup.style.cssText = [
300
- 'display:flex',
301
- 'align-items:center',
302
- 'gap:4px',
303
- 'padding-left:8px',
304
- 'border-left:1px solid var(--fps-editor-divider)',
305
- ].join(';');
306
751
  const snapGroup = doc.createElement('div');
307
752
  snapGroup.style.cssText = [
308
753
  'display:flex',
@@ -311,24 +756,71 @@ export function createLocalEditorBrowserUi(options = {}) {
311
756
  'padding-left:8px',
312
757
  'border-left:1px solid var(--fps-editor-divider)',
313
758
  ].join(';');
314
- const snapButton = LocalEditorShared.createButton(doc, '吸附', { icon: 'snap' });
759
+ const snapButton = createToolbarIconButton(doc, '吸附设置', 'snap', '吸附设置', 'settings');
315
760
  snapButton.dataset.transformSnapToggle = 'true';
316
- snapButton.title = '移动 / 旋转 / 缩放吸附';
761
+ const snapSettingsPopover = doc.createElement('div');
762
+ snapSettingsPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
763
+ snapSettingsPopover.dataset.transformSnapSettingsPopover = 'true';
764
+ snapSettingsPopover.setAttribute('role', 'dialog');
765
+ snapSettingsPopover.setAttribute('aria-label', '吸附设置');
766
+ snapSettingsPopover.style.cssText = [
767
+ 'position:fixed',
768
+ 'z-index:2147483641',
769
+ 'display:none',
770
+ 'flex-direction:column',
771
+ 'gap:8px',
772
+ 'width:220px',
773
+ 'padding:10px',
774
+ 'border:1px solid var(--fps-editor-border)',
775
+ 'border-radius:3px',
776
+ 'background:var(--fps-editor-panel)',
777
+ 'box-shadow:var(--fps-editor-shadow-popover)',
778
+ 'pointer-events:auto',
779
+ ].join(';');
780
+ const snapSettingsHeader = doc.createElement('label');
781
+ snapSettingsHeader.style.cssText = [
782
+ 'display:flex',
783
+ 'align-items:center',
784
+ 'justify-content:space-between',
785
+ 'gap:8px',
786
+ 'color:var(--fps-editor-text)',
787
+ 'font-size:12px',
788
+ 'font-weight:900',
789
+ ].join(';');
790
+ const snapSettingsHeaderText = doc.createElement('span');
791
+ snapSettingsHeaderText.textContent = '启用吸附';
792
+ const snapEnabledInput = doc.createElement('input');
793
+ snapEnabledInput.type = 'checkbox';
794
+ snapEnabledInput.dataset.transformSnapEnabled = 'true';
795
+ snapSettingsHeader.appendChild(snapSettingsHeaderText);
796
+ snapSettingsHeader.appendChild(snapEnabledInput);
797
+ snapSettingsPopover.appendChild(snapSettingsHeader);
317
798
  const snapStepInputs = new Map();
318
- for (const [kind, label, title] of [
319
- ['move', 'M', '移动吸附步长'],
320
- ['rotate', 'R', '旋转吸附角度'],
321
- ['scale', 'S', '缩放吸附步长'],
799
+ for (const [kind, label, unit] of [
800
+ ['move', '位移', '单位'],
801
+ ['rotate', '旋转', ''],
802
+ ['scale', '缩放', '倍数'],
322
803
  ]) {
804
+ const row = doc.createElement('label');
805
+ row.style.cssText = [
806
+ 'display:grid',
807
+ 'grid-template-columns:54px 1fr 34px',
808
+ 'align-items:center',
809
+ 'gap:8px',
810
+ 'color:var(--fps-editor-muted-strong)',
811
+ 'font-size:11px',
812
+ 'font-weight:800',
813
+ ].join(';');
814
+ const rowLabel = doc.createElement('span');
815
+ rowLabel.textContent = label;
323
816
  const input = doc.createElement('input');
324
817
  input.type = 'number';
325
818
  input.min = '0.0001';
326
819
  input.step = kind === 'rotate' ? '1' : '0.1';
327
820
  input.dataset.transformSnapStep = kind;
328
- input.title = `${label} · ${title}`;
329
821
  input.style.cssText = [
330
- 'width:46px',
331
822
  'height:26px',
823
+ 'min-width:0',
332
824
  'box-sizing:border-box',
333
825
  'border:1px solid var(--fps-editor-border)',
334
826
  'border-radius:3px',
@@ -338,11 +830,16 @@ export function createLocalEditorBrowserUi(options = {}) {
338
830
  'font-weight:800',
339
831
  'padding:0 4px',
340
832
  ].join(';');
833
+ const rowUnit = doc.createElement('span');
834
+ rowUnit.textContent = unit;
835
+ rowUnit.style.cssText = 'color:var(--fps-editor-muted);font-size:10px;font-weight:800';
836
+ row.appendChild(rowLabel);
837
+ row.appendChild(input);
838
+ row.appendChild(rowUnit);
839
+ snapSettingsPopover.appendChild(row);
341
840
  snapStepInputs.set(kind, input);
342
841
  }
343
842
  snapGroup.appendChild(snapButton);
344
- for (const input of snapStepInputs.values())
345
- snapGroup.appendChild(input);
346
843
  const placementGroup = doc.createElement('div');
347
844
  placementGroup.style.cssText = [
348
845
  'display:flex',
@@ -351,22 +848,40 @@ export function createLocalEditorBrowserUi(options = {}) {
351
848
  'padding-left:8px',
352
849
  'border-left:1px solid var(--fps-editor-divider)',
353
850
  ].join(';');
354
- const placementLabel = doc.createElement('span');
355
- placementLabel.textContent = '放置';
356
- placementLabel.style.cssText = 'color:var(--fps-editor-muted);font-size:11px;font-weight:900;white-space:nowrap';
357
- const placementButtons = {
358
- off: LocalEditorShared.createButton(doc, '', { icon: PLACEMENT_MODE_ICONS.off }),
359
- ground: LocalEditorShared.createButton(doc, '', { icon: PLACEMENT_MODE_ICONS.ground }),
360
- surface: LocalEditorShared.createButton(doc, '', { icon: PLACEMENT_MODE_ICONS.surface }),
361
- };
362
- placementButtons.off.title = '关闭放置模式';
363
- placementButtons.ground.title = '放置到 XZ 地面';
364
- placementButtons.surface.title = '放置到场景表面';
365
- placementGroup.appendChild(placementLabel);
366
- for (const [mode, button] of Object.entries(placementButtons)) {
851
+ const placementButton = createToolbarIconButton(doc, '放置模式', PLACEMENT_MODE_ICONS.off, '放置模式', 'settings');
852
+ placementButton.dataset.placementModeToggle = 'true';
853
+ const placementSettingsPopover = doc.createElement('div');
854
+ placementSettingsPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
855
+ placementSettingsPopover.dataset.placementSettingsPopover = 'true';
856
+ placementSettingsPopover.setAttribute('role', 'dialog');
857
+ placementSettingsPopover.setAttribute('aria-label', '放置模式');
858
+ placementSettingsPopover.style.cssText = [
859
+ 'position:fixed',
860
+ 'z-index:2147483641',
861
+ 'display:none',
862
+ 'flex-direction:column',
863
+ 'gap:5px',
864
+ 'width:180px',
865
+ 'padding:8px',
866
+ 'border:1px solid var(--fps-editor-border)',
867
+ 'border-radius:3px',
868
+ 'background:var(--fps-editor-panel)',
869
+ 'box-shadow:var(--fps-editor-shadow-popover)',
870
+ 'pointer-events:auto',
871
+ ].join(';');
872
+ const placementButtons = Object.fromEntries(Object.keys(PLACEMENT_MODE_ICONS).map((mode) => {
873
+ const button = LocalEditorShared.createButton(doc, PLACEMENT_MODE_DESCRIPTIONS[mode], {
874
+ icon: PLACEMENT_MODE_ICONS[mode],
875
+ tooltip: PLACEMENT_MODE_DESCRIPTIONS[mode],
876
+ ariaLabel: PLACEMENT_MODE_DESCRIPTIONS[mode],
877
+ });
367
878
  button.dataset.placementMode = mode;
368
- placementGroup.appendChild(button);
369
- }
879
+ button.style.justifyContent = 'flex-start';
880
+ button.style.width = '100%';
881
+ placementSettingsPopover.appendChild(button);
882
+ return [mode, button];
883
+ }));
884
+ placementGroup.appendChild(placementButton);
370
885
  const actionGroup = doc.createElement('div');
371
886
  actionGroup.style.cssText = [
372
887
  'display:flex',
@@ -375,70 +890,73 @@ export function createLocalEditorBrowserUi(options = {}) {
375
890
  'padding-left:8px',
376
891
  'border-left:1px solid var(--fps-editor-divider)',
377
892
  ].join(';');
378
- const transformActionSelect = doc.createElement('select');
379
- transformActionSelect.dataset.transformActionSelect = 'true';
380
- transformActionSelect.title = '多选对齐 / 分布';
381
- transformActionSelect.style.cssText = [
382
- 'height:26px',
893
+ const transformActionButton = createToolbarIconButton(doc, '对齐与分布', 'arrange', '对齐与分布', 'settings');
894
+ transformActionButton.dataset.transformActionToggle = 'true';
895
+ const transformActionPopover = doc.createElement('div');
896
+ transformActionPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
897
+ transformActionPopover.dataset.transformActionPopover = 'true';
898
+ transformActionPopover.setAttribute('role', 'dialog');
899
+ transformActionPopover.setAttribute('aria-label', '对齐与分布');
900
+ transformActionPopover.style.cssText = [
901
+ 'position:fixed',
902
+ 'z-index:2147483641',
903
+ 'display:none',
904
+ 'flex-direction:column',
905
+ 'gap:9px',
906
+ 'width:236px',
907
+ 'padding:10px',
383
908
  'border:1px solid var(--fps-editor-border)',
384
909
  'border-radius:3px',
385
- 'background:var(--fps-editor-field)',
386
- 'color:var(--fps-editor-text)',
387
- 'font-size:11px',
388
- 'font-weight:800',
389
- 'padding:0 4px',
910
+ 'background:var(--fps-editor-panel)',
911
+ 'box-shadow:var(--fps-editor-shadow-popover)',
912
+ 'pointer-events:auto',
390
913
  ].join(';');
391
- let selectedTransformAction = 'align-all';
392
- for (const action of [
393
- 'align-x',
394
- 'align-y',
395
- 'align-z',
396
- 'align-all',
397
- 'distribute-x',
398
- 'distribute-y',
399
- 'distribute-z',
400
- ]) {
401
- const option = doc.createElement('option');
402
- option.value = action;
403
- option.textContent = LocalEditorShared.toTransformActionLabel(action);
404
- transformActionSelect.appendChild(option);
914
+ const transformActionButtons = new Map();
915
+ for (const group of TRANSFORM_ACTION_GROUPS) {
916
+ const section = doc.createElement('section');
917
+ section.style.cssText = 'display:flex;flex-direction:column;gap:5px';
918
+ const heading = doc.createElement('div');
919
+ heading.textContent = group.title;
920
+ heading.style.cssText = [
921
+ 'color:var(--fps-editor-text-strong)',
922
+ 'font-size:11px',
923
+ 'font-weight:900',
924
+ ].join(';');
925
+ const hint = doc.createElement('div');
926
+ hint.textContent = group.hint;
927
+ hint.style.cssText = [
928
+ 'color:var(--fps-editor-muted)',
929
+ 'font-size:10px',
930
+ 'font-weight:700',
931
+ 'margin-top:-3px',
932
+ ].join(';');
933
+ section.appendChild(heading);
934
+ section.appendChild(hint);
935
+ for (const action of group.actions) {
936
+ const button = LocalEditorShared.createButton(doc, TRANSFORM_ACTION_LABELS[action], {
937
+ icon: 'arrange',
938
+ tooltip: TRANSFORM_ACTION_LABELS[action],
939
+ ariaLabel: TRANSFORM_ACTION_LABELS[action],
940
+ });
941
+ button.dataset.transformAction = action;
942
+ button.style.justifyContent = 'flex-start';
943
+ button.style.width = '100%';
944
+ transformActionButtons.set(action, button);
945
+ section.appendChild(button);
946
+ }
947
+ transformActionPopover.appendChild(section);
405
948
  }
406
- transformActionSelect.value = selectedTransformAction;
407
- const transformActionButton = LocalEditorShared.createButton(doc, '执行', { icon: 'execute' });
408
- transformActionButton.dataset.transformActionRun = 'true';
409
- transformActionButton.title = '对当前多选执行 Transform 操作';
410
- actionGroup.appendChild(transformActionSelect);
411
949
  actionGroup.appendChild(transformActionButton);
412
- const sceneToolStatus = doc.createElement('span');
413
- sceneToolStatus.style.cssText = [
414
- 'max-width:220px',
415
- 'overflow:hidden',
416
- 'text-overflow:ellipsis',
417
- 'white-space:nowrap',
418
- 'color:var(--fps-editor-muted-strong)',
419
- 'font-size:11px',
420
- ].join(';');
421
- const sceneMouseHint = doc.createElement('span');
422
- sceneMouseHint.style.cssText = [
423
- 'padding-left:8px',
424
- 'border-left:1px solid var(--fps-editor-divider)',
425
- 'max-width:360px',
426
- 'overflow:hidden',
427
- 'text-overflow:ellipsis',
428
- 'white-space:nowrap',
429
- 'color:var(--fps-editor-muted)',
430
- 'font-size:11px',
431
- ].join(';');
432
- sceneMouseHint.textContent = '左键选择 · 空白拖拽框选 · 中键平移 · Alt+左键环绕 · 右键飞行 · 滚轮缩放';
433
- const themeToggleButton = LocalEditorShared.createButton(doc, '主题', { icon: 'theme' });
950
+ const themeToggleButton = createToolbarIconButton(doc, '主题', 'theme', '切换主题');
434
951
  themeToggleButton.dataset.editorThemeToggle = 'true';
435
- themeToggleButton.style.padding = '5px 7px';
436
- const sceneHelpButton = LocalEditorShared.createButton(doc, '快捷键', { icon: 'help' });
437
- sceneHelpButton.style.padding = '5px 7px';
952
+ const gridToggleButton = createToolbarIconButton(doc, '网格', 'grid', '显示 / 隐藏 Scene View 网格');
953
+ gridToggleButton.dataset.editorGridToggle = 'true';
954
+ const sceneHelpButton = createToolbarIconButton(doc, '快捷键', 'help', '快捷键与操作说明');
438
955
  sceneQuickActions.appendChild(localTestGroup);
439
956
  sceneQuickActions.appendChild(undoButton);
440
957
  sceneQuickActions.appendChild(redoButton);
441
958
  sceneQuickActions.appendChild(dirtyBadge);
959
+ sceneUtilityActions.appendChild(gridToggleButton);
442
960
  sceneUtilityActions.appendChild(themeToggleButton);
443
961
  sceneUtilityActions.appendChild(sceneHelpButton);
444
962
  const cameraPreviewGroup = doc.createElement('div');
@@ -449,13 +967,79 @@ export function createLocalEditorBrowserUi(options = {}) {
449
967
  'padding-left:8px',
450
968
  'border-left:1px solid var(--fps-editor-divider)',
451
969
  ].join(';');
452
- const sceneCameraButton = LocalEditorShared.createButton(doc, 'Scene Camera', { icon: 'camera' });
970
+ const sceneCameraButton = createToolbarIconButton(doc, ' Main Camera 查看场景', 'camera');
453
971
  sceneCameraButton.dataset.sceneCameraPreviewToggle = 'true';
454
- sceneCameraButton.title = '从 Main Camera 查看当前场景';
455
972
  cameraPreviewGroup.appendChild(sceneCameraButton);
456
- const toolbarOverflowButton = LocalEditorShared.createButton(doc, '更多', { icon: 'chevron-down' });
973
+ const viewportToolsGroup = doc.createElement('div');
974
+ viewportToolsGroup.style.cssText = [
975
+ 'display:flex',
976
+ 'align-items:center',
977
+ 'gap:4px',
978
+ 'padding-left:8px',
979
+ 'border-left:1px solid var(--fps-editor-divider)',
980
+ ].join(';');
981
+ const viewportPresetButtons = new Map();
982
+ for (const preset of ['perspective', 'top', 'front', 'right']) {
983
+ const button = createToolbarIconButton(doc, VIEW_PRESET_TOOLTIPS[preset], VIEW_PRESET_ICONS[preset], VIEW_PRESET_TOOLTIPS[preset]);
984
+ button.dataset.viewportViewPreset = preset;
985
+ viewportPresetButtons.set(preset, button);
986
+ viewportToolsGroup.appendChild(button);
987
+ }
988
+ const projectionToggleButton = createToolbarIconButton(doc, '投影模式', 'projection-perspective', '切换投影模式');
989
+ projectionToggleButton.dataset.viewportProjectionToggle = 'true';
990
+ viewportToolsGroup.appendChild(projectionToggleButton);
991
+ const overlaySettingsButton = createToolbarIconButton(doc, '视口信息层设置', 'view-overlay', '视口信息层设置', 'settings');
992
+ overlaySettingsButton.dataset.viewportOverlaySettingsToggle = 'true';
993
+ viewportToolsGroup.appendChild(overlaySettingsButton);
994
+ const measureButton = createToolbarIconButton(doc, '测量距离', 'measure', '测量 XZ 地面距离', 'toggle');
995
+ measureButton.dataset.viewportMeasureToggle = 'true';
996
+ viewportToolsGroup.appendChild(measureButton);
997
+ const clearMeasurementButton = createToolbarIconButton(doc, '清除测量结果', 'discard', '清除测量结果');
998
+ clearMeasurementButton.dataset.viewportMeasurementClear = 'true';
999
+ viewportToolsGroup.appendChild(clearMeasurementButton);
1000
+ const overlaySettingsPopover = doc.createElement('div');
1001
+ overlaySettingsPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
1002
+ overlaySettingsPopover.dataset.viewportOverlaySettingsPopover = 'true';
1003
+ overlaySettingsPopover.setAttribute('role', 'dialog');
1004
+ overlaySettingsPopover.setAttribute('aria-label', '视口信息层设置');
1005
+ overlaySettingsPopover.style.cssText = [
1006
+ 'position:fixed',
1007
+ 'z-index:2147483641',
1008
+ 'display:none',
1009
+ 'flex-direction:column',
1010
+ 'gap:7px',
1011
+ 'width:190px',
1012
+ 'padding:8px',
1013
+ 'border:1px solid var(--fps-editor-border)',
1014
+ 'border-radius:3px',
1015
+ 'background:var(--fps-editor-panel)',
1016
+ 'box-shadow:var(--fps-editor-shadow-popover)',
1017
+ 'pointer-events:auto',
1018
+ ].join(';');
1019
+ const overlayInputs = new Map();
1020
+ for (const key of Object.keys(VIEWPORT_OVERLAY_TOGGLE_LABELS)) {
1021
+ const row = doc.createElement('label');
1022
+ row.style.cssText = [
1023
+ 'display:flex',
1024
+ 'align-items:center',
1025
+ 'justify-content:space-between',
1026
+ 'gap:10px',
1027
+ 'color:var(--fps-editor-text)',
1028
+ 'font-size:11px',
1029
+ 'font-weight:800',
1030
+ ].join(';');
1031
+ const text = doc.createElement('span');
1032
+ text.textContent = VIEWPORT_OVERLAY_TOGGLE_LABELS[key];
1033
+ const input = doc.createElement('input');
1034
+ input.type = 'checkbox';
1035
+ input.dataset.viewportOverlaySetting = key;
1036
+ row.appendChild(text);
1037
+ row.appendChild(input);
1038
+ overlaySettingsPopover.appendChild(row);
1039
+ overlayInputs.set(key, input);
1040
+ }
1041
+ const toolbarOverflowButton = createToolbarIconButton(doc, '更多', 'chevron-down', '显示隐藏的工具栏命令');
457
1042
  toolbarOverflowButton.dataset.editorToolbarOverflowToggle = 'true';
458
- toolbarOverflowButton.title = '显示隐藏的工具栏命令';
459
1043
  toolbarOverflowButton.style.display = 'none';
460
1044
  const toolbarOverflowMenu = doc.createElement('div');
461
1045
  toolbarOverflowMenu.classList.add(LOCAL_EDITOR_THEME_CLASS);
@@ -477,23 +1061,37 @@ export function createLocalEditorBrowserUi(options = {}) {
477
1061
  'box-shadow:var(--fps-editor-shadow-popover)',
478
1062
  'pointer-events:auto',
479
1063
  ].join(';');
1064
+ const coordinateAxesOverlay = createCoordinateAxesOverlay(doc);
1065
+ const spatialOverlay = createSpatialOverlay(doc);
1066
+ const measurementOverlay = createMeasurementOverlay(doc);
480
1067
  sceneToolOverlay.appendChild(sceneTitle);
481
1068
  sceneToolOverlay.appendChild(sceneQuickActions);
482
- sceneToolOverlay.appendChild(sceneUtilityActions);
483
- sceneToolOverlay.appendChild(cameraPreviewGroup);
484
1069
  sceneToolOverlay.appendChild(toolGroup);
485
1070
  sceneToolOverlay.appendChild(spaceGroup);
486
- sceneToolOverlay.appendChild(handleGroup);
487
1071
  sceneToolOverlay.appendChild(snapGroup);
488
1072
  sceneToolOverlay.appendChild(placementGroup);
489
1073
  sceneToolOverlay.appendChild(actionGroup);
490
- sceneToolOverlay.appendChild(sceneToolStatus);
491
- sceneToolOverlay.appendChild(sceneMouseHint);
492
- sceneToolOverlay.appendChild(status);
1074
+ sceneToolOverlay.appendChild(viewportToolsGroup);
1075
+ sceneToolOverlay.appendChild(sceneUtilityActions);
1076
+ sceneToolOverlay.appendChild(cameraPreviewGroup);
1077
+ sceneToolOverlay.appendChild(editorStatusButton);
493
1078
  sceneToolOverlay.appendChild(toolbarOverflowButton);
494
1079
  workbench.sceneHeader.appendChild(sceneToolOverlay);
1080
+ workbench.sceneFrame.appendChild(spatialOverlay.root);
1081
+ workbench.sceneFrame.appendChild(measurementOverlay.root);
1082
+ workbench.sceneFrame.appendChild(coordinateAxesOverlay.root);
495
1083
  root.appendChild(localTestMenu);
496
1084
  root.appendChild(toolbarOverflowMenu);
1085
+ root.appendChild(snapSettingsPopover);
1086
+ root.appendChild(placementSettingsPopover);
1087
+ root.appendChild(transformActionPopover);
1088
+ root.appendChild(overlaySettingsPopover);
1089
+ tooltipSurfaces.add(localTestMenu);
1090
+ tooltipSurfaces.add(toolbarOverflowMenu);
1091
+ tooltipSurfaces.add(snapSettingsPopover);
1092
+ tooltipSurfaces.add(placementSettingsPopover);
1093
+ tooltipSurfaces.add(transformActionPopover);
1094
+ tooltipSurfaces.add(overlaySettingsPopover);
497
1095
  const boxSelectionOverlay = doc.createElement('div');
498
1096
  boxSelectionOverlay.classList.add(LOCAL_EDITOR_THEME_CLASS);
499
1097
  boxSelectionOverlay.style.cssText = [
@@ -506,23 +1104,32 @@ export function createLocalEditorBrowserUi(options = {}) {
506
1104
  'pointer-events:none',
507
1105
  ].join(';');
508
1106
  root.appendChild(boxSelectionOverlay);
1107
+ tooltipSurfaces.add(boxSelectionOverlay);
509
1108
  const shortcutHelpPanel = createLocalEditorShortcutHelpPanel(doc);
510
1109
  shortcutHelpPanel.classList.add(LOCAL_EDITOR_THEME_CLASS);
511
1110
  root.appendChild(shortcutHelpPanel);
1111
+ tooltipSurfaces.add(shortcutHelpPanel);
512
1112
  function applyThemeToSurfaces() {
513
1113
  applyLocalEditorTheme(hostChrome, activeTheme);
514
1114
  applyLocalEditorTheme(workbench.root, activeTheme);
1115
+ applyLocalEditorTheme(spatialOverlay.root, activeTheme);
1116
+ applyLocalEditorTheme(measurementOverlay.root, activeTheme);
515
1117
  applyLocalEditorTheme(shortcutHelpPanel, activeTheme);
516
1118
  applyLocalEditorTheme(boxSelectionOverlay, activeTheme);
517
1119
  applyLocalEditorTheme(localTestMenu, activeTheme);
518
1120
  applyLocalEditorTheme(toolbarOverflowMenu, activeTheme);
1121
+ applyLocalEditorTheme(snapSettingsPopover, activeTheme);
1122
+ applyLocalEditorTheme(placementSettingsPopover, activeTheme);
1123
+ applyLocalEditorTheme(transformActionPopover, activeTheme);
1124
+ applyLocalEditorTheme(overlaySettingsPopover, activeTheme);
1125
+ tooltipController.setTheme(activeTheme);
519
1126
  contextMenu.setTheme?.(activeTheme);
520
1127
  }
521
1128
  function updateThemeToggleButton() {
522
1129
  const nextTheme = activeTheme === 'dark' ? 'light' : 'dark';
523
1130
  themeToggleButton.dataset.editorTheme = activeTheme;
524
1131
  themeToggleButton.setAttribute('aria-pressed', activeTheme === 'light' ? 'true' : 'false');
525
- themeToggleButton.title = nextTheme === 'light' ? '切换为浅色主题' : '切换为深色主题';
1132
+ setToolbarButtonTooltip(themeToggleButton, nextTheme === 'light' ? '切换为浅色主题' : '切换为深色主题');
526
1133
  }
527
1134
  function setActiveTheme(theme) {
528
1135
  activeTheme = normalizeLocalEditorThemeName(theme);
@@ -530,15 +1137,52 @@ export function createLocalEditorBrowserUi(options = {}) {
530
1137
  updateThemeToggleButton();
531
1138
  }
532
1139
  let localTestMenuOpen = false;
1140
+ let snapSettingsOpen = false;
1141
+ let placementSettingsOpen = false;
1142
+ let transformActionOpen = false;
1143
+ let overlaySettingsOpen = false;
533
1144
  function closeLocalTestMenu() {
534
1145
  localTestMenuOpen = false;
535
1146
  localTestMenu.style.display = 'none';
536
1147
  LocalEditorShared.applyButtonActiveState(localTestButton, false);
537
1148
  }
1149
+ function closeSnapSettingsPopover() {
1150
+ snapSettingsOpen = false;
1151
+ snapSettingsPopover.style.display = 'none';
1152
+ snapButton.setAttribute('aria-expanded', 'false');
1153
+ const enabled = currentState?.transformOperations?.settings.snap.enabled
1154
+ ?? DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS.snap.enabled;
1155
+ LocalEditorShared.applyButtonActiveState(snapButton, enabled);
1156
+ }
1157
+ function closePlacementSettingsPopover() {
1158
+ placementSettingsOpen = false;
1159
+ placementSettingsPopover.style.display = 'none';
1160
+ placementButton.setAttribute('aria-expanded', 'false');
1161
+ const mode = currentState?.transformOperations?.settings.placementMode
1162
+ ?? DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS.placementMode;
1163
+ LocalEditorShared.applyButtonActiveState(placementButton, mode !== 'off');
1164
+ }
1165
+ function closeTransformActionPopover() {
1166
+ transformActionOpen = false;
1167
+ transformActionPopover.style.display = 'none';
1168
+ transformActionButton.setAttribute('aria-expanded', 'false');
1169
+ LocalEditorShared.applyButtonActiveState(transformActionButton, false);
1170
+ }
1171
+ function closeOverlaySettingsPopover() {
1172
+ overlaySettingsOpen = false;
1173
+ overlaySettingsPopover.style.display = 'none';
1174
+ overlaySettingsButton.setAttribute('aria-expanded', 'false');
1175
+ LocalEditorShared.applyButtonActiveState(overlaySettingsButton, false);
1176
+ }
538
1177
  function openLocalTestMenu() {
539
1178
  if (!localTestActionsEnabled || localTestButton.style.display === 'none')
540
1179
  return;
541
1180
  closeToolbarOverflowMenu();
1181
+ closeSnapSettingsPopover();
1182
+ closePlacementSettingsPopover();
1183
+ closeTransformActionPopover();
1184
+ closeOverlaySettingsPopover();
1185
+ closeOverlaySettingsPopover();
542
1186
  const win = doc.defaultView;
543
1187
  const rect = localTestButton.getBoundingClientRect();
544
1188
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -548,6 +1192,106 @@ export function createLocalEditorBrowserUi(options = {}) {
548
1192
  localTestMenuOpen = true;
549
1193
  LocalEditorShared.applyButtonActiveState(localTestButton, true);
550
1194
  }
1195
+ function openSnapSettingsPopover() {
1196
+ if (snapButton.disabled || snapGroup.style.display === 'none')
1197
+ return;
1198
+ closeToolbarOverflowMenu();
1199
+ closeLocalTestMenu();
1200
+ closePlacementSettingsPopover();
1201
+ closeTransformActionPopover();
1202
+ closeOverlaySettingsPopover();
1203
+ const win = doc.defaultView;
1204
+ const rect = snapButton.getBoundingClientRect();
1205
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
1206
+ snapSettingsPopover.style.display = 'flex';
1207
+ const popoverWidth = snapSettingsPopover.offsetWidth || 220;
1208
+ snapSettingsPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
1209
+ snapSettingsPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
1210
+ snapSettingsOpen = true;
1211
+ snapButton.setAttribute('aria-expanded', 'true');
1212
+ LocalEditorShared.applyButtonActiveState(snapButton, true);
1213
+ }
1214
+ function toggleSnapSettingsPopover() {
1215
+ if (snapSettingsOpen)
1216
+ closeSnapSettingsPopover();
1217
+ else
1218
+ openSnapSettingsPopover();
1219
+ }
1220
+ function openPlacementSettingsPopover() {
1221
+ if (placementButton.disabled || placementGroup.style.display === 'none')
1222
+ return;
1223
+ closeToolbarOverflowMenu();
1224
+ closeLocalTestMenu();
1225
+ closeSnapSettingsPopover();
1226
+ closeTransformActionPopover();
1227
+ closeOverlaySettingsPopover();
1228
+ const win = doc.defaultView;
1229
+ const rect = placementButton.getBoundingClientRect();
1230
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
1231
+ placementSettingsPopover.style.display = 'flex';
1232
+ const popoverWidth = placementSettingsPopover.offsetWidth || 180;
1233
+ placementSettingsPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
1234
+ placementSettingsPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
1235
+ placementSettingsOpen = true;
1236
+ placementButton.setAttribute('aria-expanded', 'true');
1237
+ LocalEditorShared.applyButtonActiveState(placementButton, true);
1238
+ }
1239
+ function togglePlacementSettingsPopover() {
1240
+ if (placementSettingsOpen)
1241
+ closePlacementSettingsPopover();
1242
+ else
1243
+ openPlacementSettingsPopover();
1244
+ }
1245
+ function openTransformActionPopover() {
1246
+ if (transformActionButton.disabled || actionGroup.style.display === 'none')
1247
+ return;
1248
+ closeToolbarOverflowMenu();
1249
+ closeLocalTestMenu();
1250
+ closeSnapSettingsPopover();
1251
+ closePlacementSettingsPopover();
1252
+ closeOverlaySettingsPopover();
1253
+ const win = doc.defaultView;
1254
+ const rect = transformActionButton.getBoundingClientRect();
1255
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
1256
+ transformActionPopover.style.display = 'flex';
1257
+ const popoverWidth = transformActionPopover.offsetWidth || 236;
1258
+ transformActionPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
1259
+ transformActionPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
1260
+ transformActionOpen = true;
1261
+ transformActionButton.setAttribute('aria-expanded', 'true');
1262
+ LocalEditorShared.applyButtonActiveState(transformActionButton, true);
1263
+ }
1264
+ function toggleTransformActionPopover() {
1265
+ if (transformActionOpen)
1266
+ closeTransformActionPopover();
1267
+ else
1268
+ openTransformActionPopover();
1269
+ }
1270
+ function openOverlaySettingsPopover() {
1271
+ if (overlaySettingsButton.disabled || viewportToolsGroup.style.display === 'none')
1272
+ return;
1273
+ const win = doc.defaultView;
1274
+ const rect = overlaySettingsButton.getBoundingClientRect();
1275
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
1276
+ closeLocalTestMenu();
1277
+ closeSnapSettingsPopover();
1278
+ closePlacementSettingsPopover();
1279
+ closeTransformActionPopover();
1280
+ closeToolbarOverflowMenu();
1281
+ overlaySettingsPopover.style.display = 'flex';
1282
+ const popoverWidth = overlaySettingsPopover.offsetWidth || 190;
1283
+ overlaySettingsPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
1284
+ overlaySettingsPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
1285
+ overlaySettingsOpen = true;
1286
+ overlaySettingsButton.setAttribute('aria-expanded', 'true');
1287
+ LocalEditorShared.applyButtonActiveState(overlaySettingsButton, true);
1288
+ }
1289
+ function toggleOverlaySettingsPopover() {
1290
+ if (overlaySettingsOpen)
1291
+ closeOverlaySettingsPopover();
1292
+ else
1293
+ openOverlaySettingsPopover();
1294
+ }
551
1295
  const onLocalTestButtonClick = (event) => {
552
1296
  event.stopPropagation();
553
1297
  if (localTestMenuOpen)
@@ -575,30 +1319,26 @@ export function createLocalEditorBrowserUi(options = {}) {
575
1319
  const toolbarOrder = [
576
1320
  sceneTitle,
577
1321
  sceneQuickActions,
578
- sceneUtilityActions,
579
- cameraPreviewGroup,
580
1322
  toolGroup,
581
1323
  spaceGroup,
582
- handleGroup,
583
1324
  snapGroup,
584
1325
  placementGroup,
585
1326
  actionGroup,
586
- sceneToolStatus,
587
- sceneMouseHint,
588
- status,
1327
+ viewportToolsGroup,
1328
+ sceneUtilityActions,
1329
+ cameraPreviewGroup,
1330
+ editorStatusButton,
589
1331
  toolbarOverflowButton,
590
1332
  ];
591
1333
  const toolbarOverflowItems = [
592
- { id: 'status', element: status, kind: 'text' },
593
- { id: 'mouse-hint', element: sceneMouseHint, kind: 'text' },
594
- { id: 'tool-status', element: sceneToolStatus, kind: 'text' },
1334
+ { id: 'editor-status', element: editorStatusButton, kind: 'group' },
1335
+ { id: 'scene-utilities', element: sceneUtilityActions, kind: 'group' },
1336
+ { id: 'camera-preview', element: cameraPreviewGroup, kind: 'group' },
1337
+ { id: 'viewport-tools', element: viewportToolsGroup, kind: 'group' },
595
1338
  { id: 'transform-actions', element: actionGroup, kind: 'group' },
596
1339
  { id: 'placement', element: placementGroup, kind: 'group' },
597
1340
  { id: 'snap', element: snapGroup, kind: 'group' },
598
- { id: 'handles', element: handleGroup, kind: 'group' },
599
1341
  { id: 'space', element: spaceGroup, kind: 'group' },
600
- { id: 'camera-preview', element: cameraPreviewGroup, kind: 'group' },
601
- { id: 'scene-utilities', element: sceneUtilityActions, kind: 'group' },
602
1342
  ];
603
1343
  let toolbarOverflowOpen = false;
604
1344
  let toolbarOverflowRaf = null;
@@ -636,6 +1376,8 @@ export function createLocalEditorBrowserUi(options = {}) {
636
1376
  item.element.style.width = '100%';
637
1377
  }
638
1378
  function setToolbarButtonCompact(button, compact) {
1379
+ if (button.dataset.editorButtonLabelMode === 'icon-only')
1380
+ return;
639
1381
  const label = button.querySelector('[data-editor-button-label]');
640
1382
  if (!label)
641
1383
  return;
@@ -668,6 +1410,10 @@ export function createLocalEditorBrowserUi(options = {}) {
668
1410
  if (toolbarOverflowMenu.childElementCount === 0)
669
1411
  return;
670
1412
  closeLocalTestMenu();
1413
+ closeSnapSettingsPopover();
1414
+ closePlacementSettingsPopover();
1415
+ closeTransformActionPopover();
1416
+ closeOverlaySettingsPopover();
671
1417
  const win = doc.defaultView;
672
1418
  const rect = toolbarOverflowButton.getBoundingClientRect();
673
1419
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -744,8 +1490,20 @@ export function createLocalEditorBrowserUi(options = {}) {
744
1490
  return;
745
1491
  if (target.closest('[data-editor-toolbar-overflow-menu]') || target.closest('[data-editor-toolbar-overflow-toggle]'))
746
1492
  return;
1493
+ if (target.closest('[data-transform-snap-settings-popover]') || target.closest('[data-transform-snap-toggle]'))
1494
+ return;
1495
+ if (target.closest('[data-placement-settings-popover]') || target.closest('[data-placement-mode-toggle]'))
1496
+ return;
1497
+ if (target.closest('[data-transform-action-popover]') || target.closest('[data-transform-action-toggle]'))
1498
+ return;
1499
+ if (target.closest('[data-viewport-overlay-settings-popover]') || target.closest('[data-viewport-overlay-settings-toggle]'))
1500
+ return;
747
1501
  closeLocalTestMenu();
748
1502
  closeToolbarOverflowMenu();
1503
+ closeSnapSettingsPopover();
1504
+ closePlacementSettingsPopover();
1505
+ closeTransformActionPopover();
1506
+ closeOverlaySettingsPopover();
749
1507
  };
750
1508
  const ResizeObserverCtor = doc.defaultView?.ResizeObserver;
751
1509
  const toolbarResizeObserver = ResizeObserverCtor
@@ -776,10 +1534,46 @@ export function createLocalEditorBrowserUi(options = {}) {
776
1534
  });
777
1535
  undoButton.addEventListener('click', () => callbacks.onUndo?.());
778
1536
  redoButton.addEventListener('click', () => callbacks.onRedo?.());
1537
+ gridToggleButton.addEventListener('click', () => {
1538
+ const visible = currentState?.grid?.visible ?? true;
1539
+ callbacks.onGridVisibleChange?.(!visible);
1540
+ });
779
1541
  sceneCameraButton.addEventListener('click', () => {
780
1542
  const enabled = currentState?.sceneCameraPreview?.enabled ?? false;
781
1543
  callbacks.onSceneCameraPreviewToggle?.(!enabled);
782
1544
  });
1545
+ viewportToolsGroup.addEventListener('click', (event) => {
1546
+ const target = event.target instanceof HTMLElement ? event.target : null;
1547
+ const preset = target?.closest('[data-viewport-view-preset]')?.dataset.viewportViewPreset;
1548
+ if (preset) {
1549
+ callbacks.onViewportViewPresetChange?.(preset);
1550
+ return;
1551
+ }
1552
+ const projectionToggle = target?.closest('[data-viewport-projection-toggle]');
1553
+ if (projectionToggle) {
1554
+ const currentMode = currentState?.viewportTools?.projectionMode ?? 'perspective';
1555
+ const nextMode = currentMode === 'orthographic' ? 'perspective' : 'orthographic';
1556
+ callbacks.onViewportProjectionModeChange?.(nextMode);
1557
+ return;
1558
+ }
1559
+ const overlayToggle = target?.closest('[data-viewport-overlay-settings-toggle]');
1560
+ if (overlayToggle) {
1561
+ event.stopPropagation();
1562
+ toggleOverlaySettingsPopover();
1563
+ return;
1564
+ }
1565
+ const measureToggle = target?.closest('[data-viewport-measure-toggle]');
1566
+ if (measureToggle) {
1567
+ const currentTool = currentState?.viewportTools?.activeUtilityTool ?? 'none';
1568
+ const nextTool = currentTool === 'measure-distance' ? 'none' : 'measure-distance';
1569
+ callbacks.onViewportUtilityToolChange?.(nextTool);
1570
+ return;
1571
+ }
1572
+ const clearMeasurement = target?.closest('[data-viewport-measurement-clear]');
1573
+ if (clearMeasurement) {
1574
+ callbacks.onViewportMeasurementClear?.();
1575
+ }
1576
+ });
783
1577
  const setShortcutHelpOpen = (open) => {
784
1578
  if (open)
785
1579
  contextMenu.close();
@@ -812,12 +1606,18 @@ export function createLocalEditorBrowserUi(options = {}) {
812
1606
  if (space)
813
1607
  callbacks.onTransformSpaceChange?.(space);
814
1608
  });
815
- snapButton.addEventListener('click', () => {
816
- const enabled = currentState?.transformOperations?.settings.snap.enabled
817
- ?? DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS.snap.enabled;
818
- callbacks.onTransformSnapEnabledChange?.(!enabled);
1609
+ snapButton.addEventListener('click', (event) => {
1610
+ event.stopPropagation();
1611
+ toggleSnapSettingsPopover();
819
1612
  });
820
- snapGroup.addEventListener('change', (event) => {
1613
+ snapSettingsPopover.addEventListener('change', (event) => {
1614
+ const enabledInput = event.target instanceof HTMLInputElement
1615
+ ? event.target.closest('[data-transform-snap-enabled]')
1616
+ : null;
1617
+ if (enabledInput) {
1618
+ callbacks.onTransformSnapEnabledChange?.(enabledInput.checked);
1619
+ return;
1620
+ }
821
1621
  const input = event.target instanceof HTMLInputElement ? event.target : null;
822
1622
  const kind = input?.dataset.transformSnapStep;
823
1623
  if (!input || !kind)
@@ -831,18 +1631,47 @@ export function createLocalEditorBrowserUi(options = {}) {
831
1631
  callbacks.onTransformSnapStepChange?.({ kind, value });
832
1632
  });
833
1633
  placementGroup.addEventListener('click', (event) => {
1634
+ event.stopPropagation();
1635
+ togglePlacementSettingsPopover();
1636
+ });
1637
+ placementSettingsPopover.addEventListener('click', (event) => {
834
1638
  const target = event.target instanceof HTMLElement ? event.target : null;
835
1639
  const mode = target?.closest('[data-placement-mode]')?.dataset.placementMode;
836
- if (mode)
837
- callbacks.onPlacementModeChange?.(mode);
1640
+ if (!mode)
1641
+ return;
1642
+ callbacks.onPlacementModeChange?.(mode);
1643
+ closePlacementSettingsPopover();
1644
+ });
1645
+ actionGroup.addEventListener('click', (event) => {
1646
+ event.stopPropagation();
1647
+ toggleTransformActionPopover();
838
1648
  });
839
- transformActionSelect.addEventListener('change', () => {
840
- selectedTransformAction = transformActionSelect.value;
841
- if (currentState)
842
- render(currentState);
1649
+ transformActionPopover.addEventListener('click', (event) => {
1650
+ const target = event.target instanceof HTMLElement ? event.target : null;
1651
+ const action = target?.closest('[data-transform-action]')?.dataset.transformAction;
1652
+ if (!action)
1653
+ return;
1654
+ const transformOperations = currentState?.transformOperations;
1655
+ if (!transformOperations || !isTransformActionEnabled(action, transformOperations))
1656
+ return;
1657
+ callbacks.onTransformAction?.(action);
1658
+ closeTransformActionPopover();
843
1659
  });
844
- transformActionButton.addEventListener('click', () => {
845
- callbacks.onTransformAction?.(selectedTransformAction);
1660
+ overlaySettingsPopover.addEventListener('change', (event) => {
1661
+ const input = event.target instanceof HTMLInputElement
1662
+ ? event.target.closest('[data-viewport-overlay-setting]')
1663
+ : null;
1664
+ const key = input?.dataset.viewportOverlaySetting;
1665
+ if (!input || !key)
1666
+ return;
1667
+ const currentOverlay = currentState?.viewportTools?.overlay;
1668
+ callbacks.onViewportOverlaySettingsChange?.({
1669
+ bounds: currentOverlay?.bounds ?? true,
1670
+ dimensions: currentOverlay?.dimensions ?? true,
1671
+ edgeLengths: currentOverlay?.edgeLengths ?? false,
1672
+ anchor: currentOverlay?.anchor ?? true,
1673
+ [key]: input.checked,
1674
+ });
846
1675
  });
847
1676
  assetPanel.addEventListener('click', (event) => {
848
1677
  const target = event.target instanceof HTMLElement ? event.target : null;
@@ -930,6 +1759,26 @@ export function createLocalEditorBrowserUi(options = {}) {
930
1759
  if (currentState?.mode !== 'editor' || currentState.busy)
931
1760
  return;
932
1761
  const key = event.key.toLowerCase();
1762
+ if (key === 'escape' && snapSettingsOpen) {
1763
+ event.preventDefault();
1764
+ closeSnapSettingsPopover();
1765
+ return;
1766
+ }
1767
+ if (key === 'escape' && placementSettingsOpen) {
1768
+ event.preventDefault();
1769
+ closePlacementSettingsPopover();
1770
+ return;
1771
+ }
1772
+ if (key === 'escape' && transformActionOpen) {
1773
+ event.preventDefault();
1774
+ closeTransformActionPopover();
1775
+ return;
1776
+ }
1777
+ if (key === 'escape' && overlaySettingsOpen) {
1778
+ event.preventDefault();
1779
+ closeOverlaySettingsPopover();
1780
+ return;
1781
+ }
933
1782
  const primaryModifier = event.metaKey || event.ctrlKey;
934
1783
  const handleDocumentShortcut = inputRouter.shouldHandleDocumentShortcut(event);
935
1784
  const handleGlobalShortcut = inputRouter.shouldHandleGlobalShortcut(event);
@@ -975,7 +1824,10 @@ export function createLocalEditorBrowserUi(options = {}) {
975
1824
  }
976
1825
  if (key === 'escape') {
977
1826
  event.preventDefault();
978
- callbacks.onCancelActiveOperation?.();
1827
+ if (callbacks.onCancelEditorIntent)
1828
+ callbacks.onCancelEditorIntent();
1829
+ else
1830
+ callbacks.onCancelActiveOperation?.();
979
1831
  }
980
1832
  };
981
1833
  doc.addEventListener('keydown', onKeyDown);
@@ -1028,33 +1880,6 @@ export function createLocalEditorBrowserUi(options = {}) {
1028
1880
  ? CSS.escape(value)
1029
1881
  : value.replace(/["\\]/g, '\\$&');
1030
1882
  }
1031
- function renderTransformHandleBadges(transformTool) {
1032
- LocalEditorShared.clearElement(handleGroup);
1033
- const tool = transformTool?.activeTool ?? 'select';
1034
- const descriptor = DEFAULT_EDITOR_TRANSFORM_TOOL_DESCRIPTORS[tool];
1035
- handleGroup.style.display = descriptor.handles.length > 0 ? 'flex' : 'none';
1036
- for (const handle of descriptor.handles) {
1037
- const badge = doc.createElement('span');
1038
- badge.dataset.transformHandle = handle.constraint;
1039
- badge.title = handle.description;
1040
- badge.textContent = handle.label;
1041
- badge.style.cssText = [
1042
- 'height:22px',
1043
- 'display:inline-flex',
1044
- 'align-items:center',
1045
- 'padding:0 7px',
1046
- 'border:1px solid var(--fps-editor-border)',
1047
- 'border-radius:3px',
1048
- 'background:var(--fps-editor-field)',
1049
- 'color:var(--fps-editor-muted-strong)',
1050
- 'font-size:11px',
1051
- 'font-weight:800',
1052
- 'white-space:nowrap',
1053
- 'pointer-events:none',
1054
- ].join(';');
1055
- handleGroup.appendChild(badge);
1056
- }
1057
- }
1058
1883
  function formatSnapStepInputValue(snap, kind) {
1059
1884
  const value = kind === 'move'
1060
1885
  ? snap.moveStep
@@ -1077,7 +1902,7 @@ export function createLocalEditorBrowserUi(options = {}) {
1077
1902
  contextMenu.close();
1078
1903
  hostChrome.style.display = !inEditor && localTestActionsEnabled ? 'flex' : 'none';
1079
1904
  enterEditorButton.disabled = disabled;
1080
- for (const button of [saveButton, saveAndRunButton, discardRunButton, undoButton, redoButton, sceneHelpButton, sceneCameraButton]) {
1905
+ for (const button of [saveButton, saveAndRunButton, discardRunButton, undoButton, redoButton, sceneHelpButton, sceneCameraButton, gridToggleButton]) {
1081
1906
  button.style.display = 'inline-flex';
1082
1907
  button.disabled = disabled;
1083
1908
  }
@@ -1091,13 +1916,63 @@ export function createLocalEditorBrowserUi(options = {}) {
1091
1916
  const transformTool = state.transformTool ?? null;
1092
1917
  workbench.root.style.display = inEditor ? '' : 'none';
1093
1918
  sceneToolOverlay.style.display = inEditor ? 'flex' : 'none';
1919
+ if (!inEditor)
1920
+ closeSnapSettingsPopover();
1921
+ if (!inEditor)
1922
+ closePlacementSettingsPopover();
1923
+ if (!inEditor)
1924
+ closeTransformActionPopover();
1925
+ if (!inEditor)
1926
+ closeOverlaySettingsPopover();
1094
1927
  const sceneCameraPreview = state.sceneCameraPreview ?? { enabled: false, available: false };
1095
1928
  cameraPreviewGroup.style.display = inEditor ? 'flex' : 'none';
1096
1929
  sceneCameraButton.disabled = disabled || !sceneCameraPreview.available;
1097
- sceneCameraButton.title = sceneCameraPreview.available
1930
+ const sceneCameraTooltip = sceneCameraPreview.available
1098
1931
  ? '从 Main Camera 查看当前场景'
1099
1932
  : '当前场景没有可预览的 Main Camera';
1933
+ setToolbarButtonTooltip(sceneCameraButton, sceneCameraTooltip);
1100
1934
  LocalEditorShared.applyButtonActiveState(sceneCameraButton, sceneCameraPreview.enabled);
1935
+ const viewportTools = state.viewportTools ?? null;
1936
+ const viewportControlsDisabled = disabled || sceneCameraPreview.enabled;
1937
+ const viewportControlsTooltip = sceneCameraPreview.enabled
1938
+ ? '请先关闭 Scene Camera 预览,再使用视口工具'
1939
+ : '';
1940
+ viewportToolsGroup.style.display = inEditor ? 'flex' : 'none';
1941
+ for (const [preset, button] of viewportPresetButtons) {
1942
+ button.disabled = viewportControlsDisabled;
1943
+ setToolbarButtonTooltip(button, viewportControlsTooltip || VIEW_PRESET_TOOLTIPS[preset]);
1944
+ LocalEditorShared.applyButtonActiveState(button, viewportTools?.viewPreset === preset);
1945
+ }
1946
+ projectionToggleButton.disabled = viewportControlsDisabled;
1947
+ const projectionMode = viewportTools?.projectionMode ?? 'perspective';
1948
+ const nextProjectionMode = projectionMode === 'orthographic' ? 'perspective' : 'orthographic';
1949
+ setToolbarButtonIcon(doc, projectionToggleButton, projectionMode === 'orthographic' ? 'projection-ortho' : 'projection-perspective');
1950
+ setToolbarButtonTooltip(projectionToggleButton, viewportControlsTooltip || `投影模式:${VIEWPORT_PROJECTION_LABELS[projectionMode]}。切换为${VIEWPORT_PROJECTION_LABELS[nextProjectionMode]}`);
1951
+ LocalEditorShared.applyButtonActiveState(projectionToggleButton, projectionMode === 'orthographic');
1952
+ overlaySettingsButton.disabled = disabled;
1953
+ setToolbarButtonTooltip(overlaySettingsButton, '视口信息层设置');
1954
+ LocalEditorShared.applyButtonActiveState(overlaySettingsButton, overlaySettingsOpen);
1955
+ const overlaySettings = viewportTools?.overlay;
1956
+ for (const [key, input] of overlayInputs) {
1957
+ input.disabled = disabled;
1958
+ input.checked = overlaySettings?.[key] ?? false;
1959
+ }
1960
+ measureButton.disabled = viewportControlsDisabled;
1961
+ setToolbarButtonTooltip(measureButton, viewportControlsTooltip || '测量 XZ 地面距离');
1962
+ LocalEditorShared.applyButtonActiveState(measureButton, viewportTools?.activeUtilityTool === 'measure-distance');
1963
+ const hasMeasurement = !!(state.viewportMeasurement?.start
1964
+ || state.viewportMeasurement?.end
1965
+ || state.viewportMeasurement?.preview);
1966
+ clearMeasurementButton.style.display = hasMeasurement ? 'inline-flex' : 'none';
1967
+ clearMeasurementButton.disabled = disabled || !hasMeasurement;
1968
+ setToolbarButtonTooltip(clearMeasurementButton, '清除测量结果');
1969
+ LocalEditorShared.applyButtonActiveState(clearMeasurementButton, false);
1970
+ if (!inEditor || disabled || viewportToolsGroup.style.display === 'none')
1971
+ closeOverlaySettingsPopover();
1972
+ const gridState = state.grid ?? { visible: false, available: false };
1973
+ gridToggleButton.disabled = disabled || !gridState.available;
1974
+ setToolbarButtonTooltip(gridToggleButton, gridState.visible ? '隐藏 Scene View 网格' : '显示 Scene View 网格');
1975
+ LocalEditorShared.applyButtonActiveState(gridToggleButton, gridState.visible);
1101
1976
  for (const [tool, button] of toolButtons) {
1102
1977
  button.disabled = disabled;
1103
1978
  LocalEditorShared.applyButtonActiveState(button, transformTool?.activeTool === tool);
@@ -1107,7 +1982,6 @@ export function createLocalEditorBrowserUi(options = {}) {
1107
1982
  LocalEditorShared.applyButtonActiveState(button, transformTool?.activeSpace === space);
1108
1983
  }
1109
1984
  sceneHelpButton.disabled = disabled;
1110
- renderTransformHandleBadges(transformTool);
1111
1985
  const transformOperations = state.transformOperations ?? {
1112
1986
  settings: DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS,
1113
1987
  selectedCount: state.selectedIds.length,
@@ -1118,53 +1992,75 @@ export function createLocalEditorBrowserUi(options = {}) {
1118
1992
  const operationSettings = transformOperations.settings;
1119
1993
  snapGroup.style.display = inEditor ? 'flex' : 'none';
1120
1994
  snapButton.disabled = disabled;
1121
- LocalEditorShared.applyButtonActiveState(snapButton, operationSettings.snap.enabled);
1995
+ snapEnabledInput.disabled = disabled;
1996
+ snapEnabledInput.checked = operationSettings.snap.enabled;
1997
+ LocalEditorShared.applyButtonActiveState(snapButton, operationSettings.snap.enabled || snapSettingsOpen);
1998
+ setToolbarButtonTooltip(snapButton, `吸附设置 · 位移 ${formatSnapStepInputValue(operationSettings.snap, 'move')} · 旋转 ${formatSnapStepInputValue(operationSettings.snap, 'rotate')}° · 缩放 ${formatSnapStepInputValue(operationSettings.snap, 'scale')}`);
1122
1999
  for (const [kind, input] of snapStepInputs) {
1123
2000
  input.disabled = disabled;
1124
2001
  if (doc.activeElement !== input)
1125
2002
  input.value = formatSnapStepInputValue(operationSettings.snap, kind);
1126
2003
  }
2004
+ if (!inEditor || disabled || snapGroup.style.display === 'none')
2005
+ closeSnapSettingsPopover();
1127
2006
  placementGroup.style.display = inEditor ? 'flex' : 'none';
2007
+ placementButton.disabled = disabled;
2008
+ setToolbarButtonIcon(doc, placementButton, PLACEMENT_MODE_ICONS[operationSettings.placementMode]);
2009
+ setToolbarButtonTooltip(placementButton, `放置模式 · ${PLACEMENT_MODE_DESCRIPTIONS[operationSettings.placementMode]}`);
2010
+ LocalEditorShared.applyButtonActiveState(placementButton, operationSettings.placementMode !== 'off' || placementSettingsOpen);
1128
2011
  for (const [mode, button] of Object.entries(placementButtons)) {
1129
2012
  button.disabled = disabled;
1130
2013
  LocalEditorShared.applyButtonActiveState(button, operationSettings.placementMode === mode);
1131
2014
  }
2015
+ if (!inEditor || disabled || placementGroup.style.display === 'none')
2016
+ closePlacementSettingsPopover();
1132
2017
  actionGroup.style.display = inEditor ? 'flex' : 'none';
1133
- transformActionSelect.disabled = disabled;
1134
- transformActionButton.disabled = disabled || !isTransformActionEnabled(selectedTransformAction, transformOperations);
1135
- transformActionButton.title = selectedTransformAction.startsWith('align-')
1136
- ? '需要至少 2 个选中对象,并以 active object 为目标'
1137
- : '需要至少 3 个选中对象';
2018
+ const hasTransformAction = transformOperations.canAlign || transformOperations.canDistribute;
2019
+ transformActionButton.disabled = disabled || !hasTransformAction;
2020
+ setToolbarButtonTooltip(transformActionButton, hasTransformAction ? '对齐与分布' : '选择至少 2 个对象后可用');
2021
+ LocalEditorShared.applyButtonActiveState(transformActionButton, transformActionOpen);
2022
+ for (const [action, button] of transformActionButtons) {
2023
+ const enabled = isTransformActionEnabled(action, transformOperations);
2024
+ button.disabled = disabled || !enabled;
2025
+ button.style.opacity = button.disabled ? '0.45' : '1';
2026
+ button.style.cursor = button.disabled ? 'not-allowed' : 'pointer';
2027
+ }
2028
+ if (!inEditor || disabled || !hasTransformAction || actionGroup.style.display === 'none')
2029
+ closeTransformActionPopover();
1138
2030
  undoButton.disabled = disabled || !state.session?.canUndo;
1139
2031
  redoButton.disabled = disabled || !state.session?.canRedo;
1140
2032
  LocalEditorShared.applyButtonActiveState(sceneHelpButton, inEditor && helpOpen);
1141
2033
  shortcutHelpPanel.style.display = inEditor && helpOpen ? '' : 'none';
1142
2034
  dirtyBadge.style.display = inEditor && state.session?.dirty ? 'inline-flex' : 'none';
1143
- const dragSuffix = transformTool?.dragPhase === 'dragging'
1144
- ? ` | dragging ${transformTool.draggingNodeId ?? 'selection'}`
1145
- : '';
1146
- status.textContent = [
2035
+ const editorStatusText = [
1147
2036
  state.status,
1148
- state.session?.dirty ? '*' : '',
2037
+ state.session?.dirty ? '有未保存更改' : '',
1149
2038
  LocalEditorShared.formatAuthoringSourceLabel(state.session?.source),
1150
2039
  state.summary,
1151
2040
  ].filter(Boolean).join(' | ');
1152
- status.textContent += dragSuffix;
1153
- status.title = state.statusDetails || status.textContent;
1154
- status.style.color = state.statusTone === 'error'
2041
+ const transformStatusText = transformTool?.dragPhase === 'dragging'
2042
+ ? `正在拖拽 ${transformTool.draggingNodeId ?? '选择对象'}`
2043
+ : LocalEditorShared.toTransformToolStatusLabel(transformTool?.activeTool ?? 'select', transformTool?.activeSpace ?? 'world');
2044
+ const statusToneColor = state.statusTone === 'error'
1155
2045
  ? 'var(--fps-editor-danger-strong)'
1156
2046
  : state.statusTone === 'warning'
1157
2047
  ? 'var(--fps-editor-warn)'
1158
2048
  : state.statusTone === 'success'
1159
2049
  ? 'var(--fps-editor-success)'
1160
2050
  : 'var(--fps-editor-muted)';
1161
- sceneToolStatus.textContent = transformTool?.dragPhase === 'dragging'
1162
- ? `正在拖拽 ${transformTool.draggingNodeId ?? '选择对象'}`
1163
- : LocalEditorShared.toTransformToolStatusLabel(transformTool?.activeTool ?? 'select', transformTool?.activeSpace ?? 'world');
1164
- sceneMouseHint.textContent = [
1165
- LocalEditorShared.toTransformMouseHint(transformTool?.activeTool ?? 'select'),
2051
+ setToolbarButtonTooltip(editorStatusButton, [
2052
+ `编辑器状态:${editorStatusText}`,
2053
+ state.statusDetails && state.statusDetails !== state.status ? state.statusDetails : '',
2054
+ transformStatusText,
1166
2055
  LocalEditorShared.toTransformOperationStatusLabel(operationSettings),
1167
- ].join(' · ');
2056
+ ].filter(Boolean).join(' · '));
2057
+ editorStatusButton.style.color = statusToneColor;
2058
+ editorStatusButton.style.borderColor = state.statusTone == null || state.statusTone === 'default'
2059
+ ? 'var(--fps-editor-border)'
2060
+ : statusToneColor;
2061
+ renderCoordinateAxesOverlay(coordinateAxesOverlay, inEditor ? state.coordinateAxes ?? null : null);
2062
+ renderSpatialOverlay(spatialOverlay, inEditor ? state.viewportSpatialOverlay ?? null : null);
2063
+ renderMeasurementOverlay(measurementOverlay, inEditor ? state.viewportMeasurement ?? null : null);
1168
2064
  const boxSelection = state.boxSelection;
1169
2065
  if (inEditor && boxSelection?.active) {
1170
2066
  boxSelectionOverlay.style.display = '';
@@ -1212,10 +2108,17 @@ export function createLocalEditorBrowserUi(options = {}) {
1212
2108
  workbench.root.remove();
1213
2109
  localTestMenu.remove();
1214
2110
  toolbarOverflowMenu.remove();
2111
+ snapSettingsPopover.remove();
2112
+ placementSettingsPopover.remove();
2113
+ transformActionPopover.remove();
2114
+ overlaySettingsPopover.remove();
2115
+ spatialOverlay.root.remove();
2116
+ measurementOverlay.root.remove();
1215
2117
  boxSelectionOverlay.remove();
1216
2118
  shortcutHelpPanel.remove();
1217
2119
  hierarchyController.dispose();
1218
2120
  workbenchLayoutController.dispose();
2121
+ tooltipController.dispose();
1219
2122
  contextMenu.dispose();
1220
2123
  inputRouter.dispose();
1221
2124
  doc.removeEventListener('keydown', onKeyDown);