优化建筑内外自动切换状态机
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-13 16:16:24 +08:00
parent e473b6a2a5
commit 9c003c16dc
5 changed files with 502 additions and 363 deletions

View File

@@ -69,6 +69,12 @@ import {
guideModelLoadManager,
type GuideModelResourceSet
} from '@/services/model/GuideModelLoadManager'
import {
GuideAutoSwitchStateMachine,
type GuideAutoSwitchDirection,
type GuideAutoSwitchInputSource,
type GuideAutoSwitchRequest
} from './guideAutoSwitchStateMachine'
type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
@@ -193,6 +199,7 @@ interface CameraFitOptions {
targetOffsetRatio?: THREE.Vector3
screenOffsetRatio?: THREE.Vector2
up?: THREE.Vector3
onComplete?: () => void
}
interface LoadFloorOptions {
@@ -337,8 +344,6 @@ const props = withDefaults(defineProps<{
showRoute?: boolean
autoSwitch?: boolean
disableAutoExit?: boolean
autoSwitchThresholdLow?: number
autoSwitchThresholdHigh?: number
autoSwitchCooldown?: number
}>(), {
assetBaseUrl: '',
@@ -354,9 +359,7 @@ const props = withDefaults(defineProps<{
showRoute: false,
autoSwitch: true,
disableAutoExit: false,
autoSwitchThresholdLow: 1.45,
autoSwitchThresholdHigh: 3.8,
autoSwitchCooldown: 2000
autoSwitchCooldown: 1500
})
const emit = defineEmits<{
@@ -456,28 +459,14 @@ let desktopRotateModifiers = {
}
let cachedOverviewModel: THREE.Object3D | null = null
let cachedSharedModelUrl = ''
let autoSwitchOverviewMaxDim = 0
// 自动切换状态:建筑外观积极进入,馆内锁定后再二段退出
// 自动切换状态:所有缩放输入统一使用相对距离状态机
let isAutoSwitchLocked = false
let lastAutoSwitchTime = 0
let lastControlDistance = 0
let activeControlGestureStartedAt = 0
let indoorEntryGestureLocked = false
let indoorContextLockedUntil = 0
let floorExitCandidateStartedAt = 0
let floorExitCandidateBaseDistance = 0
let floorExitZoomOutAccumulatedRatio = 0
let autoSwitchTemporarilyDisabled = false
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
let overviewEntryIntentStartedAt = 0
let modelAdjustReportTimer: ReturnType<typeof setTimeout> | null = null
let hasPendingManualModelAdjustment = false
let overviewEntryStartDistance = 0
let overviewEntryZoomInRounds = 0
let overviewWheelZoomInRounds = 0
let overviewWheelZoomIntentUntil = 0
let overviewWheelAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
let cameraTween: CameraTweenState | null = null
@@ -495,21 +484,9 @@ const poiScreenHitRadiusPx = 64
const poiScreenHitRadiusCorePx = 76
const poiAmbientLabelHallSpacingPx = 92
const poiAmbientLabelServiceSpacingPx = 72
const indoorContextLockMs = 5000
const indoorGestureExitGraceMs = 900
const overviewEntryIntentWindowMs = 1200
const overviewEntryMinZoomInRatio = 0.04
const overviewEntryAccumulatedRatio = 0.2
const overviewEntryMinRounds = 2
const overviewEntryMaxRounds = 4
const overviewWheelEntryDelayMs = 180
const floorExitCandidateThresholdMultiplier = 1.12
const floorExitConfirmThresholdMultiplier = 1.34
const floorExitZoomOutIntentMinDeltaRatio = 0.015
const floorExitAccumulatedZoomOutRatio = 0.26
const floorExitCandidateHoldMs = 900
const modelLoadRetryDelaysMs = [300, 900]
const adjacentPreloadDelayMs = 900
const manualAutoSwitchPauseMs = 1500
const poiMarkerVisualLiftFactor = 0.92
const poiAmbientLabelLiftFactor = 2.1
const poiFocusLabelLiftFactor = 2.72
@@ -1593,7 +1570,10 @@ const cancelCameraTween = (options: { manual?: boolean } = {}) => {
clearProgrammaticCameraTimer()
const distance = controls?.getDistance()
if (typeof distance === 'number' && Number.isFinite(distance)) {
lastControlDistance = distance
if (activeView.value === 'floor') {
// 用户中断楼层初始拟合时,使用中断后的稳定距离重新建立退出基准。
autoSwitchStateMachine.setFloorInitialDistance(distance)
}
}
}
}
@@ -1610,11 +1590,6 @@ const moveCameraTo = (
isProgrammaticCameraChange = true
clearProgrammaticCameraTimer()
const nextDistance = toPosition.distanceTo(toTarget)
if (Number.isFinite(nextDistance)) {
lastControlDistance = nextDistance
}
if (!cameraTweenEnabled || options.immediate) {
cameraTween = null
camera.position.copy(toPosition)
@@ -1689,7 +1664,10 @@ const updatePoiTapFeedback = (now: number) => {
}
const handleControlStart = () => {
activeControlGestureStartedAt = Date.now()
const distance = controls?.getDistance()
if (typeof distance === 'number' && Number.isFinite(distance)) {
autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
}
if (!isProgrammaticCameraChange) {
hasPendingManualModelAdjustment = true
clearModelAdjustReportTimer()
@@ -1698,12 +1676,7 @@ const handleControlStart = () => {
}
const handleControlEnd = () => {
if (indoorEntryGestureLocked) {
indoorContextLockedUntil = Math.max(indoorContextLockedUntil, Date.now() + indoorGestureExitGraceMs)
indoorEntryGestureLocked = false
}
activeControlGestureStartedAt = 0
updateLastControlDistance()
activeAutoSwitchInputSource = 'gesture'
scheduleModelAdjustIdleReport()
}
@@ -2491,7 +2464,6 @@ const showFocusHallHighlight = (poi: RenderPoi) => {
}
const disposeCachedOverviewModel = () => {
autoSwitchOverviewMaxDim = 0
if (!cachedOverviewModel) return
disposeObject(cachedOverviewModel)
@@ -2885,15 +2857,6 @@ const getObjectSize = (object: THREE.Object3D) => (
getObjectBox(object).getSize(new THREE.Vector3())
)
const getObjectMaxDim = (object: THREE.Object3D) => {
const size = getObjectSize(object)
return Math.max(size.x, size.y, size.z, 1)
}
const cacheAutoSwitchOverviewMaxDim = (model: THREE.Object3D) => {
autoSwitchOverviewMaxDim = getObjectMaxDim(model)
}
const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => {
const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1)
const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1)
@@ -3096,229 +3059,58 @@ const disposeDetachedMultiFloorModels = (
.forEach((item) => disposeObject(item.model, protectedResources))
}
const updateLastControlDistance = () => {
if (!controls) return
const distance = controls.getDistance()
if (Number.isFinite(distance)) {
lastControlDistance = distance
}
}
const resetOverviewEntryIntent = () => {
overviewEntryIntentStartedAt = 0
overviewEntryStartDistance = 0
overviewEntryZoomInRounds = 0
overviewWheelZoomInRounds = 0
overviewWheelZoomIntentUntil = 0
}
const resetFloorExitCandidate = () => {
floorExitCandidateStartedAt = 0
floorExitCandidateBaseDistance = 0
floorExitZoomOutAccumulatedRatio = 0
}
const markFloorAutoSwitchEntry = () => {
indoorContextLockedUntil = Date.now() + indoorContextLockMs
indoorEntryGestureLocked = activeControlGestureStartedAt > 0
resetOverviewEntryIntent()
resetFloorExitCandidate()
updateLastControlDistance()
}
const resetAutoSwitchDistanceTracking = () => {
activeControlGestureStartedAt = 0
indoorEntryGestureLocked = false
indoorContextLockedUntil = 0
resetOverviewEntryIntent()
resetFloorExitCandidate()
updateLastControlDistance()
activeAutoSwitchInputSource = 'gesture'
autoSwitchStateMachine.reset(activeView.value)
}
const clearOverviewWheelAutoSwitchTimer = () => {
if (overviewWheelAutoSwitchTimer) {
clearTimeout(overviewWheelAutoSwitchTimer)
overviewWheelAutoSwitchTimer = null
}
}
const hasOverviewWheelEntryRounds = () => (
activeView.value === 'overview'
&& overviewWheelZoomInRounds >= overviewEntryMinRounds
)
const hasPendingOverviewWheelEntryIntent = (now: number) => (
hasOverviewWheelEntryRounds()
&& isOverviewEntryIntentActive(now)
)
const canRunPendingOverviewEntry = (now: number) => (
props.autoSwitch
&& !autoSwitchTemporarilyDisabled
&& !isLoading.value
&& !isAutoSwitchLocked
&& now - lastAutoSwitchTime >= props.autoSwitchCooldown
&& Boolean(controls && activeModel)
)
const handleWheelIntent = (event: WheelEvent) => {
if (!controls || activeView.value !== 'overview' || event.deltaY >= 0) return
if (!controls || activeView.value !== 'overview' || event.deltaY === 0) return
const distance = controls.getDistance()
if (!Number.isFinite(distance)) return
const now = Date.now()
if (!isOverviewEntryIntentActive(now)) {
overviewEntryIntentStartedAt = now
overviewEntryStartDistance = distance
overviewEntryZoomInRounds = 0
overviewWheelZoomInRounds = 0
}
overviewEntryIntentStartedAt = now
overviewEntryZoomInRounds += 1
overviewWheelZoomInRounds += 1
overviewWheelZoomIntentUntil = now + 900
clearOverviewWheelAutoSwitchTimer()
overviewWheelAutoSwitchTimer = setTimeout(() => {
const triggerNow = Date.now()
overviewWheelAutoSwitchTimer = null
if (hasPendingOverviewWheelEntryIntent(triggerNow) && canRunPendingOverviewEntry(triggerNow)) {
checkAutoSwitch({ forceOverviewEntry: true })
}
}, overviewWheelEntryDelayMs)
activeAutoSwitchInputSource = 'wheel'
autoSwitchStateMachine.beginInput(distance, 'wheel')
}
const isOverviewEntryIntentActive = (now: number) => (
overviewEntryIntentStartedAt > 0
&& (
now - overviewEntryIntentStartedAt <= overviewEntryIntentWindowMs
|| now <= overviewWheelZoomIntentUntil
)
)
const getOverviewEntryAccumulatedRatio = (distance: number) => {
if (!overviewEntryStartDistance || !Number.isFinite(distance)) return 0
return Math.max(0, 1 - distance / overviewEntryStartDistance)
}
const trackOverviewEntryIntent = (distance: number, previousDistance: number, now: number) => {
if (!Number.isFinite(distance) || !Number.isFinite(previousDistance) || previousDistance <= 0) return
const zoomInRatio = (previousDistance - distance) / previousDistance
const hasClearZoomIn = zoomInRatio >= overviewEntryMinZoomInRatio
if (!hasClearZoomIn) {
if (!isOverviewEntryIntentActive(now)) {
resetOverviewEntryIntent()
}
return
}
if (!isOverviewEntryIntentActive(now) || !overviewEntryStartDistance || distance > overviewEntryStartDistance) {
overviewEntryIntentStartedAt = now
overviewEntryStartDistance = previousDistance
overviewEntryZoomInRounds = 0
}
overviewEntryIntentStartedAt = now
overviewEntryZoomInRounds += 1
const accumulatedRatio = getOverviewEntryAccumulatedRatio(distance)
overviewEntryZoomInRounds = Math.max(
overviewEntryZoomInRounds,
Math.floor(accumulatedRatio / overviewEntryMinZoomInRatio)
)
}
const shouldEnterFloorFromOverview = (
distance: number,
maxDim: number,
now: number,
hasForcedOverviewEntryIntent = false
) => {
const enterFloorAt = maxDim * props.autoSwitchThresholdLow
if (distance <= enterFloorAt) return true
if (hasForcedOverviewEntryIntent) return true
if (!isOverviewEntryIntentActive(now)) return false
const accumulatedRatio = getOverviewEntryAccumulatedRatio(distance)
const hasRepeatedZoomIn = overviewEntryZoomInRounds >= overviewEntryMinRounds
&& accumulatedRatio >= overviewEntryAccumulatedRatio
const hasSustainedZoomIn = overviewEntryZoomInRounds >= overviewEntryMaxRounds
&& accumulatedRatio >= overviewEntryAccumulatedRatio * 0.68
return hasRepeatedZoomIn || hasSustainedZoomIn
}
const isFloorAutoExitLocked = (now: number) => (
props.disableAutoExit
|| Boolean(props.targetFocus)
|| indoorEntryGestureLocked
|| now < indoorContextLockedUntil
)
const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => {
const now = Date.now()
const hasForcedOverviewEntryIntent = options.forceOverviewEntry === true
&& hasOverviewWheelEntryRounds()
&& canRunPendingOverviewEntry(now)
const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
if (
!props.autoSwitch
|| autoSwitchTemporarilyDisabled
|| isLoading.value
|| (!hasForcedOverviewEntryIntent && isProgrammaticCameraChange)
|| isAutoSwitchLocked
|| isProgrammaticCameraChange
|| !controls
|| !activeModel
|| activeView.value === 'multi'
) {
return false
}
if (direction === 'exit-overview') {
return !props.disableAutoExit && !props.targetFocus && activeView.value === 'floor'
}
return activeView.value === 'overview'
}
const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
if (!canRunAutoSwitch(request.direction)) {
autoSwitchStateMachine.markTransitionFailed()
return
}
if (isAutoSwitchLocked) {
emit('autoSwitchBlocked', { reason: 'loading-locked' })
return
}
if (now - lastAutoSwitchTime < props.autoSwitchCooldown) {
emit('autoSwitchBlocked', { reason: 'cooldown' })
return
}
if (!controls || !activeModel || activeView.value === 'multi') return
const distance = controls.getDistance()
const previousDistance = lastControlDistance || distance
const distanceDelta = distance - previousDistance
lastControlDistance = distance
// 自动切换使用全馆稳定尺寸,避免单楼层可见性过滤压低 floor -> overview 阈值。
const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel)
if (activeView.value === 'overview') {
if (!hasForcedOverviewEntryIntent) {
trackOverviewEntryIntent(distance, previousDistance, now)
}
if (!shouldEnterFloorFromOverview(distance, maxDim, now, hasForcedOverviewEntryIntent)) {
return
}
isAutoSwitchLocked = true
if (request.direction === 'enter-floor') {
const targetFloorId = getAutoEntryTargetFloorId()
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
isAutoSwitchLocked = true
lastAutoSwitchTime = now
resetFloorExitCandidate()
resetOverviewEntryIntent()
clearOverviewWheelAutoSwitchTimer()
void runAutoSwitchLoad(
{
from: 'overview',
to: 'floor',
trigger: 'zoom-in',
distance
distance: request.distance
},
async () => {
await loadFloor(targetFloorId, {
@@ -3332,65 +3124,33 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => {
return
}
if (activeView.value !== 'floor') {
resetFloorExitCandidate()
return
}
if (isFloorAutoExitLocked(now)) {
resetFloorExitCandidate()
return
}
const baseExitThreshold = maxDim * props.autoSwitchThresholdHigh
const candidateExitAt = baseExitThreshold * floorExitCandidateThresholdMultiplier
const confirmExitAt = baseExitThreshold * floorExitConfirmThresholdMultiplier
const isBeyondCandidateThreshold = distance >= candidateExitAt
if (!isBeyondCandidateThreshold) {
resetFloorExitCandidate()
return
}
if (!floorExitCandidateStartedAt) {
floorExitCandidateStartedAt = now
floorExitCandidateBaseDistance = Math.max(previousDistance, distance, 1)
floorExitZoomOutAccumulatedRatio = 0
return
}
const zoomOutRatio = previousDistance > 0 ? distanceDelta / previousDistance : 0
const isClearZoomOutIntent = zoomOutRatio >= floorExitZoomOutIntentMinDeltaRatio
if (isClearZoomOutIntent || distance > floorExitCandidateBaseDistance) {
floorExitZoomOutAccumulatedRatio = Math.max(
floorExitZoomOutAccumulatedRatio,
distance / Math.max(floorExitCandidateBaseDistance, 1) - 1
)
}
const hasConfirmedScale = distance >= confirmExitAt
&& floorExitZoomOutAccumulatedRatio >= floorExitAccumulatedZoomOutRatio
const hasHeldCandidate = now - floorExitCandidateStartedAt >= floorExitCandidateHoldMs
if (!hasConfirmedScale || !hasHeldCandidate) {
return
}
isAutoSwitchLocked = true
lastAutoSwitchTime = now
resetFloorExitCandidate()
void runAutoSwitchLoad(
{
from: 'floor',
to: 'overview',
trigger: 'zoom-out',
distance
distance: request.distance
},
loadOverview
)
}
const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
cooldownMs: props.autoSwitchCooldown,
canSwitch: canRunAutoSwitch,
onSwitchRequested: requestAutoSwitch
})
const checkAutoSwitch = (source: GuideAutoSwitchInputSource = activeAutoSwitchInputSource) => {
if (!controls || isProgrammaticCameraChange) return
const distance = controls.getDistance()
if (!Number.isFinite(distance)) return
autoSwitchStateMachine.setView(activeView.value)
autoSwitchStateMachine.updateDistance(distance, { source })
}
const runAutoSwitchLoad = async (
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
loadTask: () => Promise<unknown>,
@@ -3407,8 +3167,10 @@ const runAutoSwitchLoad = async (
if (showLoading) {
isLoading.value = false
}
autoSwitchStateMachine.markTransitionSucceeded()
emit('autoSwitch', event)
} catch (error) {
autoSwitchStateMachine.markTransitionFailed()
if (isStaleModelLoadError(error)) return
console.error('馆内 3D 自动视角切换失败:', error)
@@ -3475,7 +3237,7 @@ const setCameraView = (
}
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08)
controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 8)
moveCameraTo(nextPosition, nextTarget)
moveCameraTo(nextPosition, nextTarget, { onComplete: options.onComplete })
}
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
@@ -3503,8 +3265,17 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
up: new THREE.Vector3(0, 1, 0)
}
: {}
: {}
overviewFitOptions.onComplete = () => {
if (activeView.value !== 'floor' || !controls) return
const distance = controls.getDistance()
if (Number.isFinite(distance)) {
// 楼层切换或相机复位完成后,以最终拟合距离作为退出基准。
autoSwitchStateMachine.setFloorInitialDistance(distance)
}
}
setCameraView(center, maxDim, direction, overviewFitOptions)
}
@@ -3523,11 +3294,6 @@ const applyOverviewInitialCamera = () => {
camera.updateProjectionMatrix()
controls.target.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
controls.update()
const distance = camera.position.distanceTo(controls.target)
if (Number.isFinite(distance)) {
lastControlDistance = distance
}
}
const resetCamera = () => {
@@ -3555,33 +3321,16 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
if (!Number.isFinite(nextDistance) || nextDistance <= 0) return
const shouldTrackUserZoom = options.source === 'button'
if (shouldTrackUserZoom && direction === 'in' && activeView.value === 'overview') {
const now = Date.now()
if (!isOverviewEntryIntentActive(now)) {
overviewEntryIntentStartedAt = now
overviewEntryStartDistance = currentDistance
overviewEntryZoomInRounds = 0
}
overviewEntryIntentStartedAt = now
overviewEntryZoomInRounds += 1
overviewWheelZoomInRounds += 1
overviewWheelZoomIntentUntil = now + 900
if (shouldTrackUserZoom) {
activeAutoSwitchInputSource = 'button'
autoSwitchStateMachine.beginInput(currentDistance, 'button')
}
offset.setLength(nextDistance)
const nextPosition = controls.target.clone().add(offset)
moveCameraTo(nextPosition, controls.target, {
onComplete: handleControlChange
onComplete: () => handleControlChange()
})
if (shouldTrackUserZoom) {
lastControlDistance = currentDistance
checkAutoSwitch({
forceOverviewEntry: direction === 'in' && activeView.value === 'overview'
})
}
handleControlChange()
}
const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
@@ -3635,6 +3384,7 @@ const loadOverview = async () => {
const loadToken = startModelLoad()
activeView.value = 'overview'
autoSwitchStateMachine.setView('overview')
syncControlInteractionOptions()
clearSceneData()
@@ -3647,7 +3397,6 @@ const loadOverview = async () => {
activeModel.visible = true
applyIndoorInitialModelTransform(activeModel)
applyModelVisibilityForView(activeModel, 'overview')
cacheAutoSwitchOverviewMaxDim(activeModel)
targetScene.add(activeModel)
applyOverviewInitialCamera()
loadCurrentFloorPoiMarkersInBackground(loadToken)
@@ -3677,7 +3426,6 @@ const loadOverview = async () => {
prepareModel(activeModel)
applyIndoorInitialModelTransform(activeModel)
applyModelVisibilityForView(activeModel, 'overview')
cacheAutoSwitchOverviewMaxDim(activeModel)
cachedOverviewModel = activeModel
cachedSharedModelUrl = packageData.overviewModelUrl
targetScene.add(activeModel)
@@ -3742,10 +3490,6 @@ const prepareFloorScene = async (
applyIndoorInitialModelTransform(model)
applyModelVisibilityForView(model, 'floor', requestedFloorId)
cacheAsSharedModel = Boolean(floor.sharedModelAsset)
if (cacheAsSharedModel) {
cacheAutoSwitchOverviewMaxDim(model)
}
}
if (!model) {
@@ -3786,6 +3530,8 @@ const commitPreparedFloorScene = (
}
activeView.value = 'floor'
autoSwitchStateMachine.setView('floor')
autoSwitchStateMachine.clearFloorInitialDistance()
syncControlInteractionOptions()
currentFloor.value = expectedFloorId
clearSceneData()
@@ -3812,7 +3558,6 @@ const commitPreparedFloorScene = (
updatePoiMarkerFocus()
hasLoadedFloorViewOnce = true
fitCameraToObject(activeModel)
markFloorAutoSwitchEntry()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('floor', {
@@ -3869,6 +3614,7 @@ const loadMultiFloor = async () => {
const loadToken = startModelLoad()
activeView.value = 'multi'
autoSwitchStateMachine.setView('multi')
syncControlInteractionOptions()
clearSceneData()
setProgress(18, '正在加载多层展示模型...')
@@ -3897,7 +3643,6 @@ const loadMultiFloor = async () => {
sourceModel.name = 'GuideOverviewModel'
prepareModel(sourceModel)
applyModelVisibilityForView(sourceModel, 'overview')
cacheAutoSwitchOverviewMaxDim(sourceModel)
cachedOverviewModel = sourceModel
cachedSharedModelUrl = sharedModelUrl
}
@@ -4791,7 +4536,7 @@ const handleSceneTap = async (event: PointerEvent) => {
if (hitMarker?.userData.poi) {
const selectedPoi = hitMarker.userData.poi as RenderPoi
if (activeView.value === 'overview') {
disableAutoSwitchTemporarily(10000)
disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
try {
await loadFloor(selectedPoi.floorId, {
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
@@ -4826,7 +4571,7 @@ const handleSceneTap = async (event: PointerEvent) => {
.find((object): object is THREE.Object3D => Boolean(object))
if (floorObject?.userData.floorId) {
disableAutoSwitchTemporarily(10000)
disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
void handleFloorChange(floorObject.userData.floorId as string)
return
}
@@ -4841,6 +4586,12 @@ const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType === 'touch' && getActiveTouchPointerCount() >= 2) {
hadMultiPointerGesture = true
pointerDownState = null
activeAutoSwitchInputSource = 'touch'
const distance = controls?.getDistance()
if (typeof distance === 'number' && Number.isFinite(distance)) {
// 双指手势从第二个触点按下时重新记录连续缩放基准。
autoSwitchStateMachine.beginInput(distance, 'touch', { resetBaseline: true })
}
}
if (event.pointerType !== 'touch') {
updateDesktopRotateModifierState(event)
@@ -5273,19 +5024,14 @@ const disposeScene = () => {
autoSwitchDisableTimer = null
}
clearOverviewWheelAutoSwitchTimer()
autoSwitchStateMachine.dispose()
clearProgrammaticCameraTimer()
clearModelAdjustReportTimer()
hasPendingManualModelAdjustment = false
cameraTween = null
isProgrammaticCameraChange = false
lastControlDistance = 0
activeControlGestureStartedAt = 0
activeAutoSwitchInputSource = 'gesture'
resetInteractionGateState()
indoorEntryGestureLocked = false
indoorContextLockedUntil = 0
resetOverviewEntryIntent()
resetFloorExitCandidate()
clearSceneData()
disposePoiMarkerCache()

View File

@@ -0,0 +1,251 @@
export type GuideAutoSwitchView = 'overview' | 'floor' | 'multi'
export type GuideAutoSwitchDirection = 'enter-floor' | 'exit-overview'
export type GuideAutoSwitchInputSource = 'wheel' | 'button' | 'touch' | 'gesture'
export interface GuideAutoSwitchRequest {
direction: GuideAutoSwitchDirection
distance: number
source: GuideAutoSwitchInputSource
}
interface GuideAutoSwitchStateMachineOptions {
onSwitchRequested: (request: GuideAutoSwitchRequest) => void
canSwitch?: (direction: GuideAutoSwitchDirection) => boolean
now?: () => number
enterRatio?: number
exitRatio?: number
holdMs?: number
cooldownMs?: number
intentTimeoutMs?: number
}
interface DistanceUpdateOptions {
previousDistance?: number
source?: GuideAutoSwitchInputSource
}
interface BeginInputOptions {
resetBaseline?: boolean
}
interface PendingCandidate {
direction: GuideAutoSwitchDirection
timer: ReturnType<typeof setTimeout>
}
const isValidDistance = (distance: number) => Number.isFinite(distance) && distance > 0
export class GuideAutoSwitchStateMachine {
private readonly onSwitchRequested: (request: GuideAutoSwitchRequest) => void
private readonly canSwitch: (direction: GuideAutoSwitchDirection) => boolean
private readonly now: () => number
private readonly enterRatio: number
private readonly exitRatio: number
private readonly holdMs: number
private readonly cooldownMs: number
private readonly intentTimeoutMs: number
// 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。
private view: GuideAutoSwitchView = 'overview'
private overviewStartDistance = 0
private overviewLastDistance = 0
private overviewLastInputAt: number | null = null
private floorInitialDistance = 0
private currentDistance = 0
private currentSource: GuideAutoSwitchInputSource = 'gesture'
private cooldownUntil = 0
private transitionPending = false
private candidate: PendingCandidate | null = null
constructor(options: GuideAutoSwitchStateMachineOptions) {
this.onSwitchRequested = options.onSwitchRequested
this.canSwitch = options.canSwitch || (() => true)
this.now = options.now || Date.now
this.enterRatio = options.enterRatio ?? 0.15
this.exitRatio = options.exitRatio ?? 0.2
this.holdMs = options.holdMs ?? 500
this.cooldownMs = options.cooldownMs ?? 1500
this.intentTimeoutMs = options.intentTimeoutMs ?? 1200
}
setView(view: GuideAutoSwitchView) {
if (this.view === view) return
this.cancelCandidate()
this.view = view
this.resetOverviewTracking()
if (view !== 'floor') {
this.floorInitialDistance = 0
}
}
beginInput(
distance: number,
source: GuideAutoSwitchInputSource,
options: BeginInputOptions = {}
) {
if (this.view !== 'overview' || !isValidDistance(distance)) return
const now = this.now()
const intentExpired = this.hasOverviewIntentExpired(now)
const reversed = this.overviewLastDistance > 0 && distance > this.overviewLastDistance
if (
options.resetBaseline
|| !this.overviewStartDistance
|| intentExpired
|| reversed
) {
this.cancelCandidate('enter-floor')
this.overviewStartDistance = distance
}
this.overviewLastDistance = distance
this.overviewLastInputAt = now
this.currentDistance = distance
this.currentSource = source
}
updateDistance(distance: number, options: DistanceUpdateOptions = {}) {
if (!isValidDistance(distance) || this.view === 'multi') {
this.cancelCandidate()
return
}
const now = this.now()
const source = options.source || this.currentSource
this.currentDistance = distance
this.currentSource = source
if (this.view === 'overview') {
this.updateOverviewDistance(distance, now, options.previousDistance)
return
}
const shouldExit = this.floorInitialDistance > 0
&& distance >= this.floorInitialDistance * (1 + this.exitRatio)
this.updateCandidate('exit-overview', shouldExit)
}
setFloorInitialDistance(distance: number) {
this.cancelCandidate('exit-overview')
this.floorInitialDistance = isValidDistance(distance) ? distance : 0
this.currentDistance = this.floorInitialDistance
}
clearFloorInitialDistance() {
this.cancelCandidate('exit-overview')
this.floorInitialDistance = 0
}
markTransitionSucceeded() {
this.transitionPending = false
this.cancelCandidate()
this.cooldownUntil = this.now() + this.cooldownMs
this.resetOverviewTracking()
}
markTransitionFailed() {
this.transitionPending = false
this.cancelCandidate()
}
reset(view: GuideAutoSwitchView = this.view) {
this.cancelCandidate()
this.view = view
this.transitionPending = false
this.cooldownUntil = 0
this.floorInitialDistance = 0
this.currentDistance = 0
this.resetOverviewTracking()
}
dispose() {
this.cancelCandidate()
}
private updateOverviewDistance(distance: number, now: number, previousDistance?: number) {
const fallbackStartDistance = typeof previousDistance === 'number' && isValidDistance(previousDistance)
? previousDistance
: distance
const intentExpired = this.hasOverviewIntentExpired(now)
const reversed = this.overviewLastDistance > 0 && distance > this.overviewLastDistance
if (!this.overviewStartDistance || intentExpired || reversed) {
this.cancelCandidate('enter-floor')
this.overviewStartDistance = reversed ? distance : fallbackStartDistance
}
this.overviewLastDistance = distance
this.overviewLastInputAt = now
const shouldEnter = distance <= this.overviewStartDistance * (1 - this.enterRatio)
this.updateCandidate('enter-floor', shouldEnter)
}
private hasOverviewIntentExpired(now: number) {
return this.overviewLastInputAt !== null
&& now - this.overviewLastInputAt > this.intentTimeoutMs
}
private updateCandidate(direction: GuideAutoSwitchDirection, conditionMet: boolean) {
if (!conditionMet) {
this.cancelCandidate(direction)
return
}
if (this.transitionPending || this.candidate?.direction === direction) return
if (!this.canSwitch(direction)) return
this.cancelCandidate()
// 条件必须保持 500ms冷却尚未结束时主动等到更晚的确认时刻。
const delay = Math.max(this.holdMs, this.cooldownUntil - this.now())
const timer = setTimeout(() => {
this.confirmCandidate(direction)
}, Math.max(0, delay))
this.candidate = { direction, timer }
}
private confirmCandidate(direction: GuideAutoSwitchDirection) {
if (this.candidate?.direction !== direction) return
this.candidate = null
if (this.transitionPending || !this.isConditionMet(direction)) return
if (this.now() < this.cooldownUntil) {
this.updateCandidate(direction, true)
return
}
if (!this.canSwitch(direction)) return
this.transitionPending = true
this.onSwitchRequested({
direction,
distance: this.currentDistance,
source: this.currentSource
})
}
private isConditionMet(direction: GuideAutoSwitchDirection) {
if (direction === 'enter-floor') {
return this.view === 'overview'
&& this.overviewStartDistance > 0
&& this.currentDistance <= this.overviewStartDistance * (1 - this.enterRatio)
}
return this.view === 'floor'
&& this.floorInitialDistance > 0
&& this.currentDistance >= this.floorInitialDistance * (1 + this.exitRatio)
}
private cancelCandidate(direction?: GuideAutoSwitchDirection) {
if (!this.candidate || (direction && this.candidate.direction !== direction)) return
clearTimeout(this.candidate.timer)
this.candidate = null
}
private resetOverviewTracking() {
this.overviewStartDistance = 0
this.overviewLastDistance = 0
this.overviewLastInputAt = null
}
}

View File

@@ -20,8 +20,6 @@
:route-preview="routePreview"
:show-route="showRoute"
:disable-auto-exit="disableAutoExit"
:auto-switch-threshold-low="autoSwitchThresholdLow"
:auto-switch-threshold-high="autoSwitchThresholdHigh"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
@selection-clear="handleSelectionClear"
@@ -358,8 +356,6 @@ const props = withDefaults(defineProps<{
routePreview?: GuideRouteResult | null
showRoute?: boolean
disableAutoExit?: boolean
autoSwitchThresholdLow?: number
autoSwitchThresholdHigh?: number
outdoorNavPolylines?: OutdoorNavPolyline[]
outdoorMarkers?: OutdoorMapMarker[]
activeOutdoorMarkerId?: string
@@ -411,8 +407,6 @@ const props = withDefaults(defineProps<{
routePreview: null,
showRoute: false,
disableAutoExit: false,
autoSwitchThresholdLow: 1.0,
autoSwitchThresholdHigh: 1.3,
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
outdoorMarkers: () => [] as OutdoorMapMarker[],
activeOutdoorMarkerId: '',
@@ -440,10 +434,13 @@ const emit = defineEmits<{
outdoorMarkerClick: [markerId: string]
}>()
// 手动切换与自动切换共用 1500ms 保护,避免操作完成后立即反向跳转。
const manualAutoSwitchPauseMs = 1500
// 监听视角切换循环切换reset -> top -> oblique -> reset
watch(() => props.cameraView, (view) => {
if (props.mapType !== 'indoor') return
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
if (view === 'reset') {
indoorRendererRef.value?.resetCamera?.()
@@ -595,7 +592,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
failedFloorId.value = ''
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
emit('floorRequest', {
floorId,
floorLabel: floor.label
@@ -620,8 +617,8 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
}
const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时临时禁用缩放自动切换 10 秒
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
// 手动切换展示层数时使用统一的短保护期。
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
if (mode === 'multi') {
loadingFloorId.value = ''
@@ -679,7 +676,7 @@ const handleFloorHeaderTap = () => {
const handleToolClick = (tool: string) => {
if (props.mapType === 'indoor') {
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
if (tool === '复位') {
indoorRendererRef.value?.resetCamera?.()
@@ -709,7 +706,7 @@ const handleZoomClick = (direction: 'in' | 'out') => {
}
const handleShowOverview = async () => {
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
loadingFloorId.value = ''
requestedFloorId.value = ''
requestedFloorLabel.value = ''

View File

@@ -45,8 +45,6 @@
:active-outdoor-marker-id="activeArrivalMarkerId"
:outdoor-focus-offset-y="arrivalOutdoorFocusOffsetY"
:disable-auto-exit="disableIndoorAutoExit"
:auto-switch-threshold-low="0.58"
:auto-switch-threshold-high="1.18"
@mode-change="handleModeChange"
@floor-request="handleFloorRequest"
@floor-change="handleFloorChange"

View File

@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
GuideAutoSwitchStateMachine,
type GuideAutoSwitchDirection,
type GuideAutoSwitchInputSource
} from '@/components/map/guideAutoSwitchStateMachine'
const createMachine = () => {
const requests: GuideAutoSwitchDirection[] = []
const machine = new GuideAutoSwitchStateMachine({
onSwitchRequested: ({ direction }) => requests.push(direction)
})
return { machine, requests }
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
})
describe('建筑外观与馆内楼层自动切换状态机', () => {
it('连续放大 14.9% 不进入,达到 15% 并保持 500ms 后进入', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'gesture')
machine.updateDistance(85.1, { source: 'gesture' })
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
machine.updateDistance(85, { source: 'gesture' })
vi.advanceTimersByTime(499)
expect(requests).toEqual([])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['enter-floor'])
})
it('两次滚轮事件累计不足 15% 时不进入', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'wheel')
machine.updateDistance(95, { source: 'wheel' })
machine.beginInput(95, 'wheel')
machine.updateDistance(90, { source: 'wheel' })
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
})
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 并保持 500ms 后退出', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(119.9)
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
machine.updateDistance(120)
vi.advanceTimersByTime(499)
expect(requests).toEqual([])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['exit-overview'])
})
it('条件满足后反向缩放会取消防抖任务并重置进入基准', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'touch', { resetBaseline: true })
machine.updateDistance(85, { source: 'touch' })
vi.advanceTimersByTime(300)
machine.updateDistance(88, { source: 'touch' })
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
machine.updateDistance(74.8, { source: 'touch' })
vi.advanceTimersByTime(500)
expect(requests).toEqual(['enter-floor'])
})
it('退出条件满足后回到 20% 阈值内会取消防抖任务', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(120)
vi.advanceTimersByTime(300)
machine.updateDistance(119.9)
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
})
it('进入意图超时后使用新的连续缩放距离重新计算基准', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'wheel')
machine.updateDistance(90, { source: 'wheel' })
vi.advanceTimersByTime(1201)
machine.updateDistance(80, { previousDistance: 90, source: 'wheel' })
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
machine.updateDistance(76.5, { source: 'wheel' })
vi.advanceTimersByTime(500)
expect(requests).toEqual(['enter-floor'])
})
it('切换成功后 1499ms 内不能反向切换1500ms 后可以', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'wheel')
machine.updateDistance(85, { source: 'wheel' })
vi.advanceTimersByTime(500)
expect(requests).toEqual(['enter-floor'])
machine.markTransitionSucceeded()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(120)
vi.advanceTimersByTime(1499)
expect(requests).toEqual(['enter-floor'])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['enter-floor', 'exit-overview'])
})
it.each<GuideAutoSwitchInputSource>(['wheel', 'button', 'touch'])(
'%s 路径使用同一套 15% 进入判断',
(source) => {
const { machine, requests } = createMachine()
machine.beginInput(100, source, { resetBaseline: source === 'touch' })
machine.updateDistance(85.1, { source })
vi.advanceTimersByTime(500)
expect(requests).toEqual([])
machine.updateDistance(85, { source })
vi.advanceTimersByTime(500)
expect(requests).toEqual(['enter-floor'])
}
)
})