@fps-games/editor 0.1.1-beta.7 → 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 (52) hide show
  1. package/dist/local-editor-harness.d.ts +14 -2
  2. package/dist/local-editor-harness.d.ts.map +1 -1
  3. package/dist/local-editor-harness.js +330 -2
  4. package/dist/local-editor-harness.js.map +1 -1
  5. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts +2 -0
  6. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts.map +1 -1
  7. package/node_modules/@fps-games/editor-babylon/dist/index.js +2 -0
  8. package/node_modules/@fps-games/editor-babylon/dist/index.js.map +1 -1
  9. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts +18 -0
  10. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.d.ts.map +1 -1
  11. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js +142 -0
  12. package/node_modules/@fps-games/editor-babylon/dist/scene-view-camera-controller.js.map +1 -1
  13. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts +1 -0
  14. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.d.ts.map +1 -1
  15. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js +3 -1
  16. package/node_modules/@fps-games/editor-babylon/dist/scene-view-input-controller.js.map +1 -1
  17. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.d.ts +20 -0
  18. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.d.ts.map +1 -0
  19. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.js +187 -0
  20. package/node_modules/@fps-games/editor-babylon/dist/scene-view-measurement-controller.js.map +1 -0
  21. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.d.ts +20 -0
  22. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.d.ts.map +1 -0
  23. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.js +188 -0
  24. package/node_modules/@fps-games/editor-babylon/dist/scene-view-spatial-overlay-controller.js.map +1 -0
  25. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts +4 -0
  26. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  27. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  28. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts +1 -1
  29. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts.map +1 -1
  30. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js +42 -0
  31. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js.map +1 -1
  32. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +16 -1
  33. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  34. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +1 -1
  35. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  36. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +502 -6
  37. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  38. package/node_modules/@fps-games/editor-browser/package.json +2 -2
  39. package/node_modules/@fps-games/editor-core/dist/index.d.ts +1 -0
  40. package/node_modules/@fps-games/editor-core/dist/index.d.ts.map +1 -1
  41. package/node_modules/@fps-games/editor-core/dist/index.js +1 -0
  42. package/node_modules/@fps-games/editor-core/dist/index.js.map +1 -1
  43. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts +1 -1
  44. package/node_modules/@fps-games/editor-core/dist/scene-view-input.d.ts.map +1 -1
  45. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts +94 -0
  46. package/node_modules/@fps-games/editor-core/dist/viewport-tools.d.ts.map +1 -0
  47. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js +58 -0
  48. package/node_modules/@fps-games/editor-core/dist/viewport-tools.js.map +1 -0
  49. package/node_modules/@fps-games/editor-core/package.json +2 -2
  50. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  51. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  52. package/package.json +6 -6
