修复三维楼层自动切换与模型加载
- 优化建筑外观与馆内楼层的缩放自动切换状态 - 首次进入馆内默认加载 1F 楼层模型,后续重进保持上次楼层 - 手动楼层切换强制加载对应楼层模型,避免误用外观兜底 - 调整三维渲染色调、背景和灯光,减轻楼层模型灰蒙感 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ContainerRef>(null)
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref(false)
|
||||
const loadingProgress = ref(0)
|
||||
const loadingMessage = ref('正在读取室内导览资源...')
|
||||
const loadingMessage = ref('正在读取馆内导览资源...')
|
||||
const activeView = ref<ViewMode>(props.initialView)
|
||||
const currentFloor = ref(props.initialFloorId)
|
||||
const selectedPOI = ref<RenderPoi | null>(null)
|
||||
@@ -264,22 +267,28 @@ let isDisposed = false
|
||||
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
||||
let targetFocusQueue: Promise<unknown> = 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<typeof setTimeout> | 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<typeof setTimeout> | 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user