fl-web-component 2.2.2 → 2.2.4

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.
@@ -22,11 +22,91 @@
22
22
  <div class="loading-text">{{ modelLoadingState.text }}</div>
23
23
  </div>
24
24
  </div>
25
+ <el-dialog
26
+ class="roaming-settings-dialog"
27
+ title="漫游设置"
28
+ width="520px"
29
+ :visible.sync="roamingSettingsVisible"
30
+ :append-to-body="true"
31
+ :close-on-click-modal="false"
32
+ @close="handleRoamingSettingsDialogClose"
33
+ >
34
+ <el-form
35
+ v-if="roamingSettingsDraft"
36
+ ref="roamingSettingsForm"
37
+ :model="roamingSettingsDraft"
38
+ label-width="100px"
39
+ size="small"
40
+ class="roaming-settings-form"
41
+ >
42
+ <div class="roaming-settings-section-title">视角</div>
43
+ <el-form-item label="视角">
44
+ <el-radio-group
45
+ v-model="roamingSettingsDraft.perspective"
46
+ class="roaming-settings-perspective"
47
+ >
48
+ <el-radio label="first-person">第一人称</el-radio>
49
+ <el-radio label="third-person">第三人称</el-radio>
50
+ </el-radio-group>
51
+ </el-form-item>
52
+
53
+ <div class="roaming-settings-section-title">人物属性</div>
54
+ <el-form-item label="人物身高">
55
+ <el-input
56
+ v-model.number="roamingSettingsDraft.height"
57
+ type="number"
58
+ :min="roamingSettingsLimits.height.min"
59
+ :max="roamingSettingsLimits.height.max"
60
+ :step="1"
61
+ class="roaming-settings-height"
62
+ >
63
+ <span slot="append">cm</span>
64
+ </el-input>
65
+ </el-form-item>
66
+ <el-form-item label="设置障碍">
67
+ <el-select
68
+ v-model="roamingSettingsDraft.collisionEnabled"
69
+ class="roaming-settings-control"
70
+ >
71
+ <el-option label="开启" :value="true"></el-option>
72
+ <el-option label="关闭" :value="false"></el-option>
73
+ </el-select>
74
+ </el-form-item>
75
+ <el-form-item label="移动速度">
76
+ <el-slider
77
+ v-model="roamingSettingsDraft.moveSpeed"
78
+ :min="roamingSettingsLimits.moveSpeed.min"
79
+ :max="roamingSettingsLimits.moveSpeed.max"
80
+ :step="roamingSettingsLimits.moveSpeed.step"
81
+ ></el-slider>
82
+ </el-form-item>
83
+ <el-form-item label="鼠标速度">
84
+ <el-slider
85
+ v-model="roamingSettingsDraft.mouseSpeed"
86
+ :min="roamingSettingsLimits.mouseSpeed.min"
87
+ :max="roamingSettingsLimits.mouseSpeed.max"
88
+ :step="roamingSettingsLimits.mouseSpeed.step"
89
+ ></el-slider>
90
+ </el-form-item>
91
+ </el-form>
92
+ <div slot="footer" class="roaming-settings-footer">
93
+ <el-button size="small" @click="cancelRoamingSettings">取消</el-button>
94
+ <el-button size="small" type="primary" @click="confirmRoamingSettings">确定</el-button>
95
+ </div>
96
+ </el-dialog>
25
97
  </div>
26
98
  </template>
27
99
  <script>
28
100
  var singleFrameTime = 1 / 30;
29
101
  var dragThreshold = 5; // 拖拽阈值,像素单位
102
+ const ORIENTATION_AXIS_VIEWPORT_SIZE = 120;
103
+ const ORIENTATION_AXIS_VIEWPORT_MARGIN = 16;
104
+ const ORIENTATION_AXIS_LENGTH = 0.68;
105
+ const ORIENTATION_AXIS_HEAD_LENGTH = 0.21;
106
+ const ORIENTATION_AXIS_HEAD_RADIUS = 0.12;
107
+ const ORIENTATION_AXIS_SHAFT_RADIUS = 0.045;
108
+ const ORIENTATION_AXIS_LABEL_OFFSET = 0.12;
109
+ const ORIENTATION_AXIS_LABEL_SIZE = 0.3;
30
110
 
31
111
  // camera-controls 相机限位参数,见 setCameraConfig / applyCameraBoundary
32
112
  // minDistance 是相机到目标点的最小距离,取场景最大边长的千分之一,并夹在上下限之间。
@@ -38,7 +118,7 @@ var orbitMinDistanceCeiling = 50;
38
118
  // 相机离目标点的最远距离,按场景包围盒对角线倍数给
39
119
  var orbitMaxDistanceRatio = 3;
40
120
  // 目标点可活动范围相对场景包围盒的外扩倍数(1 表示不超出包围盒)
41
- var orbitBoundaryRatio = 1.5;
121
+ var orbitBoundaryRatio = 3;
42
122
  // 左键切换旋转中心时,命中点与当前目标点的最小位移(占场景最大边长比例),小于该值视为重复点击
43
123
  var orbitPointMoveRatio = 1e-4;
44
124
 
@@ -154,6 +234,14 @@ var clippingMesh = [],
154
234
 
155
235
  const FIRST_PERSON_GROUND_HEIGHT = 1680;
156
236
  const FIRST_PERSON_POINTER_SPEED = 0.3;
237
+ const THIRD_PERSON_MODEL_NAME = '__third_person_roaming_avatar__';
238
+ const THIRD_PERSON_CAMERA_DISTANCE = 12500;
239
+ const THIRD_PERSON_CAMERA_MIN_DISTANCE_RATIO = 1;
240
+ const THIRD_PERSON_CAMERA_MAX_DISTANCE_MULTIPLIER = 10;
241
+ const THIRD_PERSON_CAMERA_HEIGHT_RATIO = 0.65;
242
+ const THIRD_PERSON_COLLISION_RADIUS_RATIO = 0.16;
243
+ const THIRD_PERSON_GRAVITY = 9800;
244
+ const THIRD_PERSON_GROUND_SNAP_DISTANCE = 12;
157
245
 
158
246
  var removeSpeed = 1000,
159
247
  upSpeed = 200; //控制器移动速度 , //控制跳起时的速度
@@ -162,6 +250,40 @@ var roamConfig = {
162
250
  speed: 0, // 最大值为3
163
251
  name: '',
164
252
  };
