diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index fa9467d..bcd1d38 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -543,6 +543,8 @@ const poiAmbientLabelServiceSpacingPx = 72 const modelLoadRetryDelaysMs = [300, 900] const adjacentPreloadDelayMs = 900 const manualAutoSwitchPauseMs = 1500 +const autoSwitchExitDistanceRatio = 0.2 +const autoSwitchExitDistanceMarginRatio = 0.08 const poiMarkerVisualLiftFactor = 0.92 const poiAmbientLabelLiftFactor = 2.1 const poiFocusLabelLiftFactor = 2.72 @@ -3275,6 +3277,18 @@ const resetAutoSwitchDistanceTracking = () => { autoSwitchStateMachine.reset(activeView.value) } +const ensureFloorAutoExitZoomRange = () => { + if (!controls) return + + const distance = controls.getDistance() + if (!Number.isFinite(distance) || distance <= 0) return + + // 楼层退出以当前距离的 120% 为阈值;参考外观相机正好处于原 600 上限时, + // 必须预留额外缩小空间,否则切层后无法满足自动返回外观的条件。 + const requiredMaxDistance = distance * (1 + autoSwitchExitDistanceRatio + autoSwitchExitDistanceMarginRatio) + controls.maxDistance = Math.max(controls.maxDistance, requiredMaxDistance) +} + const handleWheelIntent = (event: WheelEvent) => { if (!controls || activeView.value !== 'overview' || event.deltaY === 0) return @@ -3368,6 +3382,7 @@ const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({ exitHoldMs: 400, intentTimeoutMs: 2000, reverseToleranceRatio: 0.008, + exitRatio: autoSwitchExitDistanceRatio, cooldownMs: props.autoSwitchCooldown, canSwitch: canRunAutoSwitch, onSwitchRequested: requestAutoSwitch @@ -3752,6 +3767,7 @@ const applyIndoorInitialModelTransform = (model: THREE.Object3D) => { const resetCamera = () => { applyReferenceOverviewCameraState() if (activeView.value === 'floor' && controls) { + ensureFloorAutoExitZoomRange() autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance()) } refreshPoiVisibilityByDistance() @@ -4060,6 +4076,7 @@ const commitPreparedFloorScene = ( } recordCameraSnapshotRestoration(cameraSnapshot, `floor:${expectedFloorId}`) if (controls) { + ensureFloorAutoExitZoomRange() autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance()) } options.onCameraStable?.() diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts index 0433047..69f49eb 100644 --- a/tests/e2e/three-map-camera-stability.spec.ts +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -168,6 +168,21 @@ const showOverview = async (page: Page, testInfo: TestInfo, name: string) => { assertStableTransition(before, after) } +const exitOverviewWithZoomControl = async (page: Page, testInfo: TestInfo, name: string) => { + const before = await getReport(page) + await screenshot(page, testInfo, `${name}-before`) + + await page.getByText('−', { exact: true }).click() + await expect.poll(async () => (await getReport(page)).activeView, { timeout: 10_000 }).toBe('overview') + + const after = await getReport(page) + await screenshot(page, testInfo, `${name}-after`) + expect(after.isCameraTweening).toBe(false) + expect(after.modelRoot, 'the overview model must stay committed').not.toBeNull() + expect(after.modelRoot!.maxError).toBeLessThan(1e-12) + expect(after.camera.distance).toBeGreaterThanOrEqual(before.camera.distance * 1.2) +} + const normalizeFloorKey = (value: string) => value.trim().toLowerCase().replace(/[^a-z0-9.-]/g, '') const resolveRequiredFloorId = (floors: FloorReport[], requested: string) => { @@ -213,4 +228,8 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project await switchFloor(page, testInfo, l1Point5, 'exterior-l1point5') await showOverview(page, testInfo, 'l1point5-exterior') + + await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit') + await switchFloor(page, testInfo, l2, 'l1-l2-auto-exit') + await exitOverviewWithZoomControl(page, testInfo, 'l2-exterior-auto-exit') })