@@ -75,6 +75,28 @@ const PLACEMENT_MODE_DESCRIPTIONS = {
75
75
  ground: '放置到 XZ 地面',
76
76
  surface: '放置到场景表面',
77
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
+ };
78
100
  const TRANSFORM_ACTION_GROUPS = [
79
101
  {
80
102
  title: '对齐到 Active Object',
@@ -283,6 +305,262 @@ function renderCoordinateAxesOverlay(overlay, state) {
283
305
  }
284
306
  overlay.labelLayer.appendChild(overlay.centerDot);
285
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
+ }
286
564
  function formatSvgNumber(value) {
287
565
  return Number.isFinite(value) ? value.toFixed(2) : '0';
288
566
  }
@@ -692,6 +970,74 @@ export function createLocalEditorBrowserUi(options = {}) {
692
970
  const sceneCameraButton = createToolbarIconButton(doc, '从 Main Camera 查看场景', 'camera');
693
971
  sceneCameraButton.dataset.sceneCameraPreviewToggle = 'true';
694
972
  cameraPreviewGroup.appendChild(sceneCameraButton);
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
+ }
695
1041
  const toolbarOverflowButton = createToolbarIconButton(doc, '更多', 'chevron-down', '显示隐藏的工具栏命令');
696
1042
  toolbarOverflowButton.dataset.editorToolbarOverflowToggle = 'true';
697
1043
  toolbarOverflowButton.style.display = 'none';
@@ -716,29 +1062,36 @@ export function createLocalEditorBrowserUi(options = {}) {
716
1062
  'pointer-events:auto',
717
1063
  ].join(';');
718
1064
  const coordinateAxesOverlay = createCoordinateAxesOverlay(doc);
1065
+ const spatialOverlay = createSpatialOverlay(doc);
1066
+ const measurementOverlay = createMeasurementOverlay(doc);
719
1067
  sceneToolOverlay.appendChild(sceneTitle);
720
1068
  sceneToolOverlay.appendChild(sceneQuickActions);
721
- sceneToolOverlay.appendChild(sceneUtilityActions);
722
- sceneToolOverlay.appendChild(cameraPreviewGroup);
723
1069
  sceneToolOverlay.appendChild(toolGroup);
724
1070
  sceneToolOverlay.appendChild(spaceGroup);
725
1071
  sceneToolOverlay.appendChild(snapGroup);
726
1072
  sceneToolOverlay.appendChild(placementGroup);
727
1073
  sceneToolOverlay.appendChild(actionGroup);
1074
+ sceneToolOverlay.appendChild(viewportToolsGroup);
1075
+ sceneToolOverlay.appendChild(sceneUtilityActions);
1076
+ sceneToolOverlay.appendChild(cameraPreviewGroup);
728
1077
  sceneToolOverlay.appendChild(editorStatusButton);
729
1078
  sceneToolOverlay.appendChild(toolbarOverflowButton);
730
1079
  workbench.sceneHeader.appendChild(sceneToolOverlay);
1080
+ workbench.sceneFrame.appendChild(spatialOverlay.root);
1081
+ workbench.sceneFrame.appendChild(measurementOverlay.root);
731
1082
  workbench.sceneFrame.appendChild(coordinateAxesOverlay.root);
732
1083
  root.appendChild(localTestMenu);
733
1084
  root.appendChild(toolbarOverflowMenu);
734
1085
  root.appendChild(snapSettingsPopover);
735
1086
  root.appendChild(placementSettingsPopover);
736
1087
  root.appendChild(transformActionPopover);
1088
+ root.appendChild(overlaySettingsPopover);
737
1089
  tooltipSurfaces.add(localTestMenu);
738
1090
  tooltipSurfaces.add(toolbarOverflowMenu);
739
1091
  tooltipSurfaces.add(snapSettingsPopover);
740
1092
  tooltipSurfaces.add(placementSettingsPopover);
741
1093
  tooltipSurfaces.add(transformActionPopover);
1094
+ tooltipSurfaces.add(overlaySettingsPopover);
742
1095
  const boxSelectionOverlay = doc.createElement('div');
743
1096
  boxSelectionOverlay.classList.add(LOCAL_EDITOR_THEME_CLASS);
744
1097
  boxSelectionOverlay.style.cssText = [
@@ -759,6 +1112,8 @@ export function createLocalEditorBrowserUi(options = {}) {
759
1112
  function applyThemeToSurfaces() {
760
1113
  applyLocalEditorTheme(hostChrome, activeTheme);
761
1114
  applyLocalEditorTheme(workbench.root, activeTheme);
1115
+ applyLocalEditorTheme(spatialOverlay.root, activeTheme);
1116
+ applyLocalEditorTheme(measurementOverlay.root, activeTheme);
762
1117
  applyLocalEditorTheme(shortcutHelpPanel, activeTheme);
763
1118
  applyLocalEditorTheme(boxSelectionOverlay, activeTheme);
764
1119
  applyLocalEditorTheme(localTestMenu, activeTheme);
@@ -766,6 +1121,7 @@ export function createLocalEditorBrowserUi(options = {}) {
766
1121
  applyLocalEditorTheme(snapSettingsPopover, activeTheme);
767
1122
  applyLocalEditorTheme(placementSettingsPopover, activeTheme);
768
1123
  applyLocalEditorTheme(transformActionPopover, activeTheme);
1124
+ applyLocalEditorTheme(overlaySettingsPopover, activeTheme);
769
1125
  tooltipController.setTheme(activeTheme);
770
1126
  contextMenu.setTheme?.(activeTheme);
771
1127
  }
@@ -784,6 +1140,7 @@ export function createLocalEditorBrowserUi(options = {}) {
784
1140
  let snapSettingsOpen = false;
785
1141
  let placementSettingsOpen = false;
786
1142
  let transformActionOpen = false;
1143
+ let overlaySettingsOpen = false;
787
1144
  function closeLocalTestMenu() {
788
1145
  localTestMenuOpen = false;
789
1146
  localTestMenu.style.display = 'none';
@@ -811,6 +1168,12 @@ export function createLocalEditorBrowserUi(options = {}) {
811
1168
  transformActionButton.setAttribute('aria-expanded', 'false');
812
1169
  LocalEditorShared.applyButtonActiveState(transformActionButton, false);
813
1170
  }
1171
+ function closeOverlaySettingsPopover() {
1172
+ overlaySettingsOpen = false;
1173
+ overlaySettingsPopover.style.display = 'none';
1174
+ overlaySettingsButton.setAttribute('aria-expanded', 'false');
1175
+ LocalEditorShared.applyButtonActiveState(overlaySettingsButton, false);
1176
+ }
814
1177
  function openLocalTestMenu() {
815
1178
  if (!localTestActionsEnabled || localTestButton.style.display === 'none')
816
1179
  return;
@@ -818,6 +1181,8 @@ export function createLocalEditorBrowserUi(options = {}) {
818
1181
  closeSnapSettingsPopover();
819
1182
  closePlacementSettingsPopover();
820
1183
  closeTransformActionPopover();
1184
+ closeOverlaySettingsPopover();
1185
+ closeOverlaySettingsPopover();
821
1186
  const win = doc.defaultView;
822
1187
  const rect = localTestButton.getBoundingClientRect();
823
1188
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -834,6 +1199,7 @@ export function createLocalEditorBrowserUi(options = {}) {
834
1199
  closeLocalTestMenu();
835
1200
  closePlacementSettingsPopover();
836
1201
  closeTransformActionPopover();
1202
+ closeOverlaySettingsPopover();
837
1203
  const win = doc.defaultView;
838
1204
  const rect = snapButton.getBoundingClientRect();
839
1205
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -858,6 +1224,7 @@ export function createLocalEditorBrowserUi(options = {}) {
858
1224
  closeLocalTestMenu();
859
1225
  closeSnapSettingsPopover();
860
1226
  closeTransformActionPopover();
1227
+ closeOverlaySettingsPopover();
861
1228
  const win = doc.defaultView;
862
1229
  const rect = placementButton.getBoundingClientRect();
863
1230
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -882,6 +1249,7 @@ export function createLocalEditorBrowserUi(options = {}) {
882
1249
  closeLocalTestMenu();
883
1250
  closeSnapSettingsPopover();
884
1251
  closePlacementSettingsPopover();
1252
+ closeOverlaySettingsPopover();
885
1253
  const win = doc.defaultView;
886
1254
  const rect = transformActionButton.getBoundingClientRect();
887
1255
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -899,6 +1267,31 @@ export function createLocalEditorBrowserUi(options = {}) {
899
1267
  else
900
1268
  openTransformActionPopover();
901
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
+ }
902
1295
  const onLocalTestButtonClick = (event) => {
903
1296
  event.stopPropagation();
904
1297
  if (localTestMenuOpen)
@@ -926,24 +1319,26 @@ export function createLocalEditorBrowserUi(options = {}) {
926
1319
  const toolbarOrder = [
927
1320
  sceneTitle,
928
1321
  sceneQuickActions,
929
- sceneUtilityActions,
930
- cameraPreviewGroup,
931
1322
  toolGroup,
932
1323
  spaceGroup,
933
1324
  snapGroup,
934
1325
  placementGroup,
935
1326
  actionGroup,
1327
+ viewportToolsGroup,
1328
+ sceneUtilityActions,
1329
+ cameraPreviewGroup,
936
1330
  editorStatusButton,
937
1331
  toolbarOverflowButton,
938
1332
  ];
939
1333
  const toolbarOverflowItems = [
940
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' },
941
1338
  { id: 'transform-actions', element: actionGroup, kind: 'group' },
942
1339
  { id: 'placement', element: placementGroup, kind: 'group' },
943
1340
  { id: 'snap', element: snapGroup, kind: 'group' },
944
1341
  { id: 'space', element: spaceGroup, kind: 'group' },
945
- { id: 'camera-preview', element: cameraPreviewGroup, kind: 'group' },
946
- { id: 'scene-utilities', element: sceneUtilityActions, kind: 'group' },
947
1342
  ];
948
1343
  let toolbarOverflowOpen = false;
949
1344
  let toolbarOverflowRaf = null;
@@ -1018,6 +1413,7 @@ export function createLocalEditorBrowserUi(options = {}) {
1018
1413
  closeSnapSettingsPopover();
1019
1414
  closePlacementSettingsPopover();
1020
1415
  closeTransformActionPopover();
1416
+ closeOverlaySettingsPopover();
1021
1417
  const win = doc.defaultView;
1022
1418
  const rect = toolbarOverflowButton.getBoundingClientRect();
1023
1419
  const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
@@ -1100,11 +1496,14 @@ export function createLocalEditorBrowserUi(options = {}) {
1100
1496
  return;
1101
1497
  if (target.closest('[data-transform-action-popover]') || target.closest('[data-transform-action-toggle]'))
1102
1498
  return;
1499
+ if (target.closest('[data-viewport-overlay-settings-popover]') || target.closest('[data-viewport-overlay-settings-toggle]'))
1500
+ return;
1103
1501
  closeLocalTestMenu();
1104
1502
  closeToolbarOverflowMenu();
1105
1503
  closeSnapSettingsPopover();
1106
1504
  closePlacementSettingsPopover();
1107
1505
  closeTransformActionPopover();
1506
+ closeOverlaySettingsPopover();
1108
1507
  };
1109
1508
  const ResizeObserverCtor = doc.defaultView?.ResizeObserver;
1110
1509
  const toolbarResizeObserver = ResizeObserverCtor
@@ -1143,6 +1542,38 @@ export function createLocalEditorBrowserUi(options = {}) {
1143
1542
  const enabled = currentState?.sceneCameraPreview?.enabled ?? false;
1144
1543
  callbacks.onSceneCameraPreviewToggle?.(!enabled);
1145
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
+ });
1146
1577
  const setShortcutHelpOpen = (open) => {
1147
1578
  if (open)
1148
1579
  contextMenu.close();
@@ -1226,6 +1657,22 @@ export function createLocalEditorBrowserUi(options = {}) {
1226
1657
  callbacks.onTransformAction?.(action);
1227
1658
  closeTransformActionPopover();
1228
1659
  });
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
+ });
1675
+ });
1229
1676
  assetPanel.addEventListener('click', (event) => {
1230
1677
  const target = event.target instanceof HTMLElement ? event.target : null;
1231
1678
  const tabButton = target?.closest('[data-editor-dock-tab]');
@@ -1327,6 +1774,11 @@ export function createLocalEditorBrowserUi(options = {}) {
1327
1774
  closeTransformActionPopover();
1328
1775
  return;
1329
1776
  }
1777
+ if (key === 'escape' && overlaySettingsOpen) {
1778
+ event.preventDefault();
1779
+ closeOverlaySettingsPopover();
1780
+ return;
1781
+ }
1330
1782
  const primaryModifier = event.metaKey || event.ctrlKey;
1331
1783
  const handleDocumentShortcut = inputRouter.shouldHandleDocumentShortcut(event);
1332
1784
  const handleGlobalShortcut = inputRouter.shouldHandleGlobalShortcut(event);
@@ -1470,6 +1922,8 @@ export function createLocalEditorBrowserUi(options = {}) {
1470
1922
  closePlacementSettingsPopover();
1471
1923
  if (!inEditor)
1472
1924
  closeTransformActionPopover();
1925
+ if (!inEditor)
1926
+ closeOverlaySettingsPopover();
1473
1927
  const sceneCameraPreview = state.sceneCameraPreview ?? { enabled: false, available: false };
1474
1928
  cameraPreviewGroup.style.display = inEditor ? 'flex' : 'none';
1475
1929
  sceneCameraButton.disabled = disabled || !sceneCameraPreview.available;
@@ -1478,6 +1932,43 @@ export function createLocalEditorBrowserUi(options = {}) {
1478
1932
  : '当前场景没有可预览的 Main Camera';
1479
1933
  setToolbarButtonTooltip(sceneCameraButton, sceneCameraTooltip);
1480
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();
1481
1972
  const gridState = state.grid ?? { visible: false, available: false };
1482
1973
  gridToggleButton.disabled = disabled || !gridState.available;
1483
1974
  setToolbarButtonTooltip(gridToggleButton, gridState.visible ? '隐藏 Scene View 网格' : '显示 Scene View 网格');
@@ -1568,6 +2059,8 @@ export function createLocalEditorBrowserUi(options = {}) {
1568
2059
  ? 'var(--fps-editor-border)'
1569
2060
  : statusToneColor;
1570
2061
  renderCoordinateAxesOverlay(coordinateAxesOverlay, inEditor ? state.coordinateAxes ?? null : null);
2062
+ renderSpatialOverlay(spatialOverlay, inEditor ? state.viewportSpatialOverlay ?? null : null);
2063
+ renderMeasurementOverlay(measurementOverlay, inEditor ? state.viewportMeasurement ?? null : null);
1571
2064
  const boxSelection = state.boxSelection;
1572
2065
  if (inEditor && boxSelection?.active) {
1573
2066
  boxSelectionOverlay.style.display = '';
@@ -1618,6 +2111,9 @@ export function createLocalEditorBrowserUi(options = {}) {
1618
2111
  snapSettingsPopover.remove();
1619
2112
  placementSettingsPopover.remove();
1620
2113
  transformActionPopover.remove();
2114
+ overlaySettingsPopover.remove();
2115
+ spatialOverlay.root.remove();
2116
+ measurementOverlay.root.remove();
1621
2117
  boxSelectionOverlay.remove();
1622
2118
  shortcutHelpPanel.remove();
1623
2119
  hierarchyController.dispose();