253
+ const ROAMING_SETTINGS_DEFAULTS = Object.freeze({
254
+ perspective: 'first-person',
255
+ height: 168,
256
+ collisionEnabled: true,
257
+ moveSpeed: 1,
258
+ mouseSpeed: 1,
259
+ });
260
+ const ROAMING_SETTINGS_LIMITS = Object.freeze({
261
+ height: { min: 0.1, max: 250 },
262
+ moveSpeed: { min: 0.01, max: 5, step: 0.01 },
263
+ mouseSpeed: { min: 0.01, max: 5, step: 0.01 },
264
+ });
265
+
266
+ function createRoamingSettings(config) {
267
+ const source = config && typeof config === 'object' ? config : {};
268
+ const settings = { ...ROAMING_SETTINGS_DEFAULTS };
269
+ if (source.perspective === 'first-person' || source.perspective === 'third-person') {
270
+ settings.perspective = source.perspective;
271
+ }
272
+ if (typeof source.collisionEnabled === 'boolean') {
273
+ settings.collisionEnabled = source.collisionEnabled;
274
+ }
275
+ ['height', 'moveSpeed', 'mouseSpeed'].forEach(key => {
276
+ const value = Number(source[key]);
277
+ if (Number.isFinite(value)) {
278
+ settings[key] = value;
279
+ }
280
+ });
281
+ return settings;
282
+ }
283
+
284
+ function copyRoamingSettings(config) {
285
+ return { ...createRoamingSettings(config) };
286
+ }
165
287
  var guiParams = {
166
288
  x轴: 0,
167
289
  y轴: 0,
@@ -281,6 +403,8 @@ import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js';
281
403
  import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
282
404
  import Stats from 'three/examples/jsm/libs/stats.module.js';
283
405
  import { OBB } from 'three/examples/jsm/math/OBB.js';
406
+ import { MeshLineGeometry, MeshLineMaterial } from 'meshline';
407
+ import workerModelUrl from './assets/worker.glb';
284
408
  // import boxJson from './box.json';
285
409
  import {
286
410
  StreamLoader,
@@ -293,8 +417,8 @@ import { EDITOR_EVENT, SCENE_NODE_TYPE, TRANSFORM_MODE } from '@/utils/threejs/e
293
417
  import { isCustomRoot, isTransformAttachableObject } from '@/utils/threejs/editor/scene-helpers.js';
294
418
  import { onContextHandle } from './component/context';
295
419
 
296
- const isDebug = process.env.NODE_ENV !== 'production' || process.env.VUE_APP_IS_WATCH === true;
297
- // const isDebug = false;
420
+ let isDebug = process.env.NODE_ENV !== 'production' || process.env.VUE_APP_IS_WATCH === true;
421
+ // isDebug = false;
298
422
  const COLLISION_CONTACT_EPSILON = 0.1;
299
423
  const COLLISION_OBB_AXIS_EPSILON = 1e-8;
300
424
  const COLLISION_CONNECTION_DISTANCE_TOLERANCE = 1;
@@ -380,6 +504,11 @@ export default {
380
504
  visible: false,
381
505
  text: '加载中',
382
506
  },
507
+ roamingSettingsVisible: false,
508
+ roamingSettingsDraft: null,
509
+ roamingSettings: createRoamingSettings(),
510
+ roamingSettingsCloseReason: '',
511
+ roamingSettingsLimits: ROAMING_SETTINGS_LIMITS,
383
512
  isolateMode: false,
384
513
  // collisionObbDebugEnabled: isDebug,
385
514
  sceneBoundingBoxDebugEnabled: isDebug,
@@ -428,6 +557,9 @@ export default {
428
557
  'velocity',
429
558
  'direction',
430
559
  'clock',
560
+ 'firstPersonMouseSpeed',
561
+ 'firstPersonCollisionEnabled',
562
+ 'firstPersonActorHeight',
431
563
  'pointControls',
432
564
  'threeMeasure',
433
565
  'modelGroup',
@@ -443,7 +575,14 @@ export default {
443
575
  'centeringDebounceTimer',
444
576
  'sceneBoundingBox',
445
577
  'sceneBoundingBoxHelper',
578
+ 'globalClippingBoundsHelper',
446
579
  'collisionObbDebugGroup',
580
+ '_thirdPersonState',
581
+ '_thirdPersonMixer',
582
+ '_thirdPersonPointerLockHandler',
583
+ '_thirdPersonMouseMoveHandler',
584
+ '_thirdPersonWheelHandler',
585
+ '_thirdPersonPointerErrorHandler',
447
586
  'occlusionBoundsDebugGroup',
448
587
  ];
449
588
  arr.forEach(item => {
@@ -653,6 +792,9 @@ export default {
653
792
  this.initSceneCommandService();
654
793
  this.initCamera();
655
794
  this.initControl();
795
+ if (this._orientationAxisEnabled !== false) {
796
+ this.initOrientationAxis();
797
+ }
656
798
  this.initTransformControls();
657
799
  this.initPostProcessing();
658
800
  // 初始化统一的相机事件监听
@@ -673,6 +815,187 @@ export default {
673
815
  this.destroyScene();
674
816
  },
675
817
  methods: {
818
+ setOrientationAxisEnabled(enabled = true) {
819
+ this._orientationAxisEnabled = enabled !== false;
820
+ if (!this._orientationAxisEnabled) {
821
+ this.disposeOrientationAxis();
822
+ return;
823
+ }
824
+
825
+ if (!this._orientationAxisState && this.THREE) {
826
+ this.initOrientationAxis();
827
+ }
828
+ },
829
+ initOrientationAxis() {
830
+ const THREE = this.THREE;
831
+ const state = {
832
+ scene: new THREE.Scene(),
833
+ camera: new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10),
834
+ group: new THREE.Group(),
835
+ inverseCameraQuaternion: new THREE.Quaternion(),
836
+ renderSize: new THREE.Vector2(),
837
+ previousViewport: new THREE.Vector4(),
838
+ previousScissor: new THREE.Vector4(),
839
+ resources: {
840
+ geometries: [],
841
+ materials: [],
842
+ textures: [],
843
+ },
844
+ };
845
+ this._orientationAxisState = state;
846
+
847
+ state.camera.position.set(0, 0, 3);
848
+ state.camera.lookAt(0, 0, 0);
849
+
850
+ const axes = [
851
+ { label: 'X', color: 0xff453a, direction: new THREE.Vector3(1, 0, 0) },
852
+ { label: 'Y', color: 0x30d158, direction: new THREE.Vector3(0, 1, 0) },
853
+ { label: 'Z', color: 0x0a84ff, direction: new THREE.Vector3(0, 0, 1) },
854
+ ];
855
+ const up = new THREE.Vector3(0, 1, 0);
856
+ const shaftLength = ORIENTATION_AXIS_LENGTH - ORIENTATION_AXIS_HEAD_LENGTH;
857
+
858
+ axes.forEach(axis => {
859
+ const material = new THREE.MeshBasicMaterial({
860
+ color: axis.color,
861
+ depthTest: true,
862
+ depthWrite: true,
863
+ toneMapped: false,
864
+ });
865
+ const shaftGeometry = new THREE.CylinderGeometry(
866
+ ORIENTATION_AXIS_SHAFT_RADIUS,
867
+ ORIENTATION_AXIS_SHAFT_RADIUS,
868
+ shaftLength,
869
+ 8
870
+ );
871
+ const headGeometry = new THREE.ConeGeometry(
872
+ ORIENTATION_AXIS_HEAD_RADIUS,
873
+ ORIENTATION_AXIS_HEAD_LENGTH,
874
+ 10
875
+ );
876
+ const orientation = new THREE.Quaternion().setFromUnitVectors(up, axis.direction);
877
+ const shaft = new THREE.Mesh(shaftGeometry, material);
878
+ const head = new THREE.Mesh(headGeometry, material);
879
+
880
+ shaft.position.copy(axis.direction).multiplyScalar(shaftLength / 2);
881
+ head.position
882
+ .copy(axis.direction)
883
+ .multiplyScalar(ORIENTATION_AXIS_LENGTH - ORIENTATION_AXIS_HEAD_LENGTH / 2);
884
+ shaft.quaternion.copy(orientation);
885
+ head.quaternion.copy(orientation);
886
+ state.group.add(shaft, head);
887
+
888
+ state.resources.geometries.push(shaftGeometry, headGeometry);
889
+ state.resources.materials.push(material);
890
+
891
+ const texture = this.createOrientationAxisLabelTexture(axis.label);
892
+ if (texture) {
893
+ const labelMaterial = new THREE.SpriteMaterial({
894
+ map: texture,
895
+ transparent: true,
896
+ depthTest: true,
897
+ depthWrite: false,
898
+ toneMapped: false,
899
+ });
900
+ const label = new THREE.Sprite(labelMaterial);
901
+ label.position
902
+ .copy(axis.direction)
903
+ .multiplyScalar(ORIENTATION_AXIS_LENGTH + ORIENTATION_AXIS_LABEL_OFFSET);
904
+ label.scale.set(ORIENTATION_AXIS_LABEL_SIZE, ORIENTATION_AXIS_LABEL_SIZE, 1);
905
+ label.renderOrder = 2;
906
+ state.group.add(label);
907
+ state.resources.materials.push(labelMaterial);
908
+ state.resources.textures.push(texture);
909
+ }
910
+ });
911
+
912
+ state.scene.add(state.group);
913
+ },
914
+ createOrientationAxisLabelTexture(text) {
915
+ const canvas = document.createElement('canvas');
916
+ canvas.width = 128;
917
+ canvas.height = 128;
918
+ const context = canvas.getContext('2d');
919
+ if (!context) return null;
920
+
921
+ context.font = '700 88px Arial, sans-serif';
922
+ context.textAlign = 'center';
923
+ context.textBaseline = 'middle';
924
+ context.lineJoin = 'round';
925
+ context.lineWidth = 9;
926
+ context.strokeStyle = 'rgba(255, 255, 255, 0.9)';
927
+ context.strokeText(text, 64, 68);
928
+ context.fillStyle = '#17202a';
929
+ context.fillText(text, 64, 68);
930
+
931
+ const texture = new this.THREE.CanvasTexture(canvas);
932
+ texture.colorSpace = this.THREE.SRGBColorSpace;
933
+ texture.generateMipmaps = false;
934
+ texture.minFilter = this.THREE.LinearFilter;
935
+ texture.magFilter = this.THREE.LinearFilter;
936
+ texture.needsUpdate = true;
937
+ return texture;
938
+ },
939
+ syncOrientationAxis() {
940
+ const state = this._orientationAxisState;
941
+ if (!state || !this.camera) return;
942
+
943
+ this.camera.getWorldQuaternion(state.inverseCameraQuaternion);
944
+ state.group.quaternion.copy(state.inverseCameraQuaternion).invert();
945
+ },
946
+ renderOrientationAxis() {
947
+ const renderer = this.renderer;
948
+ const state = this._orientationAxisState;
949
+ if (this._orientationAxisEnabled === false || !renderer || !state) return;
950
+
951
+ renderer.getSize(state.renderSize);
952
+ const viewportSize = Math.min(
953
+ ORIENTATION_AXIS_VIEWPORT_SIZE,
954
+ state.renderSize.x,
955
+ state.renderSize.y
956
+ );
957
+ if (!viewportSize) return;
958
+
959
+ const x = Math.max(
960
+ 0,
961
+ Math.min(ORIENTATION_AXIS_VIEWPORT_MARGIN, state.renderSize.x - viewportSize)
962
+ );
963
+ const y = Math.max(
964
+ 0,
965
+ Math.min(ORIENTATION_AXIS_VIEWPORT_MARGIN, state.renderSize.y - viewportSize)
966
+ );
967
+ const previousRenderTarget = renderer.getRenderTarget();
968
+ const previousScissorTest = renderer.getScissorTest();
969
+ const previousAutoClear = renderer.autoClear;
970
+ renderer.getViewport(state.previousViewport);
971
+ renderer.getScissor(state.previousScissor);
972
+
973
+ try {
974
+ renderer.setRenderTarget(null);
975
+ renderer.setViewport(x, y, viewportSize, viewportSize);
976
+ renderer.setScissor(x, y, viewportSize, viewportSize);
977
+ renderer.setScissorTest(true);
978
+ renderer.autoClear = false;
979
+ renderer.clearDepth();
980
+ renderer.render(state.scene, state.camera);
981
+ } finally {
982
+ renderer.autoClear = previousAutoClear;
983
+ renderer.setRenderTarget(previousRenderTarget);
984
+ renderer.setViewport(state.previousViewport);
985
+ renderer.setScissor(state.previousScissor);
986
+ renderer.setScissorTest(previousScissorTest);
987
+ }
988
+ },
989
+ disposeOrientationAxis() {
990
+ const state = this._orientationAxisState;
991
+ if (!state) return;
992
+
993
+ state.resources.geometries.forEach(geometry => geometry.dispose());
994
+ state.resources.materials.forEach(material => material.dispose());
995
+ state.resources.textures.forEach(texture => texture.dispose());
996
+ state.scene.clear();
997
+ this._orientationAxisState = null;
998
+ },
676
999
  bindScenePointerEvents() {
677
1000
  if (!this.renderer || !this.renderer.domElement) return;
678
1001
  this.renderer.domElement.addEventListener('pointerup', this.mouseClick, true);
@@ -3725,6 +4048,8 @@ export default {
3725
4048
  }
3726
4049
  }
3727
4050
  cam.far = newFar;
4051
+ // cam.far = 200;
4052
+ // cam.near = 0.1;
3728
4053
  cam.updateProjectionMatrix();
3729
4054
  },
3730
4055
  /**
@@ -5301,14 +5626,17 @@ export default {
5301
5626
  * @param {Function} [config.boxLoadingText] - 自定义进度文案函数
5302
5627
  * @param {Function} [config.onBoxProgress] - 包围盒下载进度回调
5303
5628
  * @param {boolean} [config.showModelLoading=true] - 是否显示模型加载(裁剪 → 请求 → 分批上屏)进度提示
5629
+ * @param {boolean} [config.showOrientationAxis=true] - 是否显示左下角 XYZ 坐标轴
5304
5630
  * @param {string} [config.modelLoadingText="加载中"] - 自定义模型加载进度文案
5305
5631
  * @param {Function} [config.onStreamProgress] - 几何流式加载阶段回调(透传 StreamLoader 事件)
5306
5632
  */
5307
5633
  init(config = {}) {
5308
5634
  console.log('init');
5635
+ const { showOrientationAxis, ...streamLoaderConfig } = config;
5636
+ this.setOrientationAxisEnabled(showOrientationAxis !== false);
5309
5637
  this.setCameraControlMoveObserverConfig(config);
5310
5638
  this.setProgressiveRenderingConfig(config);
5311
- this.initStreamLoader(config.modelApi, config);
5639
+ this.initStreamLoader(config.modelApi, streamLoaderConfig);
5312
5640
  },
5313
5641
 
5314
5642
  /**
@@ -6920,7 +7248,7 @@ export default {
6920
7248
  source => {
6921
7249
  // 漫游、第一人称移动、普通视角拖拽/缩放中,使用节流策略避免持续运动被防抖吞掉。
6922
7250
  // 避免在持续运动中因防抖重置定时器而无法触发
6923
- if (source === 'roam' || source === 'firstPersonMove') {
7251
+ if (source === 'roam' || source === 'firstPersonMove' || source === 'thirdPersonMove') {
6924
7252
  return { type: 'throttle', limit: 1000 };
6925
7253
  }
6926
7254
  if (source === 'controlMove') {
@@ -7077,13 +7405,13 @@ export default {
7077
7405
  drawModel(data, color = '', meshNameConfig = {}, options = {}) {
7078
7406
  // 只打印规模摘要:直接打印 data 会让 DevTools 控制台长期持有整批数据(含全量材质字典)的引用,
7079
7407
  // 分批加载期间这些引用在控制台清空前无法回收,会抬高内存高水位。
7408
+ // data = require('./mock.json');
7080
7409
  console.log('drawModel', {
7081
7410
  material: data.material || [],
7082
7411
  mergeMaterial: data.mergeMaterial || [],
7083
7412
  primitive: data.primitive || [],
7084
7413
  mesh: data.mesh || [],
7085
7414
  });
7086
- // data = require('./mock.json');
7087
7415
  // 使用新的分帧加载方法,提供进度回调
7088
7416
  return this.drawModelWithBatchLoading(
7089
7417
  data,
@@ -7520,6 +7848,7 @@ export default {
7520
7848
  this.outlinePass.setSize(width, height);
7521
7849
  }
7522
7850
  this.labelRenderer.setSize(width, height);
7851
+ this.updateGlobalClippingBoundsResolution(width, height);
7523
7852
  // this.timeRender()
7524
7853
  // 这里也要更新测量
7525
7854
  if (this.threeMeasure) {
@@ -7755,6 +8084,31 @@ export default {
7755
8084
  }
7756
8085
  }
7757
8086
  },
8087
+ captureBusinessVisibilityState() {
8088
+ const modelState = this.noObserver
8089
+ ? this.noObserver.modelStateManager
8090
+ : this.modelStateManager;
8091
+ return {
8092
+ defaultBusinessVisible: modelState.defaultBusinessVisible,
8093
+ businessVisibilityOverrides: new Map(modelState.businessVisibilityOverrides),
8094
+ };
8095
+ },
8096
+ restoreBusinessVisibilityState(state) {
8097
+ if (!state) return;
8098
+ const modelState = this.noObserver
8099
+ ? this.noObserver.modelStateManager
8100
+ : this.modelStateManager;
8101
+ modelState.defaultBusinessVisible = state.defaultBusinessVisible;
8102
+ modelState.businessVisibilityOverrides = new Map(state.businessVisibilityOverrides || []);
8103
+ this.syncBusinessVisibilityByIds(
8104
+ Array.from(new Set([
8105
+ ...Array.from((this._boxIndex && this._boxIndex.keys()) || []),
8106
+ ...Array.from(modelState.businessVisibilityOverrides.keys()),
8107
+ ]))
8108
+ );
8109
+ this.notifyCameraChange();
8110
+ if (this._boxIndex) this.buildOctreeFromBoxIndex();
8111
+ },
7758
8112
  setAllModelVisible(visible) {
7759
8113
  const isVisible = Boolean(visible);
7760
8114
  const modelState = this.noObserver
@@ -8513,7 +8867,9 @@ export default {
8513
8867
  },
8514
8868
  // 销毁场景 释放内存
8515
8869
  destroyScene() {
8870
+ this.endThirdPersonRoaming();
8516
8871
  cancelAnimationFrame(this.animateId);
8872
+ this.disposeOrientationAxis();
8517
8873
  this.disposeTransformControls();
8518
8874
 
8519
8875
  if (this.sceneCommandEventDisposers.length > 0) {
@@ -8589,6 +8945,7 @@ export default {
8589
8945
  this.resetBusinessVisibilityState();
8590
8946
  this.hideCollisionObbDebug();
8591
8947
  this.hideOcclusionBoundsDebug();
8948
+ this.removeGlobalClippingBoundsHelper();
8592
8949
 
8593
8950
  if (this.scene) {
8594
8951
  this.removeTraverse();
@@ -8733,6 +9090,565 @@ export default {
8733
9090
  this.clock = new this.THREE.Clock();
8734
9091
  }
8735
9092
  },
9093
+ openRoamingSettings(config) {
9094
+ const currentConfig = config ? { ...this.roamingSettings, ...config } : this.roamingSettings;
9095
+ this.roamingSettingsDraft = copyRoamingSettings(currentConfig);
9096
+ this.roamingSettingsCloseReason = '';
9097
+ this.roamingSettingsVisible = true;
9098
+ return this.getRoamingConfig();
9099
+ },
9100
+ getRoamingConfig() {
9101
+ return copyRoamingSettings(this.roamingSettings);
9102
+ },
9103
+ closeRoamingSettings() {
9104
+ if (!this.roamingSettingsVisible) return;
9105
+ this.roamingSettingsCloseReason = 'api';
9106
+ this.roamingSettingsVisible = false;
9107
+ },
9108
+ cancelRoamingSettings() {
9109
+ if (!this.roamingSettingsVisible) return;
9110
+ this.roamingSettingsCloseReason = 'cancel';
9111
+ this.roamingSettingsVisible = false;
9112
+ },
9113
+ handleRoamingSettingsDialogClose() {
9114
+ if (!this.roamingSettingsCloseReason) {
9115
+ this.roamingSettingsCloseReason = 'cancel';
9116
+ }
9117
+ if (this.roamingSettingsCloseReason !== 'confirm') {
9118
+ this.$emit('roaming-settings-cancel');
9119
+ }
9120
+ this.roamingSettingsDraft = null;
9121
+ this.roamingSettingsCloseReason = '';
9122
+ },
9123
+ showRoamingSettingsError(message) {
9124
+ this.$emit('roaming-settings-error', { message });
9125
+ if (this.$message && typeof this.$message.warning === 'function') {
9126
+ this.$message.warning(message);
9127
+ }
9128
+ },
9129
+ validateRoamingSettings() {
9130
+ const draft = this.roamingSettingsDraft;
9131
+ if (!draft) return null;
9132
+
9133
+ const height = Number(draft.height);
9134
+ const moveSpeed = Number(draft.moveSpeed);
9135
+ const mouseSpeed = Number(draft.mouseSpeed);
9136
+ const {
9137
+ height: heightLimit,
9138
+ moveSpeed: moveSpeedLimit,
9139
+ mouseSpeed: mouseSpeedLimit,
9140
+ } = ROAMING_SETTINGS_LIMITS;
9141
+
9142
+ if (draft.height === '' || draft.height === null || !Number.isFinite(height)) {
9143
+ this.showRoamingSettingsError('请输入人物身高');
9144
+ return null;
9145
+ }
9146
+ if (height < heightLimit.min || height > heightLimit.max) {
9147
+ this.showRoamingSettingsError(
9148
+ `人物身高需在 ${heightLimit.min}-${heightLimit.max} cm 范围内`
9149
+ );
9150
+ return null;
9151
+ }
9152
+ if (draft.moveSpeed === '' || draft.moveSpeed === null || !Number.isFinite(moveSpeed)) {
9153
+ this.showRoamingSettingsError('请输入移动速度');
9154
+ return null;
9155
+ }
9156
+ if (moveSpeed < moveSpeedLimit.min || moveSpeed > moveSpeedLimit.max) {
9157
+ this.showRoamingSettingsError(
9158
+ `移动速度需在 ${moveSpeedLimit.min}-${moveSpeedLimit.max} 范围内`
9159
+ );
9160
+ return null;
9161
+ }
9162
+ if (draft.mouseSpeed === '' || draft.mouseSpeed === null || !Number.isFinite(mouseSpeed)) {
9163
+ this.showRoamingSettingsError('请输入鼠标速度');
9164
+ return null;
9165
+ }
9166
+ if (mouseSpeed < mouseSpeedLimit.min || mouseSpeed > mouseSpeedLimit.max) {
9167
+ this.showRoamingSettingsError(
9168
+ `鼠标速度需在 ${mouseSpeedLimit.min}-${mouseSpeedLimit.max} 范围内`
9169
+ );
9170
+ return null;
9171
+ }
9172
+ if (draft.perspective !== 'first-person' && draft.perspective !== 'third-person') {
9173
+ this.showRoamingSettingsError('请选择漫游视角');
9174
+ return null;
9175
+ }
9176
+ if (typeof draft.collisionEnabled !== 'boolean') {
9177
+ this.showRoamingSettingsError('请选择是否设置障碍');
9178
+ return null;
9179
+ }
9180
+
9181
+ return {
9182
+ perspective: draft.perspective,
9183
+ height,
9184
+ collisionEnabled: draft.collisionEnabled,
9185
+ moveSpeed,
9186
+ mouseSpeed,
9187
+ };
9188
+ },
9189
+ confirmRoamingSettings() {
9190
+ const settings = this.validateRoamingSettings();
9191
+ if (!settings) return;
9192
+
9193
+ this.roamingSettings = settings;
9194
+ this.roamingSettingsCloseReason = 'confirm';
9195
+ this.roamingSettingsVisible = false;
9196
+ this.$emit('roaming-settings-confirm', this.getRoamingConfig());
9197
+ },
9198
+ startRoaming() {
9199
+ const settings = this.getRoamingConfig();
9200
+ if (settings.perspective === 'third-person') {
9201
+ return this.startThirdPersonRoaming(settings);
9202
+ }
9203
+ this.endThirdPersonRoaming();
9204
+ this.startFirstPer({
9205
+ moveSpeed: 8000 * settings.moveSpeed,
9206
+ jumpSpeed: 500,
9207
+ mouseSpeed: settings.mouseSpeed,
9208
+ height: settings.height,
9209
+ collisionEnabled: settings.collisionEnabled,
9210
+ });
9211
+ this.$emit('roaming-start', settings);
9212
+ return true;
9213
+ },
9214
+ isRoamingActive() {
9215
+ return !!(this.firstPerSign || this._thirdPersonState);
9216
+ },
9217
+ startThirdPersonRoaming(settings) {
9218
+ if (!this.scene || !this.camera || !this.cameraControls || !this.renderer) {
9219
+ this.showRoamingSettingsError('模型场景尚未准备完成,暂时无法开始第三人称漫游');
9220
+ return false;
9221
+ }
9222
+ this.endThirdPersonRoaming();
9223
+ if (this.pointControls && this.pointControls.isLocked) {
9224
+ this.pointControls.unlock();
9225
+ }
9226
+
9227
+ const config = copyRoamingSettings(settings || this.roamingSettings);
9228
+ const sceneBox = this.sceneBoundingBox;
9229
+ if (!sceneBox || sceneBox.isEmpty()) {
9230
+ this.showRoamingSettingsError('模型空间范围尚未就绪,暂时无法开始第三人称漫游');
9231
+ return false;
9232
+ }
9233
+
9234
+ const target = new this.THREE.Vector3();
9235
+ if (typeof this.cameraControls.getTarget === 'function') {
9236
+ this.cameraControls.getTarget(target);
9237
+ } else if (this.cameraControls._target) {
9238
+ target.copy(this.cameraControls._target);
9239
+ } else {
9240
+ this.camera.getWorldDirection(target);
9241
+ target.multiplyScalar(800).add(this.camera.position);
9242
+ }
9243
+ const direction = this.camera.getWorldDirection(new this.THREE.Vector3());
9244
+ const yaw = Math.atan2(-direction.x, -direction.z);
9245
+ const actorHeight = config.height * 10;
9246
+ const actorPosition = new this.THREE.Vector3(
9247
+ target.x,
9248
+ this.getThirdPersonSpawnHeight(target.x, target.z, sceneBox),
9249
+ target.z
9250
+ );
9251
+
9252
+ this._thirdPersonState = {
9253
+ config,
9254
+ moveSpeed: this.getFirstPersonMoveSpeed(8000 * config.moveSpeed),
9255
+ actorPosition,
9256
+ actorHeight,
9257
+ verticalVelocity: 0,
9258
+ yaw,
9259
+ // worker.glb 的默认正面朝向与相机方向一致,进入第三人称时旋转 180 度背对镜头。
9260
+ avatarYaw: yaw + Math.PI,
9261
+ pitch: 0.22,
9262
+ cameraDistance: (THIRD_PERSON_CAMERA_DISTANCE * config.height) / 168,
9263
+ cameraPosition: this.camera.position.clone(),
9264
+ cameraTarget: target.clone(),
9265
+ avatar: null,
9266
+ walkAction: null,
9267
+ walking: false,
9268
+ loading: true,
9269
+ pointerLocked: false,
9270
+ };
9271
+ this.cameraControls.enabled = false;
9272
+ const initialDistance = this._thirdPersonState.cameraDistance;
9273
+ const initialPitch = this._thirdPersonState.pitch;
9274
+ const initialPosition = new this.THREE.Vector3(
9275
+ target.x + Math.sin(yaw) * Math.cos(initialPitch) * initialDistance,
9276
+ target.y + Math.sin(initialPitch) * initialDistance + actorHeight * 0.15,
9277
+ target.z + Math.cos(yaw) * Math.cos(initialPitch) * initialDistance
9278
+ );
9279
+ this._thirdPersonState.cameraPosition.copy(initialPosition);
9280
+ this.camera.position.copy(initialPosition);
9281
+ this.camera.lookAt(target);
9282
+ this.camera.updateMatrixWorld();
9283
+ this.detachTransformControls();
9284
+ this.unbindScenePointerEvents();
9285
+ document.addEventListener('keydown', this.onThirdPersonKeyDown, false);
9286
+ document.addEventListener('keyup', this.onThirdPersonKeyUp, false);
9287
+ this._thirdPersonMouseMoveHandler = event => this.onThirdPersonMouseMove(event);
9288
+ this._thirdPersonWheelHandler = event => this.onThirdPersonWheel(event);
9289
+ this._thirdPersonPointerLockHandler = () => this.handleThirdPersonPointerLockChange();
9290
+ this._thirdPersonPointerErrorHandler = () => {
9291
+ this.showRoamingSettingsError('浏览器未能锁定鼠标,请点击模型视口后重试');
9292
+ this.endThirdPersonRoaming();
9293
+ };
9294
+ document.addEventListener('mousemove', this._thirdPersonMouseMoveHandler, false);
9295
+ if (this.renderer.domElement) {
9296
+ this.renderer.domElement.addEventListener('wheel', this._thirdPersonWheelHandler, {
9297
+ capture: true,
9298
+ passive: false,
9299
+ });
9300
+ }
9301
+ document.addEventListener('pointerlockchange', this._thirdPersonPointerLockHandler, false);
9302
+ document.addEventListener('pointerlockerror', this._thirdPersonPointerErrorHandler, false);
9303
+ if (this.renderer.domElement && this.renderer.domElement.requestPointerLock) {
9304
+ try {
9305
+ this.renderer.domElement.requestPointerLock();
9306
+ } catch (error) {
9307
+ // 部分浏览器会拒绝自动锁定,仍可通过键盘移动并在视口内点击后获得鼠标控制。
9308
+ }
9309
+ }
9310
+
9311
+ const loader = new GLTFLoader();
9312
+ loader.load(
9313
+ workerModelUrl,
9314
+ gltf => {
9315
+ const state = this._thirdPersonState;
9316
+ if (!state || !this.scene) return;
9317
+ const avatar = gltf.scene;
9318
+ avatar.name = THIRD_PERSON_MODEL_NAME;
9319
+ avatar.updateMatrixWorld(true);
9320
+ const originalBox = new this.THREE.Box3().setFromObject(avatar);
9321
+ const originalHeight = Math.max(originalBox.max.y - originalBox.min.y, 1e-6);
9322
+ const scale = state.actorHeight / originalHeight;
9323
+ avatar.scale.setScalar(scale);
9324
+ state.avatarYOffset = -originalBox.min.y * scale;
9325
+ avatar.position.y = state.avatarYOffset + state.actorPosition.y;
9326
+ avatar.position.x = state.actorPosition.x;
9327
+ avatar.position.z = state.actorPosition.z;
9328
+ avatar.rotation.y = state.avatarYaw;
9329
+ avatar.traverse(object => {
9330
+ if (object.isMesh) {
9331
+ object.frustumCulled = false;
9332
+ object.userData.cull = false;
9333
+ }
9334
+ });
9335
+ state.avatar = avatar;
9336
+ state.loading = false;
9337
+ this.scene.add(avatar);
9338
+ if (gltf.animations && gltf.animations.length) {
9339
+ this._thirdPersonMixer = new this.THREE.AnimationMixer(avatar);
9340
+ state.walkAction = this._thirdPersonMixer.clipAction(gltf.animations[0]);
9341
+ state.walkAction.clampWhenFinished = false;
9342
+ state.walkAction.enabled = true;
9343
+ state.walkAction.stop();
9344
+ }
9345
+ this.$emit('roaming-start', this.getRoamingConfig());
9346
+ },
9347
+ undefined,
9348
+ error => {
9349
+ console.error('第三人称人物模型加载失败', error);
9350
+ this.showRoamingSettingsError('第三人称人物模型加载失败,请检查内置资源');
9351
+ this.endThirdPersonRoaming();
9352
+ }
9353
+ );
9354
+ return true;
9355
+ },
9356
+ endRoaming() {
9357
+ if (this._thirdPersonState) {
9358
+ this.endThirdPersonRoaming();
9359
+ return;
9360
+ }
9361
+ const wasFirstPersonActive = this.firstPerSign;
9362
+ const wasPointerLocked = !!(this.pointControls && this.pointControls.isLocked);
9363
+ if (this.pointControls) {
9364
+ if (this.pointControls.isLocked) this.pointControls.unlock();
9365
+ if (this.pointControls.object && this.pointControls.object.parent === this.scene) {
9366
+ this.scene.remove(this.pointControls.object);
9367
+ }
9368
+ }
9369
+ window.removeEventListener('keydown', this.onKeyDown);
9370
+ window.removeEventListener('keyup', this.onKeyUp);
9371
+ this.pointControls = null;
9372
+ if (this.cameraControls) this.cameraControls.enabled = true;
9373
+ if (this.renderer && this.renderer.domElement) this.bindScenePointerEvents();
9374
+ this.firstPerSign = false;
9375
+ if (!wasFirstPersonActive || !wasPointerLocked) this.$emit('roaming-end');
9376
+ },
9377
+ endThirdPersonRoaming() {
9378
+ const state = this._thirdPersonState;
9379
+ if (!state) return;
9380
+ document.removeEventListener('keydown', this.onThirdPersonKeyDown, false);
9381
+ document.removeEventListener('keyup', this.onThirdPersonKeyUp, false);
9382
+ if (this._thirdPersonMouseMoveHandler) {
9383
+ document.removeEventListener('mousemove', this._thirdPersonMouseMoveHandler, false);
9384
+ }
9385
+ if (this._thirdPersonWheelHandler && this.renderer && this.renderer.domElement) {
9386
+ this.renderer.domElement.removeEventListener('wheel', this._thirdPersonWheelHandler, true);
9387
+ }
9388
+ if (this._thirdPersonPointerLockHandler) {
9389
+ document.removeEventListener(
9390
+ 'pointerlockchange',
9391
+ this._thirdPersonPointerLockHandler,
9392
+ false
9393
+ );
9394
+ }
9395
+ if (this._thirdPersonPointerErrorHandler) {
9396
+ document.removeEventListener(
9397
+ 'pointerlockerror',
9398
+ this._thirdPersonPointerErrorHandler,
9399
+ false
9400
+ );
9401
+ }
9402
+ const avatar = state.avatar;
9403
+ if (avatar && avatar.parent) avatar.parent.remove(avatar);
9404
+ if (avatar) {
9405
+ avatar.traverse(object => {
9406
+ if (!object.isMesh) return;
9407
+ object.geometry && object.geometry.dispose();
9408
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
9409
+ materials.forEach(material => material && material.dispose());
9410
+ });
9411
+ }
9412
+ if (this._thirdPersonMixer && avatar) this._thirdPersonMixer.uncacheRoot(avatar);
9413
+ this._thirdPersonMixer = null;
9414
+ this._thirdPersonMouseMoveHandler = null;
9415
+ this._thirdPersonWheelHandler = null;
9416
+ this._thirdPersonPointerLockHandler = null;
9417
+ this._thirdPersonPointerErrorHandler = null;
9418
+ if (this.cameraControls && this.camera && state.cameraTarget) {
9419
+ this.cameraControls.setLookAt(
9420
+ this.camera.position.x,
9421
+ this.camera.position.y,
9422
+ this.camera.position.z,
9423
+ state.cameraTarget.x,
9424
+ state.cameraTarget.y,
9425
+ state.cameraTarget.z,
9426
+ false
9427
+ );
9428
+ }
9429
+ this._thirdPersonState = null;
9430
+ if (this.cameraControls) this.cameraControls.enabled = true;
9431
+ if (
9432
+ this.renderer &&
9433
+ this.renderer.domElement &&
9434
+ document.pointerLockElement === this.renderer.domElement
9435
+ ) {
9436
+ document.exitPointerLock && document.exitPointerLock();
9437
+ }
9438
+ if (this.renderer && this.renderer.domElement) this.bindScenePointerEvents();
9439
+ if (this.scene) this.ensureTransformSelectionValid();
9440
+ this.$emit('roaming-end');
9441
+ },
9442
+ onThirdPersonKeyDown(event) {
9443
+ if (!this._thirdPersonState || !event) return;
9444
+ if (![37, 38, 39, 40, 65, 68, 83, 87].includes(event.keyCode)) return;
9445
+ event.preventDefault();
9446
+ if (event.keyCode === 38 || event.keyCode === 87) this.moveForward = true;
9447
+ if (event.keyCode === 40 || event.keyCode === 83) this.moveBackward = true;
9448
+ if (event.keyCode === 37 || event.keyCode === 65) this.moveLeft = true;
9449
+ if (event.keyCode === 39 || event.keyCode === 68) this.moveRight = true;
9450
+ },
9451
+ onThirdPersonKeyUp(event) {
9452
+ if (!this._thirdPersonState || !event) return;
9453
+ this.onKeyUp(event);
9454
+ },
9455
+ onThirdPersonMouseMove(event) {
9456
+ const state = this._thirdPersonState;
9457
+ if (!state || document.pointerLockElement !== this.renderer.domElement) return;
9458
+ const sensitivity = state.config.mouseSpeed * 0.0018;
9459
+ state.yaw -= (event.movementX || 0) * sensitivity;
9460
+ state.pitch = Math.max(
9461
+ -0.2,
9462
+ Math.min(0.8, state.pitch + (event.movementY || 0) * sensitivity)
9463
+ );
9464
+ },
9465
+ onThirdPersonWheel(event) {
9466
+ const state = this._thirdPersonState;
9467
+ if (!state || !event) return;
9468
+ event.preventDefault();
9469
+ event.stopPropagation();
9470
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
9471
+ const baseDistance = (THIRD_PERSON_CAMERA_DISTANCE * state.config.height) / 168;
9472
+ const minDistance = Math.max(state.actorHeight * THIRD_PERSON_CAMERA_MIN_DISTANCE_RATIO, 20);
9473
+ const maxDistance = baseDistance * THIRD_PERSON_CAMERA_MAX_DISTANCE_MULTIPLIER;
9474
+ const delta = Math.max(-240, Math.min(240, event.deltaY || 0));
9475
+ state.cameraDistance = Math.max(
9476
+ minDistance,
9477
+ Math.min(maxDistance, state.cameraDistance + delta * Math.max(baseDistance * 0.001, 0.5))
9478
+ );
9479
+ },
9480
+ handleThirdPersonPointerLockChange() {
9481
+ const state = this._thirdPersonState;
9482
+ if (!state || !this.renderer || !this.renderer.domElement) return;
9483
+ if (document.pointerLockElement === this.renderer.domElement) {
9484
+ state.pointerLocked = true;
9485
+ } else if (state.pointerLocked) this.endThirdPersonRoaming();
9486
+ },
9487
+ canMoveThirdPersonTo(position) {
9488
+ const state = this._thirdPersonState;
9489
+ if (!state || !state.config.collisionEnabled) return true;
9490
+ if (!this._octree && this._boxIndex && this._boxIndex.size) this.buildOctreeFromBoxIndex();
9491
+ const radius = state.actorHeight * THIRD_PERSON_COLLISION_RADIUS_RATIO;
9492
+ const bodyBox = new this.THREE.Box3(
9493
+ new this.THREE.Vector3(
9494
+ position.x - radius,
9495
+ position.y + state.actorHeight * 0.12,
9496
+ position.z - radius
9497
+ ),
9498
+ new this.THREE.Vector3(
9499
+ position.x + radius,
9500
+ position.y + state.actorHeight * 0.9,
9501
+ position.z + radius
9502
+ )
9503
+ );
9504
+ const candidates = this._queryOctreeByBox(bodyBox);
9505
+ return !candidates.some(
9506
+ item => item && item.box && item.box.userData && item.box.userData.visible !== false
9507
+ );
9508
+ },
9509
+ getThirdPersonSpawnHeight(x, z, sceneBox) {
9510
+ if (!sceneBox || sceneBox.isEmpty() || !this.scene) return 0;
9511
+ const origin = new this.THREE.Vector3(x, sceneBox.max.y + 1, z);
9512
+ const raycaster = new this.THREE.Raycaster(
9513
+ origin,
9514
+ new this.THREE.Vector3(0, -1, 0),
9515
+ 0,
9516
+ Math.max(sceneBox.max.y - sceneBox.min.y + 2, 1)
9517
+ );
9518
+ const intersections = raycaster.intersectObjects(this.scene.children, true);
9519
+ return intersections.length ? intersections[0].point.y : sceneBox.min.y;
9520
+ },
9521
+ getThirdPersonCameraDistance(state, target, desiredPosition) {
9522
+ if (!this._octree && this._boxIndex && this._boxIndex.size) this.buildOctreeFromBoxIndex();
9523
+ const segmentBox = new this.THREE.Box3().setFromPoints([target, desiredPosition]);
9524
+ const radius = Math.max(state.actorHeight * 0.035, 10);
9525
+ segmentBox.expandByScalar(radius);
9526
+ const ray = new this.THREE.Ray(target, desiredPosition.clone().sub(target).normalize());
9527
+ const desiredDistance = target.distanceTo(desiredPosition);
9528
+ let nearestDistance = desiredDistance;
9529
+ this._queryOctreeByBox(segmentBox).forEach(item => {
9530
+ if (!item || !item.box || (item.box.userData && item.box.userData.visible === false))
9531
+ return;
9532
+ const expandedBox = item.box.clone().expandByScalar(radius);
9533
+ const hit = ray.intersectBox(expandedBox, new this.THREE.Vector3());
9534
+ if (hit) {
9535
+ const distance = target.distanceTo(hit);
9536
+ if (distance > radius && distance < nearestDistance) nearestDistance = distance;
9537
+ }
9538
+ });
9539
+ return Math.max(state.actorHeight * 0.65, nearestDistance - radius);
9540
+ },
9541
+ updateThirdPersonGrounding(state, delta) {
9542
+ if (!state || !state.actorPosition || !this.scene || !this.sceneBoundingBox) return;
9543
+ const position = state.actorPosition;
9544
+ const sceneBox = this.sceneBoundingBox;
9545
+ const rayOriginY = position.y + THIRD_PERSON_GROUND_SNAP_DISTANCE;
9546
+ const rayDistance = Math.max(rayOriginY - sceneBox.min.y, THIRD_PERSON_GROUND_SNAP_DISTANCE);
9547
+ const raycaster = new this.THREE.Raycaster(
9548
+ new this.THREE.Vector3(position.x, rayOriginY, position.z),
9549
+ new this.THREE.Vector3(0, -1, 0),
9550
+ 0,
9551
+ rayDistance
9552
+ );
9553
+ const groundObjects = this.scene.children.filter(
9554
+ object => object && object.name !== THIRD_PERSON_MODEL_NAME
9555
+ );
9556
+ const intersections = raycaster.intersectObjects(groundObjects, true);
9557
+ const groundHit = intersections[0];
9558
+ const groundHeight = groundHit ? groundHit.point.y : sceneBox.min.y;
9559
+ const distanceToGround = position.y - groundHeight;
9560
+ const snapDistance = Math.max(THIRD_PERSON_GROUND_SNAP_DISTANCE, state.actorHeight * 0.015);
9561
+
9562
+ if (distanceToGround <= snapDistance && state.verticalVelocity <= 0) {
9563
+ position.y = groundHeight;
9564
+ state.verticalVelocity = 0;
9565
+ return;
9566
+ }
9567
+
9568
+ state.verticalVelocity = Math.max(
9569
+ state.verticalVelocity - THIRD_PERSON_GRAVITY * delta,
9570
+ -THIRD_PERSON_GRAVITY
9571
+ );
9572
+ const nextY = position.y + state.verticalVelocity * delta;
9573
+ if (nextY <= groundHeight) {
9574
+ position.y = groundHeight;
9575
+ state.verticalVelocity = 0;
9576
+ } else {
9577
+ position.y = nextY;
9578
+ }
9579
+ },
9580
+ updateThirdPersonRoaming(delta) {
9581
+ const state = this._thirdPersonState;
9582
+ if (!state || !state.avatar || state.loading) return;
9583
+ const elapsed = Math.min(Math.max(delta || 0, 0), 0.05);
9584
+ const forward = Number(this.moveForward) - Number(this.moveBackward);
9585
+ const lateral = Number(this.moveRight) - Number(this.moveLeft);
9586
+ const moving = forward !== 0 || lateral !== 0;
9587
+ if (state.walkAction) {
9588
+ if (moving && !state.walking) {
9589
+ state.walkAction.reset().play();
9590
+ state.walking = true;
9591
+ } else if (!moving && state.walking) {
9592
+ state.walkAction.stop();
9593
+ state.walking = false;
9594
+ }
9595
+ }
9596
+ const length = Math.hypot(forward, lateral) || 1;
9597
+ const speed = state.moveSpeed;
9598
+ const step = speed * elapsed;
9599
+ const moveX =
9600
+ ((-Math.sin(state.yaw) * forward + Math.cos(state.yaw) * lateral) / length) * step;
9601
+ const moveZ =
9602
+ ((-Math.cos(state.yaw) * forward - Math.sin(state.yaw) * lateral) / length) * step;
9603
+ const nextX = state.actorPosition.clone();
9604
+ nextX.x += moveX;
9605
+ if (this.canMoveThirdPersonTo(nextX)) state.actorPosition.x = nextX.x;
9606
+ const nextZ = state.actorPosition.clone();
9607
+ nextZ.z += moveZ;
9608
+ if (this.canMoveThirdPersonTo(nextZ)) state.actorPosition.z = nextZ.z;
9609
+ this.updateThirdPersonGrounding(state, elapsed);
9610
+
9611
+ const avatar = state.avatar;
9612
+ avatar.position.set(
9613
+ state.actorPosition.x,
9614
+ state.actorPosition.y + (state.avatarYOffset || 0),
9615
+ state.actorPosition.z
9616
+ );
9617
+ if (forward !== 0 || lateral !== 0) {
9618
+ // 模型正面沿 +Z,人物朝向实际的相机相对移动方向。
9619
+ state.avatarYaw = Math.atan2(moveX, moveZ);
9620
+ }
9621
+ avatar.rotation.y = state.avatarYaw;
9622
+ if (this._thirdPersonMixer) this._thirdPersonMixer.update(elapsed);
9623
+
9624
+ const target = new this.THREE.Vector3(
9625
+ state.actorPosition.x,
9626
+ state.actorPosition.y + state.actorHeight * THIRD_PERSON_CAMERA_HEIGHT_RATIO,
9627
+ state.actorPosition.z
9628
+ );
9629
+ const horizontalDistance = state.cameraDistance;
9630
+ const pitch = state.pitch;
9631
+ const desiredPosition = new this.THREE.Vector3(
9632
+ target.x + Math.sin(state.yaw) * Math.cos(pitch) * horizontalDistance,
9633
+ target.y + Math.sin(pitch) * horizontalDistance + state.actorHeight * 0.15,
9634
+ target.z + Math.cos(state.yaw) * Math.cos(pitch) * horizontalDistance
9635
+ );
9636
+ const safeDistance = this.getThirdPersonCameraDistance(state, target, desiredPosition);
9637
+ const desiredOffset = desiredPosition
9638
+ .clone()
9639
+ .sub(target)
9640
+ .normalize()
9641
+ .multiplyScalar(safeDistance);
9642
+ desiredPosition.copy(target).add(desiredOffset);
9643
+ const smoothing = 1 - Math.exp(-50 * elapsed);
9644
+ state.cameraPosition.lerp(desiredPosition, smoothing);
9645
+ state.cameraTarget.lerp(target, smoothing);
9646
+ this.camera.position.copy(state.cameraPosition);
9647
+ this.camera.lookAt(state.cameraTarget);
9648
+ this.camera.updateMatrixWorld();
9649
+ if (typeof this._cameraChangeObserver === 'function')
9650
+ this._cameraChangeObserver('thirdPersonMove');
9651
+ },
8736
9652
  //
8737
9653
  // 开启漫游
8738
9654
  /*
@@ -8861,6 +9777,7 @@ export default {
8861
9777
  if (userData.transformControlHelper === true) return true;
8862
9778
  if (userData.collisionObbDebugHelper === true) return true;
8863
9779
  if (userData.occlusionBoundsDebugHelper === true) return true;
9780
+ if (userData.globalClippingBoundsHelper === true) return true;
8864
9781
  if (object.isCamera || object.isLight) return true;
8865
9782
  if (typeof object.type === 'string' && /Helper$/i.test(object.type)) return true;
8866
9783
  return object.type === 'CSS2DObject' || object.type === 'TransformControlsRoot';
@@ -8882,6 +9799,138 @@ export default {
8882
9799
  });
8883
9800
  return box3.isEmpty() ? null : box3;
8884
9801
  },
9802
+ getGlobalClippingBoundsSegments(bounds) {
9803
+ const min = bounds.min;
9804
+ const max = bounds.max;
9805
+ return [
9806
+ [
9807
+ min.x,
9808
+ min.y,
9809
+ min.z,
9810
+ max.x,
9811
+ min.y,
9812
+ min.z,
9813
+ max.x,
9814
+ max.y,
9815
+ min.z,
9816
+ min.x,
9817
+ max.y,
9818
+ min.z,
9819
+ min.x,
9820
+ min.y,
9821
+ min.z,
9822
+ ],
9823
+ [
9824
+ min.x,
9825
+ min.y,
9826
+ max.z,
9827
+ max.x,
9828
+ min.y,
9829
+ max.z,
9830
+ max.x,
9831
+ max.y,
9832
+ max.z,
9833
+ min.x,
9834
+ max.y,
9835
+ max.z,
9836
+ min.x,
9837
+ min.y,
9838
+ max.z,
9839
+ ],
9840
+ [min.x, min.y, min.z, min.x, min.y, max.z],
9841
+ [max.x, min.y, min.z, max.x, min.y, max.z],
9842
+ [max.x, max.y, min.z, max.x, max.y, max.z],
9843
+ [min.x, max.y, min.z, min.x, max.y, max.z],
9844
+ ];
9845
+ },
9846
+ updateGlobalClippingBoundsResolution(width, height) {
9847
+ const helper = this.globalClippingBoundsHelper;
9848
+ const firstLine = helper && helper.children && helper.children[0];
9849
+ const resolution = firstLine && firstLine.material && firstLine.material.resolution;
9850
+ if (!resolution) return;
9851
+ if ((!width || !height) && this.renderer && this.renderer.domElement) {
9852
+ const rect = this.renderer.domElement.getBoundingClientRect();
9853
+ width = rect.width;
9854
+ height = rect.height;
9855
+ }
9856
+ if (width > 0 && height > 0) resolution.set(width, height);
9857
+ },
9858
+ removeGlobalClippingBoundsHelper() {
9859
+ const helper = this.globalClippingBoundsHelper;
9860
+ if (!helper) return;
9861
+ if (this.scene) this.scene.remove(helper);
9862
+ helper.children.forEach(line => {
9863
+ line.geometry && line.geometry.dispose && line.geometry.dispose();
9864
+ });
9865
+ const material = helper.children[0] && helper.children[0].material;
9866
+ material && material.dispose && material.dispose();
9867
+ this.globalClippingBoundsHelper = null;
9868
+ },
9869
+ updateGlobalClippingBoundsHelper() {
9870
+ const planes = this.renderer && this.renderer.clippingPlanes;
9871
+ if (!this.scene || !planes || planes.length < 6) {
9872
+ this.removeGlobalClippingBoundsHelper();
9873
+ return false;
9874
+ }
9875
+
9876
+ const constants = planes.slice(0, 6).map(plane => plane && plane.constant);
9877
+ if (constants.some(value => !Number.isFinite(value))) {
9878
+ this.removeGlobalClippingBoundsHelper();
9879
+ return false;
9880
+ }
9881
+
9882
+ const bounds = new this.THREE.Box3(
9883
+ new this.THREE.Vector3(-constants[3], -constants[4], -constants[5]),
9884
+ new this.THREE.Vector3(constants[0], constants[1], constants[2])
9885
+ );
9886
+ if (bounds.isEmpty()) {
9887
+ this.removeGlobalClippingBoundsHelper();
9888
+ return false;
9889
+ }
9890
+
9891
+ const segments = this.getGlobalClippingBoundsSegments(bounds);
9892
+ let helper = this.globalClippingBoundsHelper;
9893
+ if (!helper) {
9894
+ const material = new MeshLineMaterial({
9895
+ color: 0x999999,
9896
+ lineWidth: 15,
9897
+ sizeAttenuation: 0,
9898
+ transparent: true,
9899
+ opacity: 1,
9900
+ depthTest: false,
9901
+ depthWrite: false,
9902
+ depthFunc: this.THREE.LessEqualDepth,
9903
+ // polygonOffset: true,
9904
+ // polygonOffsetFactor: -1,
9905
+ // polygonOffsetUnits: -1,
9906
+ side: this.THREE.DoubleSide,
9907
+ toneMapped: false,
9908
+ resolution: new this.THREE.Vector2(1, 1),
9909
+ });
9910
+ helper = new this.THREE.Group();
9911
+ helper.name = 'globalClippingBoundsHelper';
9912
+ helper.userData.globalClippingBoundsHelper = true;
9913
+ segments.forEach(points => {
9914
+ const geometry = new MeshLineGeometry();
9915
+ geometry.setPoints(points);
9916
+ const line = new this.THREE.Mesh(geometry, material);
9917
+ line.renderOrder = 1001;
9918
+ line.frustumCulled = false;
9919
+ line.raycast = function () {};
9920
+ helper.add(line);
9921
+ });
9922
+ this.scene.add(helper);
9923
+ this.globalClippingBoundsHelper = helper;
9924
+ } else {
9925
+ helper.children.forEach((line, index) => {
9926
+ line.geometry.setPoints(segments[index]);
9927
+ });
9928
+ if (helper.parent !== this.scene) this.scene.add(helper);
9929
+ }
9930
+ this.updateGlobalClippingBoundsResolution();
9931
+ helper.visible = !this.guiParams || this.guiParams['显示剖切线框'] !== false;
9932
+ return true;
9933
+ },
8885
9934
  // 设置全局整体剖切
8886
9935
  /*
8887
9936
  先开启模型全局剖切模式, 会返回剖切值的最大最小值
@@ -8890,6 +9939,7 @@ export default {
8890
9939
  setGlobalClipping(flag = true) {
8891
9940
  const box3 = this.getGlobalClippingBox();
8892
9941
  if (!box3) {
9942
+ this.removeGlobalClippingBoundsHelper();
8893
9943
  return {
8894
9944
  min: null,
8895
9945
  max: null,
@@ -8952,6 +10002,7 @@ export default {
8952
10002
  clippingPlanes
8953
10003
  );
8954
10004
  }
10005
+ this.updateGlobalClippingBoundsHelper();
8955
10006
  return {
8956
10007
  min: min,
8957
10008
  max: max,
@@ -8971,6 +10022,7 @@ export default {
8971
10022
  for (const key in params) {
8972
10023
  this.renderer.clippingPlanes[axis[key]].constant = params[key];
8973
10024
  }
10025
+ this.updateGlobalClippingBoundsHelper();
8974
10026
  // this.timeRender()
8975
10027
  },
8976
10028
  // 取消全局剖切
@@ -8985,6 +10037,7 @@ export default {
8985
10037
  };
8986
10038
  this.clippingPlanesConstants = null;
8987
10039
  this.renderer.clippingPlanes = Object.freeze([]);
10040
+ this.removeGlobalClippingBoundsHelper();
8988
10041
  if (this.gui) {
8989
10042
  this.gui.destroy();
8990
10043
  this.gui = null;
@@ -9116,6 +10169,9 @@ export default {
9116
10169
  '-y轴': yRange.min,
9117
10170
  '-z轴': zRange.min,
9118
10171
  };
10172
+ if (title === '全局剖切') {
10173
+ this.guiParams['显示剖切线框'] = true;
10174
+ }
9119
10175
 
9120
10176
  const initialConstants = [
9121
10177
  xRange.max,
@@ -9147,7 +10203,22 @@ export default {
9147
10203
  this.closeGlobalClipping();
9148
10204
  });
9149
10205
  this.gui.domElement.querySelector('.title').appendChild(closeButton);
10206
+ const wireframeToggle = this.gui
10207
+ .add(this.guiParams, '显示剖切线框')
10208
+ .name('显示剖切线框')
10209
+ .listen();
10210
+ wireframeToggle.domElement.classList.add('global-clipping-wireframe-toggle');
10211
+ const wireframeToggleWidget = wireframeToggle.domElement.querySelector('.widget');
10212
+ if (wireframeToggleWidget) wireframeToggleWidget.classList.remove('widget');
10213
+ wireframeToggle.onChange(visible => {
10214
+ if (this.globalClippingBoundsHelper) {
10215
+ this.globalClippingBoundsHelper.visible = visible;
10216
+ }
10217
+ });
9150
10218
  }
10219
+ const updateGlobalClippingBounds = () => {
10220
+ if (title === '全局剖切') this.updateGlobalClippingBoundsHelper();
10221
+ };
9151
10222
  this.gui
9152
10223
  .add(this.guiParams, 'x轴')
9153
10224
  .step(0.001)
@@ -9156,6 +10227,7 @@ export default {
9156
10227
  .onChange(d => {
9157
10228
  objClipp1[0].constant = d;
9158
10229
  objClipp2 && (objClipp2[0].constant = d);
10230
+ updateGlobalClippingBounds();
9159
10231
  });
9160
10232
  this.gui
9161
10233
  .add(this.guiParams, '-x轴')
@@ -9165,6 +10237,7 @@ export default {
9165
10237
  .onChange(d => {
9166
10238
  objClipp1[3].constant = -d;
9167
10239
  objClipp2 && (objClipp2[3].constant = -d);
10240
+ updateGlobalClippingBounds();
9168
10241
  });
9169
10242
  this.gui
9170
10243
  .add(this.guiParams, 'y轴')
@@ -9174,6 +10247,7 @@ export default {
9174
10247
  .onChange(d => {
9175
10248
  objClipp1[2].constant = d;
9176
10249
  objClipp2 && (objClipp2[2].constant = d);
10250
+ updateGlobalClippingBounds();
9177
10251
  });
9178
10252
  this.gui
9179
10253
  .add(this.guiParams, '-y轴')
@@ -9183,6 +10257,7 @@ export default {
9183
10257
  .onChange(d => {
9184
10258
  objClipp1[5].constant = -d;
9185
10259
  objClipp2 && (objClipp2[5].constant = -d);
10260
+ updateGlobalClippingBounds();
9186
10261
  });
9187
10262
  this.gui
9188
10263
  .add(this.guiParams, 'z轴')
@@ -9192,6 +10267,7 @@ export default {
9192
10267
  .onChange(d => {
9193
10268
  objClipp1[1].constant = d;
9194
10269
  objClipp2 && (objClipp2[1].constant = d);
10270
+ updateGlobalClippingBounds();
9195
10271
  });
9196
10272
  this.gui
9197
10273
  .add(this.guiParams, '-z轴')
@@ -9201,6 +10277,7 @@ export default {
9201
10277
  .onChange(d => {
9202
10278
  objClipp1[4].constant = -d;
9203
10279
  objClipp2 && (objClipp2[4].constant = -d);
10280
+ updateGlobalClippingBounds();
9204
10281
  });
9205
10282
  },
9206
10283
  getFirstPersonMoveSpeed(customMoveSpeed) {
@@ -9265,9 +10342,17 @@ export default {
9265
10342
  },
9266
10343
  // 开启第一视角
9267
10344
  startFirstPer(options) {
9268
- let { moveSpeed = 200, jumpSpeed = 200 } = options || {};
9269
- this.removeSpeed = 3500;
9270
- // this.removeSpeed = this.getFirstPersonMoveSpeed(moveSpeed);
10345
+ let {
10346
+ moveSpeed = 200,
10347
+ jumpSpeed = 200,
10348
+ mouseSpeed = 1,
10349
+ collisionEnabled = true,
10350
+ height = 168,
10351
+ } = options || {};
10352
+ this.firstPersonMouseSpeed = mouseSpeed;
10353
+ this.firstPersonCollisionEnabled = collisionEnabled;
10354
+ this.firstPersonActorHeight = height * 10;
10355
+ this.removeSpeed = this.getFirstPersonMoveSpeed(moveSpeed);
9271
10356
  const jumpConfig = this.getFirstPersonJumpConfig(jumpSpeed);
9272
10357
  this.upSpeed = jumpConfig.jumpSpeed;
9273
10358
  this.firstPersonGravity = jumpConfig.gravity;
@@ -9293,7 +10378,8 @@ export default {
9293
10378
  this.camera.rotation.z = 0;
9294
10379
 
9295
10380
  this.pointControls = new PointerLockControls(this.camera, this.renderer.domElement);
9296
- this.pointControls.pointerSpeed = FIRST_PERSON_POINTER_SPEED;
10381
+ this.pointControls.pointerSpeed =
10382
+ FIRST_PERSON_POINTER_SPEED * (this.firstPersonMouseSpeed || 1);
9297
10383
  this.pointControls.lock();
9298
10384
  if (!this._onFirstPersonChange) {
9299
10385
  this._onFirstPersonChange = () => {
@@ -9320,6 +10406,7 @@ export default {
9320
10406
  });
9321
10407
  // 解锁
9322
10408
  this.pointControls.addEventListener('unlock', () => {
10409
+ const wasFirstPersonActive = this.firstPerSign;
9323
10410
  this.firstPerSign = false;
9324
10411
  if (this.cameraControls) {
9325
10412
  this.cameraControls.enabled = true;
@@ -9350,6 +10437,7 @@ export default {
9350
10437
  if (typeof this._cameraChangeObserver === 'function') {
9351
10438
  this._cameraChangeObserver('firstPersonUnlock');
9352
10439
  }
10440
+ if (wasFirstPersonActive) this.$emit('roaming-end');
9353
10441
  });
9354
10442
  if (this.scene) this.scene.add(this.pointControls.object);
9355
10443
  }, 10);
@@ -9391,9 +10479,24 @@ export default {
9391
10479
  this.velocity.y = Math.max(0, this.velocity.y);
9392
10480
  this.canJump = true;
9393
10481
  }
9394
- // 根据速度值移动控制器
9395
- this.pointControls.moveRight(-this.velocity.x * delta);
9396
- this.pointControls.moveForward(-this.velocity.z * delta);
10482
+ // 分轴应用移动,避免合并位移的碰撞结果阻断可通行的另一个方向。
10483
+ const yaw = control.rotation.y;
10484
+ const rightDistance = -this.velocity.x * delta;
10485
+ const forwardDistance = -this.velocity.z * delta;
10486
+ const nextRightPosition = control.position.clone();
10487
+ nextRightPosition.x += Math.cos(yaw) * rightDistance;
10488
+ nextRightPosition.z += -Math.sin(yaw) * rightDistance;
10489
+ if (this.canMoveFirstPersonTo(nextRightPosition, control.position)) {
10490
+ this.pointControls.moveRight(rightDistance);
10491
+ } else this.velocity.x = 0;
10492
+
10493
+ const nextForwardPosition = control.position.clone();
10494
+ // moveForward 的正距离沿相机前方移动,预判位置必须与实际位移方向一致。
10495
+ nextForwardPosition.x -= Math.sin(yaw) * forwardDistance;
10496
+ nextForwardPosition.z -= Math.cos(yaw) * forwardDistance;
10497
+ if (this.canMoveFirstPersonTo(nextForwardPosition, control.position)) {
10498
+ this.pointControls.moveForward(forwardDistance);
10499
+ } else this.velocity.z = 0;
9397
10500
  control.position.y += this.velocity.y * delta;
9398
10501
  // 保证控制器的y轴在平面上
9399
10502
  if (control.position.y < FIRST_PERSON_GROUND_HEIGHT) {
@@ -9418,6 +10521,59 @@ export default {
9418
10521
  }
9419
10522
  }
9420
10523
  },
10524
+ canMoveFirstPersonTo(position, currentPosition) {
10525
+ if (!this.firstPersonCollisionEnabled) return true;
10526
+ if (!this._octree && this._boxIndex && this._boxIndex.size) this.buildOctreeFromBoxIndex();
10527
+ if (!this._octree) return true;
10528
+ const radius = Math.max((this.firstPersonActorHeight || 1700) * 0.16, 20);
10529
+ const clearance = Math.max((this.firstPersonActorHeight || 1700) * 0.015, 4);
10530
+ const height = this.firstPersonActorHeight || 1700;
10531
+ const collisionRadius = radius + clearance;
10532
+ const makeActorBox = actorPosition =>
10533
+ new this.THREE.Box3(
10534
+ new this.THREE.Vector3(
10535
+ actorPosition.x - collisionRadius,
10536
+ actorPosition.y - height * 0.9,
10537
+ actorPosition.z - collisionRadius
10538
+ ),
10539
+ new this.THREE.Vector3(
10540
+ actorPosition.x + collisionRadius,
10541
+ actorPosition.y - height * 0.1,
10542
+ actorPosition.z + collisionRadius
10543
+ )
10544
+ );
10545
+ const start = currentPosition || position;
10546
+ const startActorBox = makeActorBox(start);
10547
+ const startActorObb = new OBB().fromBox3(startActorBox);
10548
+ const distance = Math.hypot(position.x - start.x, position.z - start.z);
10549
+ const sampleStep = Math.max(radius * 0.35, 5);
10550
+ const sampleCount = Math.max(1, Math.ceil(distance / sampleStep));
10551
+
10552
+ for (let sample = 1; sample <= sampleCount; sample++) {
10553
+ const progress = sample / sampleCount;
10554
+ const samplePosition = new this.THREE.Vector3(
10555
+ start.x + (position.x - start.x) * progress,
10556
+ position.y,
10557
+ start.z + (position.z - start.z) * progress
10558
+ );
10559
+ const actorBox = makeActorBox(samplePosition);
10560
+ const candidates = this._queryOctreeByBox(actorBox);
10561
+ const actorObb = new OBB().fromBox3(actorBox);
10562
+ const blocked = candidates.some(item => {
10563
+ const obstacleBox = item && item.box;
10564
+ if (!this._isCollisionBoxAvailable(obstacleBox)) return false;
10565
+ if (!obstacleBox.intersectsBox(actorBox)) return false;
10566
+ return this._getCollisionObbs(obstacleBox).some(obstacleObb => {
10567
+ const penetration = this._getObbMinPenetration(actorObb, obstacleObb);
10568
+ const initialPenetration = this._getObbMinPenetration(startActorObb, obstacleObb);
10569
+ if (initialPenetration <= 0) return penetration > 0;
10570
+ return penetration > initialPenetration + 0.1;
10571
+ });
10572
+ });
10573
+ if (blocked) return false;
10574
+ }
10575
+ return true;
10576
+ },
9421
10577
  // 键盘监听事件
9422
10578
  onKeyDown(event) {
9423
10579
  if (!event.keyCode) return;
@@ -9918,6 +11074,8 @@ export default {
9918
11074
  // 计算相机近裁面到包围盒当前面的距离
9919
11075
  this.cameraTrack();
9920
11076
  this.firstPerspective();
11077
+ this.updateThirdPersonRoaming(delta);
11078
+ this.syncOrientationAxis();
9921
11079
  // 相机姿态(含漫游/第一人称写入)确定后,再刷新远裁剪面
9922
11080
  this.syncCameraProjection();
9923
11081
  if (this.scenePass) {
@@ -9966,6 +11124,7 @@ export default {
9966
11124
  // interactionFrameCount = 0;
9967
11125
  // }
9968
11126
  }
11127
+ this.renderOrientationAxis();
9969
11128
 
9970
11129
  // 3. 按频率输出(避免每帧都log)
9971
11130
  // if (frameCounter++ % LOG_INTERVAL === 0) {
@@ -10897,6 +12056,98 @@ export default {
10897
12056
  position: relative;
10898
12057
  cursor: pointer;
10899
12058
  }
12059
+
12060
+ ::v-deep .roaming-settings-dialog .el-dialog__header {
12061
+ padding: 22px 32px 16px;
12062
+ border-bottom: 1px solid #ebeef5;
12063
+ }
12064
+
12065
+ ::v-deep .roaming-settings-dialog .el-dialog__title {
12066
+ color: #303133;
12067
+ font-size: 18px;
12068
+ font-weight: 600;
12069
+ }
12070
+
12071
+ ::v-deep .roaming-settings-dialog .el-dialog__body {
12072
+ padding: 28px 32px 8px;
12073
+ }
12074
+
12075
+ ::v-deep .roaming-settings-dialog .el-dialog__footer {
12076
+ padding: 14px 32px 24px;
12077
+ border-top: 1px solid #ebeef5;
12078
+ }
12079
+
12080
+ .roaming-settings-form {
12081
+ ::v-deep .el-form-item__label {
12082
+ color: #303133;
12083
+ font-size: 14px;
12084
+ font-weight: 400;
12085
+ }
12086
+
12087
+ .roaming-settings-perspective {
12088
+ display: flex;
12089
+ align-items: center;
12090
+ min-height: 32px;
12091
+ gap: 72px;
12092
+
12093
+ ::v-deep .el-radio {
12094
+ margin-right: 0;
12095
+ color: #303133;
12096
+ font-size: 14px;
12097
+ }
12098
+
12099
+ ::v-deep .el-radio__label {
12100
+ padding-left: 8px;
12101
+ font-size: 14px;
12102
+ }
12103
+ }
12104
+
12105
+ .roaming-settings-section-title {
12106
+ margin: 8px 0 20px;
12107
+ color: #303133;
12108
+ font-size: 15px;
12109
+ font-weight: 600;
12110
+ }
12111
+
12112
+ .roaming-settings-height,
12113
+ .roaming-settings-control {
12114
+ width: 280px;
12115
+ }
12116
+
12117
+ .el-slider {
12118
+ width: 330px;
12119
+ padding: 0 8px 0 0;
12120
+ }
12121
+
12122
+ .el-form-item {
12123
+ margin-bottom: 22px;
12124
+ }
12125
+ }
12126
+
12127
+ ::v-deep .roaming-settings-dialog {
12128
+ max-width: calc(100vw - 32px);
12129
+ }
12130
+
12131
+ ::v-deep .roaming-settings-dialog .el-dialog__close {
12132
+ font-size: 18px;
12133
+ }
12134
+
12135
+ ::v-deep .roaming-settings-dialog .el-dialog__body {
12136
+ padding: 10px 20px;
12137
+ }
12138
+
12139
+ .roaming-settings-footer {
12140
+ display: flex;
12141
+ justify-content: flex-end;
12142
+ gap: 8px;
12143
+
12144
+ ::v-deep .el-button {
12145
+ min-width: 76px;
12146
+ height: 34px;
12147
+ font-size: 14px;
12148
+ }
12149
+ }
12150
+
10900
12151
  ::v-deep .tips-label {
10901
12152
  width: 60px;
10902
12153
  color: #000;
@@ -11086,9 +12337,16 @@ export default {
11086
12337
  }
11087
12338
  }
11088
12339
  </style>
12340
+
12341
+ <style lang="scss">
12342
+ /* el-dialog 开启 append-to-body 后挂载到 body,使用非 scoped 选择器确保样式生效。 */
12343
+ .roaming-settings-dialog .el-dialog__body {
12344
+ padding: 10px 20px;
12345
+ }
12346
+ </style>
11089
12347
  <style>
11090
12348
  /* 自定义lil-gui样式 - 浅色主题 */
11091
- .lil-this.gui {
12349
+ .lil-gui {
11092
12350
  background: rgba(255, 255, 255, 0.95) !important;
11093
12351
  border: 1px solid #e0e0e0 !important;
11094
12352
  border-radius: 8px !important;
@@ -11097,7 +12355,7 @@ export default {
11097
12355
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
11098
12356
  }
11099
12357
 
11100
- .lil-this.gui .title {
12358
+ .lil-gui .title {
11101
12359
  background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%) !important;
11102
12360
  color: #495057 !important;
11103
12361
  border-bottom: 1px solid #dee2e6 !important;
@@ -11106,50 +12364,70 @@ export default {
11106
12364
  border-radius: 8px 8px 0 0 !important;
11107
12365
  }
11108
12366
 
11109
- .lil-this.gui .controller {
12367
+ .lil-gui .controller {
11110
12368
  border-bottom: 1px solid #f1f3f4 !important;
11111
12369
  background: transparent !important;
11112
12370
  }
11113
12371
 
11114
- .lil-this.gui .controller:last-child {
12372
+ .lil-gui .controller:last-child {
11115
12373
  border-bottom: none !important;
11116
12374
  }
11117
12375
 
11118
- .lil-this.gui .controller .name {
12376
+ .lil-gui .controller .name {
11119
12377
  color: #495057 !important;
11120
12378
  font-weight: 500 !important;
11121
12379
  font-size: 12px !important;
11122
12380
  }
11123
12381
 
11124
- .lil-this.gui .controller .widget {
12382
+ .lil-gui .controller .widget {
11125
12383
  background: #f8f9fa !important;
11126
12384
  border: 1px solid #ced4da !important;
11127
12385
  border-radius: 4px !important;
11128
12386
  color: #495057 !important;
11129
12387
  }
11130
12388
 
11131
- .lil-this.gui .controller .widget:hover {
12389
+ .lil-gui .controller .widget:hover {
11132
12390
  border-color: #80bdff !important;
11133
12391
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
11134
12392
  }
11135
12393
 
11136
- .lil-this.gui .controller .widget:focus {
12394
+ .lil-gui .controller .widget:focus {
11137
12395
  border-color: #80bdff !important;
11138
12396
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
11139
12397
  outline: none !important;
11140
12398
  }
11141
12399
 
11142
- .lil-this.gui .controller input[type='range'] {
12400
+ .lil-gui .global-clipping-wireframe-toggle label {
12401
+ display: flex !important;
12402
+ align-items: center !important;
12403
+ justify-content: flex-start !important;
12404
+ background: transparent !important;
12405
+ border: 0 !important;
12406
+ box-shadow: none !important;
12407
+ }
12408
+
12409
+ .lil-gui .global-clipping-wireframe-toggle input[type='checkbox'] {
12410
+ width: 16px !important;
12411
+ height: 16px !important;
12412
+ margin: 0 8px 0 0 !important;
12413
+ padding: 0 !important;
12414
+ color: #495057 !important;
12415
+ border: 1px solid !important;
12416
+ accent-color: #1684d8;
12417
+ cursor: pointer;
12418
+ }
12419
+
12420
+ .lil-gui .controller input[type='range'] {
11143
12421
  background: #e9ecef !important;
11144
12422
  height: 4px !important;
11145
12423
  -webkit-appearance: none !important;
11146
12424
  appearance: none !important;
11147
12425
  border-radius: 2px !important;
11148
12426
  }
11149
- .lil-this.gui .controller.number .fill {
12427
+ .lil-gui .controller.number .fill {
11150
12428
  border-right: solid #008de9;
11151
12429
  }
11152
- .lil-this.gui .controller input[type='range']::-webkit-slider-thumb {
12430
+ .lil-gui .controller input[type='range']::-webkit-slider-thumb {
11153
12431
  background: #007bff !important;
11154
12432
  border: 2px solid #ffffff !important;
11155
12433
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important;
@@ -11161,7 +12439,7 @@ export default {
11161
12439
  cursor: pointer !important;
11162
12440
  }
11163
12441
 
11164
- .lil-this.gui .controller input[type='range']::-moz-range-thumb {
12442
+ .lil-gui .controller input[type='range']::-moz-range-thumb {
11165
12443
  background: #007bff !important;
11166
12444
  border: 2px solid #ffffff !important;
11167
12445
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important;
@@ -11171,23 +12449,23 @@ export default {
11171
12449
  cursor: pointer !important;
11172
12450
  }
11173
12451
 
11174
- .lil-this.gui .controller .option {
12452
+ .lil-gui .controller .option {
11175
12453
  background: #ffffff !important;
11176
12454
  color: #495057 !important;
11177
12455
  border-bottom: 1px solid #f1f3f4 !important;
11178
12456
  }
11179
12457
 
11180
- .lil-this.gui .controller .option:hover {
12458
+ .lil-gui .controller .option:hover {
11181
12459
  background: #f8f9fa !important;
11182
12460
  }
11183
12461
 
11184
- .lil-this.gui .controller .option:last-child {
12462
+ .lil-gui .controller .option:last-child {
11185
12463
  border-bottom: none !important;
11186
12464
  }
11187
- .lil-this.gui input:active {
12465
+ .lil-gui input:active {
11188
12466
  background: #e6eff4;
11189
12467
  }
11190
- .lil-this.gui .controller button {
12468
+ .lil-gui .controller button {
11191
12469
  background: linear-gradient(135deg, #007bff 0%, #0056b3 100%) !important;
11192
12470
  color: #ffffff !important;
11193
12471
  border: none !important;
@@ -11196,51 +12474,51 @@ export default {
11196
12474
  transition: all 0.2s ease !important;
11197
12475
  }
11198
12476
 
11199
- .lil-this.gui .controller button:hover {
12477
+ .lil-gui .controller button:hover {
11200
12478
  background: linear-gradient(135deg, #0056b3 0%, #004085 100%) !important;
11201
12479
  transform: translateY(-1px) !important;
11202
12480
  box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3) !important;
11203
12481
  }
11204
12482
 
11205
- .lil-this.gui .controller .color {
12483
+ .lil-gui .controller .color {
11206
12484
  border: 2px solid #ffffff !important;
11207
12485
  border-radius: 4px !important;
11208
12486
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
11209
12487
  }
11210
- .lil-this.gui .controller.number .slider {
12488
+ .lil-gui .controller.number .slider {
11211
12489
  background-color: #e6eff4;
11212
12490
  }
11213
- .lil-this.gui .controller.number .slider:hover {
12491
+ .lil-gui .controller.number .slider:hover {
11214
12492
  background-color: #e6eff4;
11215
12493
  }
11216
- .lil-this.gui input:hover {
12494
+ .lil-gui input:hover {
11217
12495
  background: #e6eff4;
11218
12496
  }
11219
- .lil-this.gui input[type='number']:focus,
11220
- .lil-this.gui input[type='text']:focus,
11221
- .lil-this.gui input {
12497
+ .lil-gui input[type='number']:focus,
12498
+ .lil-gui input[type='text']:focus,
12499
+ .lil-gui input {
11222
12500
  background: #e6eff4;
11223
12501
  }
11224
- .lil-this.gui .controller > .name {
12502
+ .lil-gui .controller > .name {
11225
12503
  min-width: 25px;
11226
12504
  }
11227
- .lil-this.gui .controller.number input {
12505
+ .lil-gui .controller.number input {
11228
12506
  color: #2e3136;
11229
12507
  }
11230
- .lil-this.gui .controller.number .slider:active {
12508
+ .lil-gui .controller.number .slider:active {
11231
12509
  background-color: #e6eff4;
11232
12510
  }
11233
- .lil-this.gui .folder > .title {
12511
+ .lil-gui .folder > .title {
11234
12512
  background: linear-gradient(135deg, #f1f3f4 0%, #e9ecef 100%) !important;
11235
12513
  color: #495057 !important;
11236
12514
  border-bottom: 1px solid #dee2e6 !important;
11237
12515
  }
11238
12516
 
11239
- .lil-this.gui .folder > .title:before {
12517
+ .lil-gui .folder > .title:before {
11240
12518
  color: #6c757d !important;
11241
12519
  }
11242
12520
 
11243
- /* .lil-this.gui .folder.closed > .children {
12521
+ /* .lil-gui .folder.closed > .children {
11244
12522
  display: none !important;
11245
12523
  } */
11246
12524
  .lil-gui button {