优化三维导览模型加载与交互体验
This commit is contained in:
@@ -124,6 +124,19 @@ interface MultiFloorModelItem {
|
||||
size: THREE.Vector3
|
||||
}
|
||||
|
||||
interface ReusableModelResources {
|
||||
geometries: Set<THREE.BufferGeometry>
|
||||
materials: Set<THREE.Material>
|
||||
textures: Set<THREE.Texture>
|
||||
}
|
||||
|
||||
interface PoiMarkerCacheEntry {
|
||||
floorId: string
|
||||
pois: RenderPoi[]
|
||||
group: THREE.Group
|
||||
markerSize: number
|
||||
}
|
||||
|
||||
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -154,8 +167,8 @@ const props = withDefaults(defineProps<{
|
||||
routePreview: null,
|
||||
showRoute: false,
|
||||
autoSwitch: true,
|
||||
autoSwitchThresholdLow: 1.0,
|
||||
autoSwitchThresholdHigh: 1.3,
|
||||
autoSwitchThresholdLow: 1.45,
|
||||
autoSwitchThresholdHigh: 3.8,
|
||||
autoSwitchCooldown: 2000
|
||||
})
|
||||
|
||||
@@ -199,6 +212,9 @@ let dracoLoader: DRACOLoader | null = null
|
||||
let activeModel: THREE.Object3D | null = null
|
||||
let poiGroup: THREE.Group | null = null
|
||||
let routeGroup: THREE.Group | null = null
|
||||
let activeRoutePreviewSignature = ''
|
||||
const poiDataCache = new Map<string, RenderPoi[]>()
|
||||
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
|
||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||
let activeFocusPulseSprite: THREE.Sprite | null = null
|
||||
let activeFocusBaseSprite: THREE.Sprite | null = null
|
||||
@@ -218,11 +234,20 @@ let autoSwitchOverviewMaxDim = 0
|
||||
// 自动切换状态
|
||||
let isAutoSwitchLocked = false
|
||||
let lastAutoSwitchTime = 0
|
||||
let lastControlDistance = 0
|
||||
let floorAutoSwitchProtectedUntil = 0
|
||||
let floorExitThresholdExceededSince = 0
|
||||
let floorExitZoomOutAccumulatedDistance = 0
|
||||
let autoSwitchTemporarilyDisabled = false
|
||||
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isProgrammaticCameraChange = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const floorAutoSwitchProtectionMs = 1600
|
||||
const floorExitZoomOutIntentMinDeltaRatio = 0.01
|
||||
const floorExitZoomOutAccumulatedRatio = 0.12
|
||||
const floorExitThresholdHoldMs = 650
|
||||
|
||||
const syncControlInteractionOptions = () => {
|
||||
if (!controls) return
|
||||
|
||||
@@ -549,8 +574,7 @@ const updatePoiVisibilityByDistance = () => {
|
||||
const acceptedPositions: THREE.Vector2[] = []
|
||||
let visibleCount = 0
|
||||
|
||||
const candidates = poiGroup.children
|
||||
.filter((child): child is THREE.Sprite => child instanceof THREE.Sprite && !isPoiAuxiliarySprite(child))
|
||||
const candidates = getPoiSprites()
|
||||
.map((sprite) => {
|
||||
const poi = sprite.userData.poi as RenderPoi | undefined
|
||||
const screenPosition = sprite.position.clone().project(camera!)
|
||||
@@ -744,27 +768,195 @@ const handleResize = () => {
|
||||
renderer.setSize(width, height)
|
||||
}
|
||||
|
||||
const disposeObject = (object: THREE.Object3D) => {
|
||||
const materialTextureKeys = [
|
||||
'map',
|
||||
'alphaMap',
|
||||
'aoMap',
|
||||
'bumpMap',
|
||||
'normalMap',
|
||||
'displacementMap',
|
||||
'roughnessMap',
|
||||
'metalnessMap',
|
||||
'emissiveMap',
|
||||
'specularMap',
|
||||
'envMap',
|
||||
'lightMap',
|
||||
'gradientMap',
|
||||
'matcap',
|
||||
'clearcoatMap',
|
||||
'clearcoatNormalMap',
|
||||
'clearcoatRoughnessMap',
|
||||
'sheenColorMap',
|
||||
'sheenRoughnessMap',
|
||||
'transmissionMap',
|
||||
'thicknessMap',
|
||||
'iridescenceMap',
|
||||
'iridescenceThicknessMap'
|
||||
] as const
|
||||
|
||||
const collectMaterialTextures = (material: THREE.Material | null | undefined) => {
|
||||
const textures = new Set<THREE.Texture>()
|
||||
if (!material) return textures
|
||||
|
||||
const materialRecord = material as unknown as Record<string, unknown>
|
||||
materialTextureKeys.forEach((key) => {
|
||||
const texture = materialRecord[key]
|
||||
if (texture instanceof THREE.Texture) {
|
||||
textures.add(texture)
|
||||
}
|
||||
})
|
||||
|
||||
return textures
|
||||
}
|
||||
|
||||
const disposeMaterialTextures = (
|
||||
material: THREE.Material,
|
||||
protectedResources: ReusableModelResources,
|
||||
disposedTextures: Set<THREE.Texture>
|
||||
) => {
|
||||
collectMaterialTextures(material).forEach((texture) => {
|
||||
if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) {
|
||||
texture.dispose()
|
||||
disposedTextures.add(texture)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const collectReusableModelResources = (object: THREE.Object3D | null): ReusableModelResources => {
|
||||
const geometries = new Set<THREE.BufferGeometry>()
|
||||
const materials = new Set<THREE.Material>()
|
||||
const textures = new Set<THREE.Texture>()
|
||||
|
||||
object?.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
|
||||
if (child.geometry) {
|
||||
geometries.add(child.geometry)
|
||||
}
|
||||
|
||||
const childMaterials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
childMaterials.forEach((material) => {
|
||||
if (!material) return
|
||||
|
||||
materials.add(material)
|
||||
collectMaterialTextures(material).forEach((texture) => textures.add(texture))
|
||||
})
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Sprite && child.material) {
|
||||
materials.add(child.material)
|
||||
collectMaterialTextures(child.material).forEach((texture) => textures.add(texture))
|
||||
}
|
||||
})
|
||||
|
||||
return { geometries, materials, textures }
|
||||
}
|
||||
|
||||
const disposeObject = (
|
||||
object: THREE.Object3D,
|
||||
protectedResources = collectReusableModelResources(null)
|
||||
) => {
|
||||
const disposedGeometries = new Set<THREE.BufferGeometry>()
|
||||
const disposedMaterials = new Set<THREE.Material>()
|
||||
const disposedTextures = new Set<THREE.Texture>()
|
||||
|
||||
object.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry?.dispose()
|
||||
if (
|
||||
child.geometry
|
||||
&& !protectedResources.geometries.has(child.geometry)
|
||||
&& !disposedGeometries.has(child.geometry)
|
||||
) {
|
||||
child.geometry.dispose()
|
||||
disposedGeometries.add(child.geometry)
|
||||
}
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
materials.forEach((material) => material?.dispose())
|
||||
materials.forEach((material) => {
|
||||
if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
|
||||
disposeMaterialTextures(material, protectedResources, disposedTextures)
|
||||
material.dispose()
|
||||
disposedMaterials.add(material)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Sprite) {
|
||||
child.material.map?.dispose()
|
||||
child.material.dispose()
|
||||
if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
|
||||
disposeMaterialTextures(child.material, protectedResources, disposedTextures)
|
||||
child.material.dispose()
|
||||
disposedMaterials.add(child.material)
|
||||
}
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Line) {
|
||||
child.geometry?.dispose()
|
||||
if (
|
||||
child.geometry
|
||||
&& !protectedResources.geometries.has(child.geometry)
|
||||
&& !disposedGeometries.has(child.geometry)
|
||||
) {
|
||||
child.geometry.dispose()
|
||||
disposedGeometries.add(child.geometry)
|
||||
}
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
materials.forEach((material) => material?.dispose())
|
||||
materials.forEach((material) => {
|
||||
if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
|
||||
disposeMaterialTextures(material, protectedResources, disposedTextures)
|
||||
material.dispose()
|
||||
disposedMaterials.add(material)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isPoiMarkerSprite = (child: THREE.Object3D): child is THREE.Sprite => (
|
||||
child instanceof THREE.Sprite
|
||||
&& Boolean(child.userData.poi)
|
||||
&& !isPoiAuxiliarySprite(child)
|
||||
)
|
||||
|
||||
const getPoiSprites = () => {
|
||||
const sprites: THREE.Sprite[] = []
|
||||
|
||||
poiGroup?.traverse((child) => {
|
||||
if (isPoiMarkerSprite(child)) {
|
||||
sprites.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
return sprites
|
||||
}
|
||||
|
||||
const detachPoiMarkerGroups = () => {
|
||||
poiMarkerGroupCache.forEach((entry) => {
|
||||
entry.group.parent?.remove(entry.group)
|
||||
})
|
||||
}
|
||||
|
||||
const disposePoiMarkerCache = () => {
|
||||
detachPoiMarkerGroups()
|
||||
poiMarkerGroupCache.forEach((entry) => {
|
||||
disposeObject(entry.group)
|
||||
})
|
||||
poiMarkerGroupCache.clear()
|
||||
poiDataCache.clear()
|
||||
}
|
||||
|
||||
const clearPoiGroupChildren = () => {
|
||||
if (!poiGroup) return
|
||||
|
||||
while (poiGroup.children.length) {
|
||||
const child = poiGroup.children[0]
|
||||
poiGroup.remove(child)
|
||||
|
||||
if (child instanceof THREE.Group) {
|
||||
const cachedEntry = [...poiMarkerGroupCache.values()].find((entry) => entry.group === child)
|
||||
if (cachedEntry) continue
|
||||
}
|
||||
|
||||
disposeObject(child)
|
||||
}
|
||||
}
|
||||
|
||||
const getMaterialColor = (material: THREE.Material) => {
|
||||
const candidate = material as THREE.Material & { color?: unknown }
|
||||
return candidate.color instanceof THREE.Color ? candidate.color : undefined
|
||||
@@ -943,6 +1135,7 @@ const disposeCachedOverviewModel = () => {
|
||||
}
|
||||
|
||||
const clearRoutePreview = () => {
|
||||
activeRoutePreviewSignature = ''
|
||||
if (!routeGroup) return
|
||||
|
||||
while (routeGroup.children.length) {
|
||||
@@ -1073,15 +1266,54 @@ const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number
|
||||
})
|
||||
}
|
||||
|
||||
const renderRoutePreview = () => {
|
||||
clearRoutePreview()
|
||||
const routePositionSignature = (position: [number, number, number]) => (
|
||||
position.map((value) => Number(value.toFixed(3))).join(',')
|
||||
)
|
||||
|
||||
const getRoutePreviewSignature = (
|
||||
route: GuideRouteResult,
|
||||
visibleSegments: GuideRouteFloorSegment[],
|
||||
markerSize: number
|
||||
) => JSON.stringify({
|
||||
routeId: route.id,
|
||||
view: activeView.value,
|
||||
floor: activeView.value === 'floor' ? currentFloor.value : '',
|
||||
markerSize: Number(markerSize.toFixed(3)),
|
||||
start: {
|
||||
poiId: route.start.poiId,
|
||||
floorId: route.start.floorId,
|
||||
position: routePositionSignature(route.start.position)
|
||||
},
|
||||
end: {
|
||||
poiId: route.end.poiId,
|
||||
floorId: route.end.floorId,
|
||||
position: routePositionSignature(route.end.position)
|
||||
},
|
||||
segments: visibleSegments.map((segment) => ({
|
||||
floorId: segment.floorId,
|
||||
points: segment.points.map((point) => `${point.nodeId}:${routePositionSignature(point.position)}`)
|
||||
})),
|
||||
connectors: route.connectorPoints
|
||||
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
|
||||
.map((point) => `${point.nodeId}:${point.floorId}:${routePositionSignature(point.position)}`)
|
||||
})
|
||||
|
||||
const renderRoutePreview = () => {
|
||||
if (!props.showRoute || !props.routePreview || !routeGroup || !scene) {
|
||||
clearRoutePreview()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.showRoute || !props.routePreview || !routeGroup || !scene) return
|
||||
|
||||
const route = props.routePreview
|
||||
const targetRouteGroup = routeGroup
|
||||
const visibleSegments = getVisibleRouteSegments(route)
|
||||
const markerSize = Math.max(getPoiMarkerSize() * 0.82, 2.8)
|
||||
const signature = getRoutePreviewSignature(route, visibleSegments, markerSize)
|
||||
if (signature === activeRoutePreviewSignature) return
|
||||
|
||||
clearRoutePreview()
|
||||
activeRoutePreviewSignature = signature
|
||||
|
||||
visibleSegments.forEach((segment) => {
|
||||
if (segment.points.length < 2) return
|
||||
@@ -1104,7 +1336,7 @@ const clearSceneData = () => {
|
||||
if (activeModel === cachedOverviewModel) {
|
||||
cachedOverviewModel.visible = false
|
||||
} else {
|
||||
disposeObject(activeModel)
|
||||
disposeObject(activeModel, collectReusableModelResources(cachedOverviewModel))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1114,14 +1346,7 @@ const clearSceneData = () => {
|
||||
disposeFocusBase()
|
||||
selectedPOI.value = null
|
||||
clearRoutePreview()
|
||||
|
||||
if (poiGroup) {
|
||||
while (poiGroup.children.length) {
|
||||
const child = poiGroup.children[0]
|
||||
poiGroup.remove(child)
|
||||
disposeObject(child)
|
||||
}
|
||||
}
|
||||
clearPoiGroupChildren()
|
||||
}
|
||||
|
||||
const loadModel = (url: string, label: string, loadToken?: number) => new Promise<GLTF>((resolve, reject) => {
|
||||
@@ -1191,6 +1416,63 @@ const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
const getSharedMultiFloorModelUrl = (floorsToLoad: FloorIndexItem[]) => {
|
||||
if (!floorsToLoad.length || !floorsToLoad.every((floor) => floor.sharedModelAsset)) return ''
|
||||
|
||||
const [firstFloor] = floorsToLoad
|
||||
return floorsToLoad.every((floor) => floor.modelUrl === firstFloor.modelUrl)
|
||||
? firstFloor.modelUrl
|
||||
: ''
|
||||
}
|
||||
|
||||
const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => {
|
||||
const floorModel = sourceModel.clone(true)
|
||||
floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
|
||||
floorModel.userData.floorId = floor.floorId
|
||||
prepareModel(floorModel)
|
||||
applyModelVisibilityForView(floorModel, 'floor', floor.floorId)
|
||||
|
||||
return {
|
||||
floor,
|
||||
label: formatFloorLabel(floor.floorId),
|
||||
model: floorModel,
|
||||
size: getObjectSize(floorModel)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeDetachedMultiFloorModels = (
|
||||
items: MultiFloorModelItem[],
|
||||
group: THREE.Group,
|
||||
protectedResources = collectReusableModelResources(null)
|
||||
) => {
|
||||
items
|
||||
.filter((item) => !group.children.includes(item.model))
|
||||
.forEach((item) => disposeObject(item.model, protectedResources))
|
||||
}
|
||||
|
||||
const updateLastControlDistance = () => {
|
||||
if (!controls) return
|
||||
|
||||
const distance = controls.getDistance()
|
||||
if (Number.isFinite(distance)) {
|
||||
lastControlDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
const markFloorAutoSwitchEntry = () => {
|
||||
floorAutoSwitchProtectedUntil = Date.now() + floorAutoSwitchProtectionMs
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
updateLastControlDistance()
|
||||
}
|
||||
|
||||
const resetAutoSwitchDistanceTracking = () => {
|
||||
floorAutoSwitchProtectedUntil = 0
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
updateLastControlDistance()
|
||||
}
|
||||
|
||||
const checkAutoSwitch = () => {
|
||||
// 检查是否启用自动切换
|
||||
if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) {
|
||||
@@ -1215,18 +1497,23 @@ const checkAutoSwitch = () => {
|
||||
|
||||
// 获取当前距离
|
||||
const distance = controls.getDistance()
|
||||
const previousDistance = lastControlDistance || distance
|
||||
const distanceDelta = distance - previousDistance
|
||||
lastControlDistance = distance
|
||||
|
||||
// 自动切换使用全馆稳定尺寸,避免单楼层可见性过滤压低 floor -> overview 阈值。
|
||||
const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel)
|
||||
|
||||
const thresholdLow = maxDim * props.autoSwitchThresholdLow
|
||||
const thresholdHigh = maxDim * props.autoSwitchThresholdHigh
|
||||
const enterFloorAt = maxDim * props.autoSwitchThresholdLow
|
||||
const exitFloorAt = maxDim * props.autoSwitchThresholdHigh
|
||||
|
||||
// 判断是否需要切换
|
||||
if (activeView.value === 'overview' && distance < thresholdLow) {
|
||||
if (activeView.value === 'overview' && distance < enterFloorAt) {
|
||||
// 从建筑外观自动切换到单楼层
|
||||
isAutoSwitchLocked = true
|
||||
lastAutoSwitchTime = now
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
|
||||
void runAutoSwitchLoad(
|
||||
{
|
||||
@@ -1238,10 +1525,52 @@ const checkAutoSwitch = () => {
|
||||
() => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
|
||||
'单楼层模型加载失败'
|
||||
)
|
||||
} else if (activeView.value === 'floor' && !props.targetFocus && distance > thresholdHigh) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeView.value !== 'floor' || props.targetFocus) {
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (now < floorAutoSwitchProtectedUntil) {
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
return
|
||||
}
|
||||
|
||||
const zoomOutIntentDelta = Math.max(maxDim * floorExitZoomOutIntentMinDeltaRatio, 1)
|
||||
const isClearZoomOutIntent = distanceDelta > zoomOutIntentDelta
|
||||
const isBeyondExitThreshold = distance > exitFloorAt
|
||||
|
||||
if (!isBeyondExitThreshold) {
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (isClearZoomOutIntent) {
|
||||
floorExitZoomOutAccumulatedDistance += distanceDelta
|
||||
if (!floorExitThresholdExceededSince) {
|
||||
floorExitThresholdExceededSince = now
|
||||
}
|
||||
}
|
||||
|
||||
const requiredZoomOutDistance = Math.max(maxDim * floorExitZoomOutAccumulatedRatio, 8)
|
||||
if (
|
||||
!floorExitThresholdExceededSince
|
||||
|| floorExitZoomOutAccumulatedDistance < requiredZoomOutDistance
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (now - floorExitThresholdExceededSince >= floorExitThresholdHoldMs) {
|
||||
// 从单楼层自动切换到完整外围模型
|
||||
isAutoSwitchLocked = true
|
||||
lastAutoSwitchTime = now
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
|
||||
void runAutoSwitchLoad(
|
||||
{
|
||||
@@ -1362,10 +1691,13 @@ const zoomCamera = (direction: 'in' | 'out') => {
|
||||
offset.setLength(nextDistance)
|
||||
camera.position.copy(controls.target).add(offset)
|
||||
controls.update()
|
||||
lastControlDistance = nextDistance
|
||||
|
||||
if (direction === 'in' && activeView.value === 'overview' && !isLoading.value && !isAutoSwitchLocked) {
|
||||
isAutoSwitchLocked = true
|
||||
lastAutoSwitchTime = Date.now()
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
|
||||
void runAutoSwitchLoad(
|
||||
{
|
||||
@@ -1401,6 +1733,7 @@ const loadOverview = async () => {
|
||||
cacheAutoSwitchOverviewMaxDim(activeModel)
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
return
|
||||
@@ -1426,6 +1759,7 @@ const loadOverview = async () => {
|
||||
cachedSharedModelUrl = packageData.overviewModelUrl
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
}
|
||||
@@ -1486,6 +1820,7 @@ const loadFloor = async (floorId: string) => {
|
||||
|
||||
if (!activeModel) throw createStaleModelLoadError()
|
||||
fitCameraToObject(activeModel)
|
||||
markFloorAutoSwitchEntry()
|
||||
|
||||
if (shouldRenderPoiMarkers.value) {
|
||||
await loadFloorPOIs(floor, loadToken)
|
||||
@@ -1508,9 +1843,81 @@ const loadMultiFloor = async () => {
|
||||
group.name = 'GuideMultiFloorModel'
|
||||
const orderedFloors = [...floorIndex.value].sort((a, b) => a.order - b.order)
|
||||
const loadedFloors: MultiFloorModelItem[] = []
|
||||
const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)
|
||||
|
||||
if (sharedModelUrl) {
|
||||
let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null
|
||||
|
||||
if (!sourceModel) {
|
||||
const gltf = await loadModel(sharedModelUrl, '正在加载多层共享模型', loadToken)
|
||||
assertCurrentModelLoad(loadToken, gltf.scene)
|
||||
|
||||
const targetScene = scene
|
||||
if (!targetScene) {
|
||||
disposeObject(gltf.scene)
|
||||
disposeObject(group)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
sourceModel = gltf.scene
|
||||
sourceModel.name = 'GuideOverviewModel'
|
||||
prepareModel(sourceModel)
|
||||
applyModelVisibilityForView(sourceModel, 'overview')
|
||||
cacheAutoSwitchOverviewMaxDim(sourceModel)
|
||||
cachedOverviewModel = sourceModel
|
||||
cachedSharedModelUrl = sharedModelUrl
|
||||
}
|
||||
|
||||
const sharedSourceModel = sourceModel
|
||||
const protectedResources = collectReusableModelResources(sharedSourceModel)
|
||||
|
||||
try {
|
||||
orderedFloors.forEach((floor, index) => {
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
const item = createMultiFloorItemFromSharedModel(sharedSourceModel, floor)
|
||||
loadedFloors.push(item)
|
||||
setProgress(
|
||||
18 + Math.round(((index + 1) / orderedFloors.length) * 72),
|
||||
`已准备 ${item.label}`
|
||||
)
|
||||
})
|
||||
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
applyMultiFloorLayout(loadedFloors)
|
||||
loadedFloors.forEach((item) => group.add(item.model))
|
||||
|
||||
const targetScene = scene
|
||||
if (!targetScene) {
|
||||
disposeObject(group, protectedResources)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
activeModel = group
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
return
|
||||
} catch (error) {
|
||||
disposeObject(group, protectedResources)
|
||||
disposeDetachedMultiFloorModels(loadedFloors, group, protectedResources)
|
||||
|
||||
if (isStaleModelLoadError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < orderedFloors.length; index += 1) {
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
try {
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
} catch (error) {
|
||||
disposeDetachedMultiFloorModels(loadedFloors, group)
|
||||
throw error
|
||||
}
|
||||
|
||||
const floor = orderedFloors[index]
|
||||
const label = formatFloorLabel(floor.floorId)
|
||||
@@ -1518,6 +1925,7 @@ const loadMultiFloor = async () => {
|
||||
if (!isCurrentModelLoad(loadToken)) {
|
||||
disposeObject(gltf.scene)
|
||||
disposeObject(group)
|
||||
disposeDetachedMultiFloorModels(loadedFloors, group)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
@@ -1538,19 +1946,27 @@ const loadMultiFloor = async () => {
|
||||
)
|
||||
}
|
||||
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
try {
|
||||
assertCurrentModelLoad(loadToken, group)
|
||||
} catch (error) {
|
||||
disposeDetachedMultiFloorModels(loadedFloors, group)
|
||||
throw error
|
||||
}
|
||||
|
||||
applyMultiFloorLayout(loadedFloors)
|
||||
loadedFloors.forEach((item) => group.add(item.model))
|
||||
|
||||
const targetScene = scene
|
||||
if (!targetScene) {
|
||||
disposeObject(group)
|
||||
disposeDetachedMultiFloorModels(loadedFloors, group)
|
||||
throw createStaleModelLoadError()
|
||||
}
|
||||
|
||||
activeModel = group
|
||||
targetScene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
}
|
||||
@@ -1873,25 +2289,16 @@ const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
|
||||
const updatePoiMarkerFocus = () => {
|
||||
if (!poiGroup) return
|
||||
|
||||
poiGroup.children.forEach((child) => {
|
||||
if (
|
||||
child instanceof THREE.Sprite
|
||||
&& !child.userData.isPoiLabel
|
||||
&& !child.userData.isPoiPulse
|
||||
&& !child.userData.isPoiBase
|
||||
) {
|
||||
const poi = child.userData.poi as RenderPoi | undefined
|
||||
setPoiSpriteFocusStyle(child, Boolean(poi && poi.id === activeFocusPoiId.value))
|
||||
}
|
||||
getPoiSprites().forEach((sprite) => {
|
||||
const poi = sprite.userData.poi as RenderPoi | undefined
|
||||
setPoiSpriteFocusStyle(sprite, Boolean(poi && poi.id === activeFocusPoiId.value))
|
||||
})
|
||||
|
||||
refreshPoiVisibilityByDistance()
|
||||
}
|
||||
|
||||
const findPoiSprite = (poiId: string) => (
|
||||
poiGroup?.children.find((child): child is THREE.Sprite => (
|
||||
child instanceof THREE.Sprite && (child.userData.poi as RenderPoi | undefined)?.id === poiId
|
||||
))
|
||||
getPoiSprites().find((sprite) => (sprite.userData.poi as RenderPoi | undefined)?.id === poiId)
|
||||
)
|
||||
|
||||
const findLoadedPoi = (poiId: string) => (
|
||||
@@ -1932,19 +2339,12 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
|
||||
return poi
|
||||
}
|
||||
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
||||
if (!poiGroup) return
|
||||
const createPoiMarkerGroup = (floorId: string, pois: RenderPoi[], markerSize: number) => {
|
||||
const group = new THREE.Group()
|
||||
group.name = `GuideModelPOI_${floorId}`
|
||||
group.userData.floorId = floorId
|
||||
|
||||
const validPois = (await props.modelSource.loadFloorPois(floor.floorId))
|
||||
.filter((poi) => shouldShowPoiInCurrentMode(poi))
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
||||
|
||||
const targetPoiGroup = poiGroup
|
||||
if (!targetPoiGroup) return
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
|
||||
validPois.forEach((poi) => {
|
||||
pois.forEach((poi) => {
|
||||
const [x, y, z] = poi.positionGltf!
|
||||
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
||||
sprite.position.set(x, y + markerSize * 0.5, z)
|
||||
@@ -1953,8 +2353,55 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
||||
sprite.userData.isCorePoi = corePoiCategories.has(poi.primaryCategory)
|
||||
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
||||
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
|
||||
targetPoiGroup.add(sprite)
|
||||
group.add(sprite)
|
||||
})
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
||||
if (!poiGroup) return
|
||||
|
||||
detachPoiMarkerGroups()
|
||||
if (entry.group.parent !== poiGroup) {
|
||||
poiGroup.add(entry.group)
|
||||
}
|
||||
updatePoiMarkerFocus()
|
||||
}
|
||||
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
||||
if (!poiGroup) return
|
||||
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
||||
|
||||
const cachedEntry = poiMarkerGroupCache.get(floor.floorId)
|
||||
if (cachedEntry) {
|
||||
attachPoiMarkerGroup(cachedEntry)
|
||||
return
|
||||
}
|
||||
|
||||
const cachedPois = poiDataCache.get(floor.floorId)
|
||||
const validPois = cachedPois || (await props.modelSource.loadFloorPois(floor.floorId))
|
||||
.filter((poi) => shouldShowPoiInCurrentMode(poi))
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
||||
|
||||
poiDataCache.set(floor.floorId, validPois)
|
||||
const markerSize = getPoiMarkerSize()
|
||||
const markerGroup = createPoiMarkerGroup(floor.floorId, validPois, markerSize)
|
||||
const entry: PoiMarkerCacheEntry = {
|
||||
floorId: floor.floorId,
|
||||
pois: validPois,
|
||||
group: markerGroup,
|
||||
markerSize
|
||||
}
|
||||
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||||
disposeObject(markerGroup)
|
||||
return
|
||||
}
|
||||
|
||||
poiMarkerGroupCache.set(floor.floorId, entry)
|
||||
attachPoiMarkerGroup(entry)
|
||||
}
|
||||
|
||||
const clearMapSelection = (shouldEmit = true) => {
|
||||
@@ -1997,7 +2444,7 @@ const handleSceneTap = (event: PointerEvent) => {
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
|
||||
if (activeView.value === 'floor' && poiGroup) {
|
||||
const poiHits = raycaster.intersectObjects(poiGroup.children, false)
|
||||
const poiHits = raycaster.intersectObjects(getPoiSprites(), false)
|
||||
const hit = poiHits.find((item) => (
|
||||
item.object.visible
|
||||
&& item.object instanceof THREE.Sprite
|
||||
@@ -2126,7 +2573,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
await loadFloor(request.floorId)
|
||||
isLoading.value = false
|
||||
emit('floorChange', request.floorId)
|
||||
} else if (shouldRenderPoiMarkers.value && poiGroup && poiGroup.children.length === 0) {
|
||||
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
|
||||
await loadFloorPOIs(floor, modelLoadVersion)
|
||||
}
|
||||
|
||||
@@ -2311,8 +2758,13 @@ const disposeScene = () => {
|
||||
programmaticCameraTimer = null
|
||||
}
|
||||
isProgrammaticCameraChange = false
|
||||
lastControlDistance = 0
|
||||
floorAutoSwitchProtectedUntil = 0
|
||||
floorExitThresholdExceededSince = 0
|
||||
floorExitZoomOutAccumulatedDistance = 0
|
||||
|
||||
clearSceneData()
|
||||
disposePoiMarkerCache()
|
||||
disposeCachedOverviewModel()
|
||||
scene?.clear()
|
||||
scene = null
|
||||
|
||||
Reference in New Issue
Block a user