diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 932ae4d..d0b98ed 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -83,6 +83,7 @@ interface CameraFitOptions { interface LoadFloorOptions { preserveCurrentSceneUntilReady?: boolean suppressProgress?: boolean + allowOverviewFallback?: boolean } interface LoadModelOptions { @@ -190,6 +191,7 @@ const props = withDefaults(defineProps<{ routePreview?: GuideRouteResult | null showRoute?: boolean autoSwitch?: boolean + disableAutoExit?: boolean autoSwitchThresholdLow?: number autoSwitchThresholdHigh?: number autoSwitchCooldown?: number @@ -205,6 +207,7 @@ const props = withDefaults(defineProps<{ routePreview: null, showRoute: false, autoSwitch: true, + disableAutoExit: false, autoSwitchThresholdLow: 1.45, autoSwitchThresholdHigh: 3.8, autoSwitchCooldown: 2000 @@ -223,7 +226,7 @@ const containerRef = ref(null) const isLoading = ref(true) const loadError = ref(false) const loadingProgress = ref(0) -const loadingMessage = ref('正在读取室内导览资源...') +const loadingMessage = ref('正在读取馆内导览资源...') const activeView = ref(props.initialView) const currentFloor = ref(props.initialFloorId) const selectedPOI = ref(null) @@ -264,22 +267,28 @@ let isDisposed = false let pendingTargetFocus: TargetPoiFocusRequest | null = null let targetFocusQueue: Promise = Promise.resolve() let modelLoadVersion = 0 +let hasLoadedFloorViewOnce = false let pointerDownState: { x: number; y: number } | null = null let cachedOverviewModel: THREE.Object3D | null = null let cachedSharedModelUrl = '' let autoSwitchOverviewMaxDim = 0 -// 自动切换状态 +// 自动切换状态:建筑外观积极进入,馆内锁定后再二段退出。 let isAutoSwitchLocked = false let lastAutoSwitchTime = 0 let lastControlDistance = 0 -let floorAutoSwitchProtectedUntil = 0 -let floorExitThresholdExceededSince = 0 -let floorExitZoomOutAccumulatedDistance = 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 | null = null -let suppressNextAutoSwitchCheck = false -let overviewWheelZoomStartDistance = 0 +let overviewEntryIntentStartedAt = 0 +let overviewEntryStartDistance = 0 +let overviewEntryZoomInRounds = 0 +let overviewWheelZoomInRounds = 0 let overviewWheelZoomIntentUntil = 0 let overviewWheelAutoSwitchTimer: ReturnType | null = null let isProgrammaticCameraChange = false @@ -293,10 +302,19 @@ const poiHitTargetScaleMultiplier = 4.8 const poiHitTargetCoreScaleMultiplier = 5.4 const poiScreenHitRadiusPx = 64 const poiScreenHitRadiusCorePx = 76 -const floorAutoSwitchProtectionMs = 1600 -const floorExitZoomOutIntentMinDeltaRatio = 0.01 -const floorExitZoomOutAccumulatedRatio = 0.12 -const floorExitThresholdHoldMs = 650 +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 syncControlInteractionOptions = () => { if (!controls) return @@ -359,7 +377,7 @@ const floorExteriorNameKeywords = [ '玻璃幕墙', '楼顶', '屋顶', - '室外' + '馆外' ] const overviewBuildingSubjectNameKeywords = [ @@ -727,11 +745,6 @@ const refreshPoiVisibilityByDistance = () => { const handleControlChange = () => { updatePoiVisibilityByDistance() - if (suppressNextAutoSwitchCheck) { - suppressNextAutoSwitchCheck = false - updateLastControlDistance() - return - } checkAutoSwitch() } @@ -862,9 +875,19 @@ const updatePoiTapFeedback = (now: number) => { } const handleControlStart = () => { + activeControlGestureStartedAt = Date.now() cancelCameraTween({ manual: true }) } +const handleControlEnd = () => { + if (indoorEntryGestureLocked) { + indoorContextLockedUntil = Math.max(indoorContextLockedUntil, Date.now() + indoorGestureExitGraceMs) + indoorEntryGestureLocked = false + } + activeControlGestureStartedAt = 0 + updateLastControlDistance() +} + const setProgress = (progress: number, message: string) => { loadingProgress.value = Math.min(100, Math.max(0, progress)) loadingMessage.value = message @@ -923,7 +946,7 @@ const initThree = async () => { } scene = new THREE.Scene() - scene.background = new THREE.Color(0xf1f3ee) + scene.background = new THREE.Color(0xf7f8f4) const width = Math.max(1, container.clientWidth) const height = Math.max(1, container.clientHeight) @@ -938,8 +961,8 @@ const initThree = async () => { renderer.setSize(width, height) renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) renderer.outputColorSpace = THREE.SRGBColorSpace - renderer.toneMapping = THREE.ACESFilmicToneMapping - renderer.toneMappingExposure = 1.05 + renderer.toneMapping = THREE.NeutralToneMapping + renderer.toneMappingExposure = 1.0 container.appendChild(renderer.domElement) controls = new OrbitControls(camera, renderer.domElement) @@ -964,14 +987,14 @@ const initThree = async () => { routeGroup.name = 'GuideModelRoute' scene.add(routeGroup) - const ambientLight = new THREE.AmbientLight(0xffffff, 1.6) + const ambientLight = new THREE.AmbientLight(0xffffff, 0.9) scene.add(ambientLight) - const keyLight = new THREE.DirectionalLight(0xffffff, 2.2) + const keyLight = new THREE.DirectionalLight(0xffffff, 2.0) keyLight.position.set(80, 120, 60) scene.add(keyLight) - const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.9) + const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.45) fillLight.position.set(-80, 60, -80) scene.add(fillLight) @@ -979,11 +1002,12 @@ const initThree = async () => { resizeObserver.observe(container) container.addEventListener('pointerdown', handlePointerDown) container.addEventListener('pointerup', handlePointerUp) - container.addEventListener('wheel', handleWheelIntent, { passive: true }) + renderer.domElement.addEventListener('wheel', handleWheelIntent, { passive: true, capture: true }) // 监听控制器变化,用于自动切换和点位可见层级更新 if (controls) { controls.addEventListener('start', handleControlStart) + controls.addEventListener('end', handleControlEnd) controls.addEventListener('change', handleControlChange) } @@ -1743,11 +1767,27 @@ const canAttachCachedSharedModel = (modelUrl: string) => ( Boolean(cachedOverviewModel && cachedSharedModelUrl === modelUrl && scene) ) +const canAttachCachedOverviewModel = () => Boolean(cachedOverviewModel && scene) + const canLoadFloorSilently = (floorId: string) => { const floor = floorIndex.value.find((item) => item.floorId === floorId) return Boolean(floor && canAttachCachedSharedModel(floor.modelUrl)) } +const getDefaultFloorId = () => ( + floorIndex.value.find((floor) => floor.floorId === props.initialFloorId)?.floorId + || floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId + || floorIndex.value[0]?.floorId + || props.initialFloorId + || 'L1' +) + +const getAutoEntryTargetFloorId = () => ( + hasLoadedFloorViewOnce + ? currentFloor.value || getDefaultFloorId() + : getDefaultFloorId() +) + const hasRenderableSceneForFloorTransition = () => Boolean(scene && activeModel) const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => { @@ -1784,19 +1824,34 @@ const updateLastControlDistance = () => { } } +const resetOverviewEntryIntent = () => { + overviewEntryIntentStartedAt = 0 + overviewEntryStartDistance = 0 + overviewEntryZoomInRounds = 0 + overviewWheelZoomInRounds = 0 + overviewWheelZoomIntentUntil = 0 +} + +const resetFloorExitCandidate = () => { + floorExitCandidateStartedAt = 0 + floorExitCandidateBaseDistance = 0 + floorExitZoomOutAccumulatedRatio = 0 +} + const markFloorAutoSwitchEntry = () => { - floorAutoSwitchProtectedUntil = Date.now() + floorAutoSwitchProtectionMs - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 + indoorContextLockedUntil = Date.now() + indoorContextLockMs + indoorEntryGestureLocked = activeControlGestureStartedAt > 0 + resetOverviewEntryIntent() + resetFloorExitCandidate() updateLastControlDistance() } const resetAutoSwitchDistanceTracking = () => { - floorAutoSwitchProtectedUntil = 0 - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 - overviewWheelZoomStartDistance = 0 - overviewWheelZoomIntentUntil = 0 + activeControlGestureStartedAt = 0 + indoorEntryGestureLocked = false + indoorContextLockedUntil = 0 + resetOverviewEntryIntent() + resetFloorExitCandidate() updateLastControlDistance() } @@ -1807,6 +1862,25 @@ const clearOverviewWheelAutoSwitchTimer = () => { } } +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 @@ -1814,47 +1888,125 @@ const handleWheelIntent = (event: WheelEvent) => { if (!Number.isFinite(distance)) return const now = Date.now() - if (!overviewWheelZoomStartDistance || now > overviewWheelZoomIntentUntil) { - overviewWheelZoomStartDistance = distance + 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 - checkAutoSwitch({ forceOverviewEntry: true }) - }, 180) + if (hasPendingOverviewWheelEntryIntent(triggerNow) && canRunPendingOverviewEntry(triggerNow)) { + checkAutoSwitch({ forceOverviewEntry: true }) + } + }, overviewWheelEntryDelayMs) } -const shouldForceOverviewEntryFromWheel = (distance: number, now: number) => ( - now <= overviewWheelZoomIntentUntil - && overviewWheelZoomStartDistance > 0 - && distance <= overviewWheelZoomStartDistance * 0.86 +const isOverviewEntryIntentActive = (now: number) => ( + overviewEntryIntentStartedAt > 0 + && ( + now - overviewEntryIntentStartedAt <= overviewEntryIntentWindowMs + || now <= overviewWheelZoomIntentUntil + ) ) -const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { - // 检查是否启用自动切换 - if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) { +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) + + if ( + !props.autoSwitch + || autoSwitchTemporarilyDisabled + || isLoading.value + || (!hasForcedOverviewEntryIntent && isProgrammaticCameraChange) + ) { return } - // 检查加载锁 if (isAutoSwitchLocked) { emit('autoSwitchBlocked', { reason: 'loading-locked' }) return } - // 检查冷却时间 - const now = Date.now() 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 @@ -1863,20 +2015,21 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { // 自动切换使用全馆稳定尺寸,避免单楼层可见性过滤压低 floor -> overview 阈值。 const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel) - const enterFloorAt = maxDim * props.autoSwitchThresholdLow - const exitFloorAt = maxDim * props.autoSwitchThresholdHigh + if (activeView.value === 'overview') { + if (!hasForcedOverviewEntryIntent) { + trackOverviewEntryIntent(distance, previousDistance, now) + } - // 判断是否需要切换 - if (activeView.value === 'overview' && (distance < enterFloorAt || (options.forceOverviewEntry && shouldForceOverviewEntryFromWheel(distance, now)))) { - // 从建筑外观自动切换到单楼层 - const targetFloorId = currentFloor.value || props.initialFloorId || 'L1' + if (!shouldEnterFloorFromOverview(distance, maxDim, now, hasForcedOverviewEntryIntent)) { + return + } + + const targetFloorId = getAutoEntryTargetFloorId() const hasSceneToKeep = hasRenderableSceneForFloorTransition() isAutoSwitchLocked = true lastAutoSwitchTime = now - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 - overviewWheelZoomStartDistance = 0 - overviewWheelZoomIntentUntil = 0 + resetFloorExitCandidate() + resetOverviewEntryIntent() clearOverviewWheelAutoSwitchTimer() void runAutoSwitchLoad( @@ -1888,7 +2041,8 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { }, () => loadFloor(targetFloorId, { preserveCurrentSceneUntilReady: hasSceneToKeep, - suppressProgress: hasSceneToKeep + suppressProgress: hasSceneToKeep, + allowOverviewFallback: true }), '单楼层模型加载失败', { showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) } @@ -1896,61 +2050,64 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { return } - if (activeView.value !== 'floor' || props.targetFocus) { - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 + if (activeView.value !== 'floor') { + resetFloorExitCandidate() return } - if (now < floorAutoSwitchProtectedUntil) { - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 + if (isFloorAutoExitLocked(now)) { + resetFloorExitCandidate() return } - const zoomOutIntentDelta = Math.max(maxDim * floorExitZoomOutIntentMinDeltaRatio, 1) - const isClearZoomOutIntent = distanceDelta > zoomOutIntentDelta - const isBeyondExitThreshold = distance > exitFloorAt + const baseExitThreshold = maxDim * props.autoSwitchThresholdHigh + const candidateExitAt = baseExitThreshold * floorExitCandidateThresholdMultiplier + const confirmExitAt = baseExitThreshold * floorExitConfirmThresholdMultiplier + const isBeyondCandidateThreshold = distance >= candidateExitAt - if (!isBeyondExitThreshold) { - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 + if (!isBeyondCandidateThreshold) { + resetFloorExitCandidate() return } - if (isClearZoomOutIntent) { - floorExitZoomOutAccumulatedDistance += distanceDelta - if (!floorExitThresholdExceededSince) { - floorExitThresholdExceededSince = now - } - } - - const requiredZoomOutDistance = Math.max(maxDim * floorExitZoomOutAccumulatedRatio, 8) - if ( - !floorExitThresholdExceededSince - || floorExitZoomOutAccumulatedDistance < requiredZoomOutDistance - ) { + if (!floorExitCandidateStartedAt) { + floorExitCandidateStartedAt = now + floorExitCandidateBaseDistance = Math.max(previousDistance, distance, 1) + floorExitZoomOutAccumulatedRatio = 0 return } - if (now - floorExitThresholdExceededSince >= floorExitThresholdHoldMs) { - // 从单楼层自动切换到完整外围模型 - isAutoSwitchLocked = true - lastAutoSwitchTime = now - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 - - void runAutoSwitchLoad( - { - from: 'floor', - to: 'overview', - trigger: 'zoom-out', - distance - }, - loadOverview, - '建筑外观模型加载失败' + 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 + }, + loadOverview, + '建筑外观模型加载失败' + ) } const runAutoSwitchLoad = async ( @@ -1974,7 +2131,7 @@ const runAutoSwitchLoad = async ( } catch (error) { if (isStaleModelLoadError(error)) return - console.error('室内 3D 自动视角切换失败:', error) + console.error('馆内 3D 自动视角切换失败:', error) loadError.value = true isLoading.value = false setProgress(0, error instanceof Error ? error.message : fallbackMessage) @@ -1996,10 +2153,6 @@ const disableAutoSwitchTemporarily = (durationMs: number) => { }, durationMs) } -const suppressAutoSwitchOnce = () => { - suppressNextAutoSwitchCheck = true -} - const setCameraView = ( center: THREE.Vector3, maxDim: number, @@ -2093,22 +2246,16 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc if (!Number.isFinite(nextDistance) || nextDistance <= 0) return - const shouldSuppressAutoSwitch = options.source === 'button' - if (shouldSuppressAutoSwitch) { - suppressAutoSwitchOnce() - } + const shouldTrackUserZoom = options.source === 'button' offset.setLength(nextDistance) const nextPosition = controls.target.clone().add(offset) moveCameraTo(nextPosition, controls.target, { - onComplete: shouldSuppressAutoSwitch - ? () => { - suppressAutoSwitchOnce() - handleControlChange() - } - : handleControlChange + onComplete: handleControlChange }) - lastControlDistance = nextDistance + if (shouldTrackUserZoom) { + lastControlDistance = currentDistance + } handleControlChange() } @@ -2166,9 +2313,10 @@ const attachCachedSharedModel = ( modelUrl: string, name: string, loadToken: number, - floorId?: string + floorId?: string, + options: { allowAnyCachedOverview?: boolean } = {} ) => { - if (!cachedOverviewModel || cachedSharedModelUrl !== modelUrl || !scene) return false + if (!cachedOverviewModel || (!options.allowAnyCachedOverview && cachedSharedModelUrl !== modelUrl) || !scene) return false const targetScene = scene activeModel = cachedOverviewModel @@ -2186,8 +2334,12 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { if (!floor || !scene) return const loadToken = startModelLoad() - const canUseCachedModel = canAttachCachedSharedModel(floor.modelUrl) - const preserveCurrentSceneUntilReady = options.preserveCurrentSceneUntilReady && !canUseCachedModel + const canUseExactCachedModel = canAttachCachedSharedModel(floor.modelUrl) + const canUseOverviewFallback = !canUseExactCachedModel + && Boolean(options.allowOverviewFallback) + && Boolean(options.preserveCurrentSceneUntilReady) + && canAttachCachedOverviewModel() + const preserveCurrentSceneUntilReady = options.preserveCurrentSceneUntilReady && !canUseExactCachedModel let nextModel: THREE.Object3D | null = null if (!preserveCurrentSceneUntilReady) { @@ -2196,63 +2348,90 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { clearSceneData() } - if (canUseCachedModel) { + if (canUseExactCachedModel) { activeView.value = 'floor' currentFloor.value = floor.floorId - attachCachedSharedModel(floor.modelUrl, `GuideFloorModel_${floor.floorId}`, loadToken, floor.floorId) - } else { - if (!options.suppressProgress) { - setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`) - } - const gltf = await loadModel( + attachCachedSharedModel( floor.modelUrl, - `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, + `GuideFloorModel_${floor.floorId}`, loadToken, - { suppressProgress: options.suppressProgress } + floor.floorId ) + } else { + let gltf: GLTF | null = null + try { - assertCurrentModelLoad(loadToken, gltf.scene) + if (!options.suppressProgress) { + setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`) + } + gltf = await loadModel( + floor.modelUrl, + `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, + loadToken, + { suppressProgress: options.suppressProgress } + ) + try { + assertCurrentModelLoad(loadToken, gltf.scene) + } catch (error) { + disposeObject(gltf.scene) + throw error + } } catch (error) { - disposeObject(gltf.scene) - throw error - } + if (isStaleModelLoadError(error) || !canUseOverviewFallback) { + throw error + } - const targetScene = scene - if (!targetScene) { - disposeObject(gltf.scene) - throw createStaleModelLoadError() - } - - nextModel = gltf.scene - nextModel.name = `GuideFloorModel_${floor.floorId}` - nextModel.userData.floorId = floor.floorId - prepareModel(nextModel) - try { - assertCurrentModelLoad(loadToken, nextModel) - } catch (error) { - nextModel = null - throw error - } - if (floor.sharedModelAsset) { - cacheAutoSwitchOverviewMaxDim(nextModel) - } - applyModelVisibilityForView(nextModel, 'floor', floor.floorId) - - if (preserveCurrentSceneUntilReady) { activeView.value = 'floor' currentFloor.value = floor.floorId clearSceneData() + attachCachedSharedModel( + floor.modelUrl, + `GuideFloorModel_${floor.floorId}`, + loadToken, + floor.floorId, + { allowAnyCachedOverview: true } + ) } - activeModel = nextModel - if (floor.sharedModelAsset) { - cachedOverviewModel = activeModel - cachedSharedModelUrl = floor.modelUrl + if (gltf) { + const targetScene = scene + if (!targetScene) { + disposeObject(gltf.scene) + throw createStaleModelLoadError() + } + + nextModel = gltf.scene + nextModel.name = `GuideFloorModel_${floor.floorId}` + nextModel.userData.floorId = floor.floorId + prepareModel(nextModel) + try { + assertCurrentModelLoad(loadToken, nextModel) + } catch (error) { + nextModel = null + throw error + } + if (floor.sharedModelAsset) { + cacheAutoSwitchOverviewMaxDim(nextModel) + } + applyModelVisibilityForView(nextModel, 'floor', floor.floorId) + + if (preserveCurrentSceneUntilReady) { + activeView.value = 'floor' + currentFloor.value = floor.floorId + clearSceneData() + } + + activeModel = nextModel + if (floor.sharedModelAsset) { + cachedOverviewModel = activeModel + cachedSharedModelUrl = floor.modelUrl + } + targetScene.add(activeModel) } - targetScene.add(activeModel) } if (!activeModel) throw createStaleModelLoadError() + hasLoadedFloorViewOnce = true fitCameraToObject(activeModel) markFloorAutoSwitchEntry() @@ -3142,7 +3321,7 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => { } const loadModelPackage = async () => { - setProgress(8, '正在读取室内导览资源...') + setProgress(8, '正在读取馆内导览资源...') const packageData = await props.modelSource.loadPackage() renderPackage.value = packageData @@ -3176,18 +3355,19 @@ const init3DScene = async () => { isLoading.value = true loadError.value = false selectedPOI.value = null + hasLoadedFloorViewOnce = false setProgress(0, '正在初始化三维场景...') await initThree() await loadModelPackage() - setProgress(100, '室内三维模型加载完成') + setProgress(100, '馆内三维模型加载完成') isLoading.value = false queueTargetFocus(pendingTargetFocus || props.targetFocus || null) } catch (error) { if (isStaleModelLoadError(error)) return - console.error('室内 3D 模型资源加载失败:', error) + console.error('馆内 3D 模型资源加载失败:', error) loadError.value = true isLoading.value = false setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试') @@ -3269,7 +3449,7 @@ const disposeScene = () => { const container = getContainerElement() container?.removeEventListener('pointerdown', handlePointerDown) container?.removeEventListener('pointerup', handlePointerUp) - container?.removeEventListener('wheel', handleWheelIntent) + renderer?.domElement.removeEventListener('wheel', handleWheelIntent, true) resizeObserver?.disconnect() resizeObserver = null @@ -3277,6 +3457,7 @@ const disposeScene = () => { // 清理自动切换相关资源 if (controls) { controls.removeEventListener('start', handleControlStart) + controls.removeEventListener('end', handleControlEnd) controls.removeEventListener('change', handleControlChange) controls.dispose() } @@ -3292,10 +3473,11 @@ const disposeScene = () => { cameraTween = null isProgrammaticCameraChange = false lastControlDistance = 0 - suppressNextAutoSwitchCheck = false - floorAutoSwitchProtectedUntil = 0 - floorExitThresholdExceededSince = 0 - floorExitZoomOutAccumulatedDistance = 0 + activeControlGestureStartedAt = 0 + indoorEntryGestureLocked = false + indoorContextLockedUntil = 0 + resetOverviewEntryIntent() + resetFloorExitCandidate() clearSceneData() disposePoiMarkerCache() @@ -3393,7 +3575,7 @@ onUnmounted(() => { width: 100%; height: 100%; overflow: hidden; - background: #f1f3ee; + background: #f7f8f4; } .three-canvas-wrapper { @@ -3416,7 +3598,7 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: center; - background: rgba(241, 243, 238, 0.9); + background: rgba(247, 248, 244, 0.9); z-index: 20; }