提交室内导览交互与讲解优化
This commit is contained in:
@@ -23,8 +23,8 @@
|
||||
</view>
|
||||
|
||||
<view v-if="showControls && floors.length" class="map-toolbar">
|
||||
<view class="overview-btn" :class="{ active: activeView === 'overview' }" @tap="showOverview">
|
||||
<text class="overview-text">全馆</text>
|
||||
<view class="overview-btn" :class="{ active: activeView === 'multi' }" @tap="showMultiFloor">
|
||||
<text class="overview-text">多层</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -57,7 +57,8 @@ import type {
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
|
||||
type ViewMode = 'overview' | 'floor'
|
||||
type ViewMode = 'overview' | 'floor' | 'multi'
|
||||
type CameraPreset = 'top' | 'oblique'
|
||||
|
||||
type FloorIndexItem = GuideModelFloorAsset
|
||||
|
||||
@@ -86,6 +87,13 @@ interface TargetPoiFocusResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface MultiFloorModelItem {
|
||||
floor: FloorIndexItem
|
||||
label: string
|
||||
model: THREE.Object3D
|
||||
size: THREE.Vector3
|
||||
}
|
||||
|
||||
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -96,19 +104,30 @@ const props = withDefaults(defineProps<{
|
||||
showControls?: boolean
|
||||
showPoi?: boolean
|
||||
targetFocus?: TargetPoiFocusRequest | null
|
||||
autoSwitch?: boolean
|
||||
autoSwitchThresholdLow?: number
|
||||
autoSwitchThresholdHigh?: number
|
||||
autoSwitchCooldown?: number
|
||||
}>(), {
|
||||
assetBaseUrl: '',
|
||||
initialFloorId: 'L1',
|
||||
initialView: 'overview',
|
||||
showControls: true,
|
||||
showPoi: false,
|
||||
targetFocus: null
|
||||
targetFocus: null,
|
||||
autoSwitch: true,
|
||||
autoSwitchThresholdLow: 1.0,
|
||||
autoSwitchThresholdHigh: 1.3,
|
||||
autoSwitchCooldown: 2000
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
floorChange: [floorId: string]
|
||||
poiClick: [poi: RenderPoi]
|
||||
selectionClear: []
|
||||
targetFocus: [result: TargetPoiFocusResult]
|
||||
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }]
|
||||
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<ContainerRef>(null)
|
||||
@@ -146,6 +165,17 @@ let resizeObserver: ResizeObserver | null = null
|
||||
let isDisposed = false
|
||||
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
||||
let targetFocusQueue: Promise<unknown> = Promise.resolve()
|
||||
let modelLoadVersion = 0
|
||||
let pointerDownState: { x: number; y: number } | null = null
|
||||
let cachedOverviewModel: THREE.Object3D | null = null
|
||||
|
||||
// 自动切换状态
|
||||
let isAutoSwitchLocked = false
|
||||
let lastAutoSwitchTime = 0
|
||||
let autoSwitchTemporarilyDisabled = false
|
||||
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isProgrammaticCameraChange = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const getContainerElement = () => {
|
||||
const container = containerRef.value
|
||||
@@ -170,6 +200,37 @@ const setProgress = (progress: number, message: string) => {
|
||||
loadingMessage.value = message
|
||||
}
|
||||
|
||||
const staleModelLoadMessage = 'STALE_MODEL_LOAD'
|
||||
|
||||
const startModelLoad = () => {
|
||||
modelLoadVersion += 1
|
||||
return modelLoadVersion
|
||||
}
|
||||
|
||||
const invalidateModelLoads = () => {
|
||||
modelLoadVersion += 1
|
||||
}
|
||||
|
||||
const isCurrentModelLoad = (loadToken: number) => (
|
||||
loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
|
||||
)
|
||||
|
||||
const createStaleModelLoadError = () => new Error(staleModelLoadMessage)
|
||||
|
||||
const isStaleModelLoadError = (error: unknown) => (
|
||||
error instanceof Error && error.message === staleModelLoadMessage
|
||||
)
|
||||
|
||||
const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => {
|
||||
if (isCurrentModelLoad(loadToken)) return
|
||||
|
||||
if (staleObject) {
|
||||
disposeObject(staleObject)
|
||||
}
|
||||
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
const waitForContainer = async () => {
|
||||
await nextTick()
|
||||
|
||||
@@ -218,6 +279,8 @@ const initThree = async () => {
|
||||
controls.enableZoom = true
|
||||
controls.minDistance = 6
|
||||
controls.maxDistance = 1200
|
||||
controls.minPolarAngle = 0.08
|
||||
controls.maxPolarAngle = Math.PI * 0.48
|
||||
|
||||
loader = new GLTFLoader()
|
||||
poiGroup = new THREE.Group()
|
||||
@@ -238,6 +301,12 @@ const initThree = async () => {
|
||||
resizeObserver = new ResizeObserver(handleResize)
|
||||
resizeObserver.observe(container)
|
||||
container.addEventListener('pointerdown', handlePointerDown)
|
||||
container.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
// 监听控制器变化,用于自动切换
|
||||
if (props.autoSwitch && controls) {
|
||||
controls.addEventListener('change', checkAutoSwitch)
|
||||
}
|
||||
|
||||
startRenderLoop()
|
||||
}
|
||||
@@ -280,10 +349,21 @@ const disposeObject = (object: THREE.Object3D) => {
|
||||
})
|
||||
}
|
||||
|
||||
const disposeCachedOverviewModel = () => {
|
||||
if (!cachedOverviewModel) return
|
||||
|
||||
disposeObject(cachedOverviewModel)
|
||||
cachedOverviewModel = null
|
||||
}
|
||||
|
||||
const clearSceneData = () => {
|
||||
if (activeModel && scene) {
|
||||
scene.remove(activeModel)
|
||||
disposeObject(activeModel)
|
||||
if (activeModel === cachedOverviewModel) {
|
||||
cachedOverviewModel.visible = false
|
||||
} else {
|
||||
disposeObject(activeModel)
|
||||
}
|
||||
}
|
||||
|
||||
activeModel = null
|
||||
@@ -299,7 +379,7 @@ const clearSceneData = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadModel = (url: string, label: string) => new Promise<GLTF>((resolve, reject) => {
|
||||
const loadModel = (url: string, label: string, loadToken?: number) => new Promise<GLTF>((resolve, reject) => {
|
||||
if (!loader) {
|
||||
reject(new Error('GLTF 加载器未初始化'))
|
||||
return
|
||||
@@ -309,6 +389,8 @@ const loadModel = (url: string, label: string) => new Promise<GLTF>((resolve, re
|
||||
url,
|
||||
resolve,
|
||||
(event) => {
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
||||
|
||||
if (event.total > 0) {
|
||||
const modelProgress = Math.round((event.loaded / event.total) * 70)
|
||||
setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
|
||||
@@ -330,41 +412,224 @@ const prepareModel = (model: THREE.Object3D) => {
|
||||
})
|
||||
}
|
||||
|
||||
const fitCameraToObject = (object: THREE.Object3D) => {
|
||||
const getObjectSize = (object: THREE.Object3D) => (
|
||||
new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3())
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
return Math.max(maxFloorHeight * 3.2, maxFootprint * 0.2, 42)
|
||||
}
|
||||
|
||||
const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
|
||||
if (!items.length) return
|
||||
|
||||
const verticalGap = getMultiFloorVerticalGap(items)
|
||||
const centerIndex = (items.length - 1) / 2
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const offsetY = (index - centerIndex) * verticalGap
|
||||
item.model.position.y += offsetY
|
||||
item.model.userData.multiFloorOffsetY = offsetY
|
||||
item.model.userData.multiFloorVerticalGap = verticalGap
|
||||
})
|
||||
}
|
||||
|
||||
const checkAutoSwitch = () => {
|
||||
// 检查是否启用自动切换
|
||||
if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) {
|
||||
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 box = new THREE.Box3().setFromObject(activeModel)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||
|
||||
const thresholdLow = maxDim * props.autoSwitchThresholdLow
|
||||
const thresholdHigh = maxDim * props.autoSwitchThresholdHigh
|
||||
|
||||
// 判断是否需要切换
|
||||
if (activeView.value === 'overview' && distance < thresholdLow) {
|
||||
// 从建筑外观自动切换到单楼层
|
||||
isAutoSwitchLocked = true
|
||||
lastAutoSwitchTime = now
|
||||
|
||||
void runAutoSwitchLoad(
|
||||
{
|
||||
from: 'overview',
|
||||
to: 'floor',
|
||||
trigger: 'zoom-in',
|
||||
distance
|
||||
},
|
||||
() => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
|
||||
'单楼层模型加载失败'
|
||||
)
|
||||
} else if (activeView.value === 'floor' && distance > thresholdHigh) {
|
||||
// 从单楼层自动切换到完整外围模型
|
||||
isAutoSwitchLocked = true
|
||||
lastAutoSwitchTime = now
|
||||
|
||||
void runAutoSwitchLoad(
|
||||
{
|
||||
from: 'floor',
|
||||
to: 'overview',
|
||||
trigger: 'zoom-out',
|
||||
distance
|
||||
},
|
||||
loadOverview,
|
||||
'建筑外观模型加载失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const runAutoSwitchLoad = async (
|
||||
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
|
||||
loadTask: () => Promise<void>,
|
||||
fallbackMessage: string
|
||||
) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
await loadTask()
|
||||
isLoading.value = false
|
||||
emit('autoSwitch', event)
|
||||
} catch (error) {
|
||||
if (isStaleModelLoadError(error)) return
|
||||
|
||||
console.error('室内 3D 自动视角切换失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : fallbackMessage)
|
||||
} finally {
|
||||
isAutoSwitchLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
const disableAutoSwitchTemporarily = (durationMs: number) => {
|
||||
autoSwitchTemporarilyDisabled = true
|
||||
|
||||
if (autoSwitchDisableTimer) {
|
||||
clearTimeout(autoSwitchDisableTimer)
|
||||
}
|
||||
|
||||
autoSwitchDisableTimer = setTimeout(() => {
|
||||
autoSwitchTemporarilyDisabled = false
|
||||
autoSwitchDisableTimer = null
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
const setCameraView = (center: THREE.Vector3, maxDim: number, direction: THREE.Vector3, distanceFactor = 0.58) => {
|
||||
if (!camera || !controls) return
|
||||
|
||||
isProgrammaticCameraChange = true
|
||||
if (programmaticCameraTimer) {
|
||||
clearTimeout(programmaticCameraTimer)
|
||||
}
|
||||
|
||||
const fov = camera.fov * (Math.PI / 180)
|
||||
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor
|
||||
|
||||
try {
|
||||
camera.near = Math.max(0.1, maxDim / 1000)
|
||||
camera.far = Math.max(10000, maxDim * 20)
|
||||
camera.position.copy(center).add(direction.normalize().multiplyScalar(distance))
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
controls.target.copy(center)
|
||||
controls.minDistance = Math.max(2, maxDim * 0.08)
|
||||
controls.maxDistance = Math.max(20, maxDim * 8)
|
||||
controls.update()
|
||||
} finally {
|
||||
programmaticCameraTimer = window.setTimeout(() => {
|
||||
isProgrammaticCameraChange = false
|
||||
programmaticCameraTimer = null
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
|
||||
if (!camera || !controls) return
|
||||
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||
const fov = camera.fov * (Math.PI / 180)
|
||||
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * 0.58
|
||||
const direction = new THREE.Vector3(0.85, 0.62, 1).normalize()
|
||||
const direction = preset === 'top'
|
||||
? new THREE.Vector3(0.02, 1, 0.02)
|
||||
: new THREE.Vector3(0.85, 0.62, 1)
|
||||
|
||||
camera.near = Math.max(0.1, maxDim / 1000)
|
||||
camera.far = Math.max(10000, maxDim * 20)
|
||||
camera.position.copy(center).add(direction.multiplyScalar(distance))
|
||||
camera.updateProjectionMatrix()
|
||||
setCameraView(center, maxDim, direction)
|
||||
}
|
||||
|
||||
controls.target.copy(center)
|
||||
controls.minDistance = Math.max(2, maxDim * 0.08)
|
||||
controls.maxDistance = Math.max(20, maxDim * 8)
|
||||
controls.update()
|
||||
const resetCamera = () => {
|
||||
if (activeModel) {
|
||||
fitCameraToObject(activeModel)
|
||||
}
|
||||
}
|
||||
|
||||
const setCameraPreset = (preset: CameraPreset) => {
|
||||
if (activeModel) {
|
||||
fitCameraToObject(activeModel, preset)
|
||||
}
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
const packageData = renderPackage.value
|
||||
if (!packageData || !scene) return
|
||||
|
||||
const loadToken = startModelLoad()
|
||||
activeView.value = 'overview'
|
||||
clearSceneData()
|
||||
setProgress(18, '正在加载全馆三维模型...')
|
||||
|
||||
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载全馆三维模型')
|
||||
if (cachedOverviewModel) {
|
||||
const targetScene = scene
|
||||
if (!targetScene) throw createStaleModelLoadError()
|
||||
|
||||
activeModel = cachedOverviewModel
|
||||
activeModel.visible = true
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
return
|
||||
}
|
||||
|
||||
setProgress(18, '正在加载建筑外观模型...')
|
||||
|
||||
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载建筑外观模型', loadToken)
|
||||
assertCurrentModelLoad(loadToken, gltf.scene)
|
||||
|
||||
const targetScene = scene
|
||||
if (!targetScene) {
|
||||
disposeObject(gltf.scene)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
activeModel = gltf.scene
|
||||
activeModel.name = 'GuideOverviewModel'
|
||||
prepareModel(activeModel)
|
||||
scene.add(activeModel)
|
||||
cachedOverviewModel = activeModel
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
}
|
||||
|
||||
@@ -372,23 +637,90 @@ const loadFloor = async (floorId: string) => {
|
||||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||||
if (!floor || !scene) return
|
||||
|
||||
const loadToken = startModelLoad()
|
||||
activeView.value = 'floor'
|
||||
currentFloor.value = floor.floorId
|
||||
clearSceneData()
|
||||
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
|
||||
|
||||
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
|
||||
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, loadToken)
|
||||
assertCurrentModelLoad(loadToken, gltf.scene)
|
||||
|
||||
const targetScene = scene
|
||||
if (!targetScene) {
|
||||
disposeObject(gltf.scene)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
activeModel = gltf.scene
|
||||
activeModel.name = `GuideFloorModel_${floor.floorId}`
|
||||
activeModel.userData.floorId = floor.floorId
|
||||
prepareModel(activeModel)
|
||||
scene.add(activeModel)
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
|
||||
if (shouldRenderPoiMarkers.value) {
|
||||
await loadFloorPOIs(floor)
|
||||
await loadFloorPOIs(floor, loadToken)
|
||||
assertCurrentModelLoad(loadToken)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMultiFloor = async () => {
|
||||
if (!scene || !floorIndex.value.length) return
|
||||
|
||||
const loadToken = startModelLoad()
|
||||
activeView.value = 'multi'
|
||||
clearSceneData()
|
||||
setProgress(18, '正在加载多层展示模型...')
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.name = 'GuideMultiFloorModel'
|
||||
const orderedFloors = [...floorIndex.value].sort((a, b) => a.order - b.order)
|
||||
const loadedFloors: MultiFloorModelItem[] = []
|
||||
|
||||
for (let index = 0; index < orderedFloors.length; index += 1) {
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
|
||||
const floor = orderedFloors[index]
|
||||
const label = formatFloorLabel(floor.floorId)
|
||||
const gltf = await loadModel(floor.modelUrl, `正在加载 ${label} 多层模型`, loadToken)
|
||||
if (!isCurrentModelLoad(loadToken)) {
|
||||
disposeObject(gltf.scene)
|
||||
disposeObject(group)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
const floorModel = gltf.scene
|
||||
floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
|
||||
floorModel.userData.floorId = floor.floorId
|
||||
prepareModel(floorModel)
|
||||
loadedFloors.push({
|
||||
floor,
|
||||
label,
|
||||
model: floorModel,
|
||||
size: getObjectSize(floorModel)
|
||||
})
|
||||
setProgress(
|
||||
18 + Math.round(((index + 1) / orderedFloors.length) * 72),
|
||||
`已加载 ${label}`
|
||||
)
|
||||
}
|
||||
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
applyMultiFloorLayout(loadedFloors)
|
||||
loadedFloors.forEach((item) => group.add(item.model))
|
||||
|
||||
const targetScene = scene
|
||||
if (!targetScene) {
|
||||
disposeObject(group)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
activeModel = group
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
}
|
||||
|
||||
const createPoiMaterial = (poi: RenderPoi) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 96
|
||||
@@ -570,10 +902,15 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
|
||||
return poi
|
||||
}
|
||||
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem) => {
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
||||
if (!poiGroup) return
|
||||
|
||||
const validPois = await props.modelSource.loadFloorPois(floor.floorId)
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
||||
|
||||
const targetPoiGroup = poiGroup
|
||||
if (!targetPoiGroup) return
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
|
||||
validPois.forEach((poi) => {
|
||||
@@ -583,12 +920,37 @@ const loadFloorPOIs = async (floor: FloorIndexItem) => {
|
||||
sprite.userData.baseScale = markerSize
|
||||
sprite.userData.poi = poi
|
||||
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
||||
poiGroup!.add(sprite)
|
||||
targetPoiGroup.add(sprite)
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!props.showControls || !camera || !renderer || !poiGroup || !getContainerElement()) return
|
||||
const clearMapSelection = (shouldEmit = true) => {
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
disposeFocusLabel()
|
||||
updatePoiMarkerFocus()
|
||||
|
||||
if (shouldEmit) {
|
||||
emit('selectionClear')
|
||||
}
|
||||
}
|
||||
|
||||
const findFloorObject = (object: THREE.Object3D) => {
|
||||
let current: THREE.Object3D | null = object
|
||||
|
||||
while (current) {
|
||||
if (typeof current.userData.floorId === 'string') {
|
||||
return current
|
||||
}
|
||||
|
||||
current = current.parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSceneTap = (event: PointerEvent) => {
|
||||
if (!camera || !renderer || !getContainerElement()) return
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
const pointer = new THREE.Vector2(
|
||||
@@ -597,16 +959,55 @@ const handlePointerDown = (event: PointerEvent) => {
|
||||
)
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
const hits = raycaster.intersectObjects(poiGroup.children, false)
|
||||
|
||||
const hit = hits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi)
|
||||
if (activeView.value === 'floor' && poiGroup) {
|
||||
const poiHits = raycaster.intersectObjects(poiGroup.children, false)
|
||||
const hit = poiHits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi)
|
||||
|
||||
if (hit?.object.userData.poi) {
|
||||
selectedPOI.value = hit.object.userData.poi as RenderPoi
|
||||
activeFocusPoiId.value = selectedPOI.value.id
|
||||
updatePoiMarkerFocus()
|
||||
emit('poiClick', selectedPOI.value)
|
||||
if (hit?.object.userData.poi) {
|
||||
const poi = hit.object.userData.poi as RenderPoi
|
||||
selectedPOI.value = poi
|
||||
activeFocusPoiId.value = poi.id
|
||||
updatePoiMarkerFocus()
|
||||
showFocusPoiLabel(poi)
|
||||
focusCameraOnPoi(poi)
|
||||
emit('poiClick', poi)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (activeView.value === 'multi' && activeModel) {
|
||||
const floorHits = raycaster.intersectObjects(activeModel.children, true)
|
||||
const floorObject = floorHits
|
||||
.map((hit) => findFloorObject(hit.object))
|
||||
.find((object): object is THREE.Object3D => Boolean(object))
|
||||
|
||||
if (floorObject?.userData.floorId) {
|
||||
disableAutoSwitchTemporarily(10000)
|
||||
void handleFloorChange(floorObject.userData.floorId as string)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clearMapSelection()
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
pointerDownState = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (!pointerDownState) return
|
||||
|
||||
const moveDistance = Math.hypot(event.clientX - pointerDownState.x, event.clientY - pointerDownState.y)
|
||||
pointerDownState = null
|
||||
|
||||
if (moveDistance > 8) return
|
||||
|
||||
handleSceneTap(event)
|
||||
}
|
||||
|
||||
const emitTargetFocus = (
|
||||
@@ -625,10 +1026,7 @@ const emitTargetFocus = (
|
||||
|
||||
const clearTargetFocus = () => {
|
||||
pendingTargetFocus = null
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
disposeFocusLabel()
|
||||
updatePoiMarkerFocus()
|
||||
clearMapSelection(false)
|
||||
}
|
||||
|
||||
const isSceneReadyForTargetFocus = () => (
|
||||
@@ -682,7 +1080,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
isLoading.value = false
|
||||
emit('floorChange', request.floorId)
|
||||
} else if (shouldRenderPoiMarkers.value && poiGroup && poiGroup.children.length === 0) {
|
||||
await loadFloorPOIs(floor)
|
||||
await loadFloorPOIs(floor, modelLoadVersion)
|
||||
}
|
||||
|
||||
const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)
|
||||
@@ -702,6 +1100,8 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
emitTargetFocus(request, 'focused')
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isStaleModelLoadError(error)) return false
|
||||
|
||||
console.error('目标 POI 聚焦失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
@@ -751,6 +1151,11 @@ const loadModelPackage = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.initialView === 'multi') {
|
||||
await loadMultiFloor()
|
||||
return
|
||||
}
|
||||
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
@@ -770,6 +1175,8 @@ const init3DScene = async () => {
|
||||
isLoading.value = false
|
||||
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
|
||||
} catch (error) {
|
||||
if (isStaleModelLoadError(error)) return
|
||||
|
||||
console.error('室内 3D 模型资源加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
@@ -785,6 +1192,8 @@ const handleFloorChange = async (floorId: string) => {
|
||||
isLoading.value = false
|
||||
emit('floorChange', floorId)
|
||||
} catch (error) {
|
||||
if (isStaleModelLoadError(error)) return
|
||||
|
||||
console.error('楼层模型加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
@@ -793,17 +1202,26 @@ const handleFloorChange = async (floorId: string) => {
|
||||
}
|
||||
|
||||
const showOverview = async () => {
|
||||
// 建筑外观不再作为手动切换入口;只在初始加载与缩放自动切换时展示。
|
||||
if (activeView.value === 'overview') {
|
||||
resetCamera()
|
||||
}
|
||||
}
|
||||
|
||||
const showMultiFloor = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
activeFocusPoiId.value = ''
|
||||
await loadOverview()
|
||||
await loadMultiFloor()
|
||||
isLoading.value = false
|
||||
} catch (error) {
|
||||
console.error('全馆模型加载失败:', error)
|
||||
if (isStaleModelLoadError(error)) return
|
||||
|
||||
console.error('多层展示模型加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : '全馆模型加载失败')
|
||||
setProgress(0, error instanceof Error ? error.message : '多层展示模型加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +1231,7 @@ const retryLoad = () => {
|
||||
|
||||
const disposeScene = () => {
|
||||
isDisposed = true
|
||||
invalidateModelLoads()
|
||||
|
||||
if (animationId) {
|
||||
window.cancelAnimationFrame(animationId)
|
||||
@@ -821,13 +1240,31 @@ const disposeScene = () => {
|
||||
|
||||
const container = getContainerElement()
|
||||
container?.removeEventListener('pointerdown', handlePointerDown)
|
||||
container?.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
controls?.dispose()
|
||||
|
||||
// 清理自动切换相关资源
|
||||
if (controls) {
|
||||
controls.removeEventListener('change', checkAutoSwitch)
|
||||
controls.dispose()
|
||||
}
|
||||
controls = null
|
||||
|
||||
if (autoSwitchDisableTimer) {
|
||||
clearTimeout(autoSwitchDisableTimer)
|
||||
autoSwitchDisableTimer = null
|
||||
}
|
||||
|
||||
if (programmaticCameraTimer) {
|
||||
clearTimeout(programmaticCameraTimer)
|
||||
programmaticCameraTimer = null
|
||||
}
|
||||
isProgrammaticCameraChange = false
|
||||
|
||||
clearSceneData()
|
||||
disposeCachedOverviewModel()
|
||||
scene?.clear()
|
||||
scene = null
|
||||
camera = null
|
||||
@@ -846,15 +1283,15 @@ const disposeScene = () => {
|
||||
defineExpose({
|
||||
switchFloor: handleFloorChange,
|
||||
showOverview,
|
||||
showMultiFloor,
|
||||
resetCamera,
|
||||
setCameraPreset,
|
||||
focusTargetPoi: (request: TargetPoiFocusRequest) => {
|
||||
queueTargetFocus(request)
|
||||
},
|
||||
clearNavigation: () => {
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
disposeFocusLabel()
|
||||
updatePoiMarkerFocus()
|
||||
}
|
||||
clearSelection: clearMapSelection,
|
||||
clearNavigation: () => clearMapSelection(false),
|
||||
disableAutoSwitchTemporarily
|
||||
})
|
||||
|
||||
watch(() => props.modelSource, () => {
|
||||
|
||||
Reference in New Issue
Block a user