diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 7eb547a..13ee5f5 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -124,6 +124,19 @@ interface MultiFloorModelItem { size: THREE.Vector3 } +interface ReusableModelResources { + geometries: Set + materials: Set + textures: Set +} + +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() +const poiMarkerGroupCache = new Map() 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 | null = null let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | 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() + if (!material) return textures + + const materialRecord = material as unknown as Record + 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 +) => { + 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() + const materials = new Set() + const textures = new Set() + + 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() + const disposedMaterials = new Set() + const disposedTextures = new Set() + 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((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 diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 621c4ec..7d56577 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -193,6 +193,9 @@ import type { import type { GuideRouteResult } from '@/domain/museum' +import { + isIndoorNavigableFloor +} from '@/domain/guideFloor' interface GuideFloorOption { id: string @@ -367,7 +370,8 @@ const indoorRendererRef = ref<{ disableAutoSwitchTemporarily?: (durationMs: number) => void } | null>(null) -const floorLabels = computed(() => props.floors.map((floor) => floor.label)) +const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor))) +const floorLabels = computed(() => indoorFloors.value.map((floor) => floor.label)) // #ifdef H5 const effectiveIndoorModelSource = computed(() => props.indoorModelSource) // #endif @@ -376,13 +380,13 @@ const showIndoorRightControls = computed(() => ( )) const activeFloorId = computed(() => { - const matchedFloor = props.floors.find((floor) => ( + const matchedFloor = indoorFloors.value.find((floor) => ( floor.id === props.activeFloor || floor.label === props.activeFloor )) if (matchedFloor) return matchedFloor.id const normalizedFloorId = props.normalizeFloorId(props.activeFloor) - const normalizedFloor = props.floors.find((floor) => ( + const normalizedFloor = indoorFloors.value.find((floor) => ( floor.id === normalizedFloorId || floor.label === normalizedFloorId )) return normalizedFloor?.id || normalizedFloorId @@ -497,7 +501,7 @@ const handleMoreTap = () => { } const handleThreeFloorChange = (floorId: string) => { - const floor = props.floors.find((item) => item.id === floorId) + const floor = indoorFloors.value.find((item) => item.id === floorId) emit('floorChange', floor?.label || floorId) emit('indoorViewChange', 'floor') emit('layerModeChange', 'single') diff --git a/src/components/navigation/RoutePointPicker.vue b/src/components/navigation/RoutePointPicker.vue index c7081d9..d1652b5 100644 --- a/src/components/navigation/RoutePointPicker.vue +++ b/src/components/navigation/RoutePointPicker.vue @@ -65,6 +65,9 @@ diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index 8c3aa97..d295222 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -7,6 +7,9 @@ import type { import { dataSourceConfig } from '@/config/dataSource' +import { + isIndoorNavigableFloor +} from '@/domain/guideFloor' import { formatSgsFloorLabel, toMuseumPoiFromSgs @@ -17,7 +20,8 @@ import { type SgsSdkFloorSummaryPayload } from '@/data/providers/sgsSdkApiProvider' import { - formatNavFloorLabel + formatNavFloorLabel, + isStaticIndoorNavigableFloorId } from '@/data/adapters/navAssetsAdapter' import { defaultStaticNavAssetsProvider, @@ -97,6 +101,7 @@ export class StaticGuideModelRepository implements GuideModelRepository { ]) const floors: GuideModelFloorAsset[] = [...floorIndex.floors] + .filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId)) .sort((a, b) => a.order - b.order) .map((floor) => ({ floorId: floor.floorId, @@ -115,7 +120,7 @@ export class StaticGuideModelRepository implements GuideModelRepository { async loadFloorPois(floorId: string) { const floorIndex = await this.provider.loadFloorIndex() const floor = floorIndex.floors.find((item) => item.floorId === floorId) - if (!floor) return [] + if (!floor || !isStaticIndoorNavigableFloorId(floor.floorId)) return [] const pois = await this.provider.loadFloorPois(floor.poiDataAsset) return pois @@ -131,7 +136,8 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { async loadPackage(): Promise { const manifest = await this.provider.getManifest() - const floors = sortSgsFloors(manifest.floors) + const allFloors = sortSgsFloors(manifest.floors) + const floors = allFloors.filter(isIndoorNavigableFloor) this.floorsCache = floors const bundles = await Promise.all( @@ -153,8 +159,13 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { }) .filter((asset) => asset.modelUrl) - const overviewFloorIndex = floors.findIndex((floor) => floor.floorCode === 'EXTERIOR') - const overviewModelUrl = floorAssets[overviewFloorIndex]?.modelUrl || floorAssets[0]?.modelUrl || '' + const overviewFloor = allFloors.find((floor) => !isIndoorNavigableFloor(floor)) + const overviewBundle = overviewFloor + ? await this.provider.getFloorBundle(String(overviewFloor.floorId)).catch(() => null) + : null + const overviewModelUrl = resolveSgsAssetUrl( + overviewBundle?.model?.modelUrl || overviewBundle?.model?.fallbackModelUrl + ) || floorAssets[0]?.modelUrl || '' return { overviewModelUrl, @@ -164,7 +175,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { async loadFloorPois(floorId: string) { const manifest = await this.provider.getManifest() - this.floorsCache = sortSgsFloors(manifest.floors) + this.floorsCache = sortSgsFloors(manifest.floors).filter(isIndoorNavigableFloor) const matchedFloor = this.floorsCache.find((floor) => ( String(floor.floorId) === floorId || floor.floorCode === floorId diff --git a/src/repositories/GuideRepository.ts b/src/repositories/GuideRepository.ts index 563bcb7..6686478 100644 --- a/src/repositories/GuideRepository.ts +++ b/src/repositories/GuideRepository.ts @@ -10,8 +10,13 @@ import type { import { NAV_ROUTE_READINESS } from '@/domain/guideReadiness' +import { + isIndoorNavigableFloor, + isPoiOnIndoorNavigableFloor +} from '@/domain/guideFloor' import { formatNavFloorLabel, + isStaticIndoorNavigableFloorId, navFloorIdFromLabel, toMuseumPoi } from '@/data/adapters/navAssetsAdapter' @@ -68,6 +73,11 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({ sourceObjectName: poi.sourceObjectName }) +const isSearchableIndoorPoi = (poi: MuseumPoi) => ( + searchableCategories.has(poi.primaryCategory.id) + && isPoiOnIndoorNavigableFloor(poi) +) + export interface GuideRepository { getAssetBaseUrl(): string getFloors(): Promise @@ -94,6 +104,7 @@ export class StaticGuideRepository implements GuideRepository { async getFloors() { const floorIndex = await this.provider.loadFloorIndex() return [...floorIndex.floors] + .filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId)) .sort((a, b) => b.order - a.order) .map((floor) => ({ id: floor.floorId, @@ -111,8 +122,8 @@ export class StaticGuideRepository implements GuideRepository { const pois = await this.provider.loadPoiIndex() this.poiCache = pois - .filter((poi) => searchableCategories.has(poi.primaryCategory)) .map(toMuseumPoi) + .filter(isSearchableIndoorPoi) return this.poiCache } @@ -208,8 +219,10 @@ export class SgsSdkGuideRepository implements GuideRepository { if (this.floorsCache) return this.floorsCache const manifest = await this.provider.getManifest() - this.floorAliases = buildSgsFloorAliases(manifest.floors) + const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor) + this.floorAliases = buildSgsFloorAliases(indoorFloors) this.floorsCache = [...manifest.floors] + .filter(isIndoorNavigableFloor) .sort((a, b) => Number(a.sortOrder || 0) - Number(b.sortOrder || 0)) .map(toMuseumFloorFromSgs) @@ -228,15 +241,18 @@ export class SgsSdkGuideRepository implements GuideRepository { if (this.poiCache) return this.poiCache const manifest = await this.provider.getManifest() - this.floorAliases = buildSgsFloorAliases(manifest.floors) + const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor) + const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId))) + this.floorAliases = buildSgsFloorAliases(indoorFloors) const poiGroups = await Promise.all( - manifest.floors.map((floor) => this.provider.getFloorPois(String(floor.floorId))) + indoorFloors.map((floor) => this.provider.getFloorPois(String(floor.floorId))) ) this.poiCache = poiGroups .flat() .map((poi) => toMuseumPoiFromSgs(poi, manifest.floors)) .filter((poi) => poi.id && poi.name) + .filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi)) return this.poiCache } @@ -271,11 +287,11 @@ export class SgsSdkGuideRepository implements GuideRepository { const normalizedFloorId = this.normalizeFloorId(floorId) try { const diagnostics = await this.provider.getFloorDiagnostics(normalizedFloorId) - return toGuideFloorDetailFromSgs(diagnostics) + return isIndoorNavigableFloor(diagnostics) ? toGuideFloorDetailFromSgs(diagnostics) : null } catch { const manifest = await this.provider.getManifest() const floor = manifest.floors.find((item) => String(item.floorId) === normalizedFloorId) - return floor ? toGuideFloorDetailFromSgs(floor) : null + return floor && isIndoorNavigableFloor(floor) ? toGuideFloorDetailFromSgs(floor) : null } } diff --git a/src/repositories/GuideRouteRepository.ts b/src/repositories/GuideRouteRepository.ts index 6ec22f9..3a6745d 100644 --- a/src/repositories/GuideRouteRepository.ts +++ b/src/repositories/GuideRouteRepository.ts @@ -3,6 +3,9 @@ import type { GuideRouteResult, GuideRouteTarget } from '@/domain/museum' +import { + isIndoorNavigableFloor +} from '@/domain/guideFloor' import { applyRouteReadinessGate, NAV_ROUTE_UNAVAILABLE_MESSAGE, @@ -164,7 +167,10 @@ export class StaticGuideRouteRepository implements GuideRouteRepository { async listRouteTargets() { const dataset = await this.loadDataset() - return dataset.targets + return dataset.targets.filter((target) => isIndoorNavigableFloor({ + floorId: target.floorId, + label: target.floorLabel + })) } async findRoute(startPoiId: string, endPoiId: string) { diff --git a/src/repositories/SdkGuideRouteRepository.ts b/src/repositories/SdkGuideRouteRepository.ts index 015c996..bd096f1 100644 --- a/src/repositories/SdkGuideRouteRepository.ts +++ b/src/repositories/SdkGuideRouteRepository.ts @@ -6,6 +6,9 @@ import type { import { NAV_ROUTE_READINESS } from '@/domain/guideReadiness' +import { + isIndoorNavigableFloor +} from '@/domain/guideFloor' import { GuideRouteError } from '@/repositories/GuideRouteRepository' @@ -63,7 +66,7 @@ export class SdkGuideRouteRepository { async getRouteReadiness(): Promise { try { const diagnostics = await this.sdkApiProvider.getMapDiagnostics() - const floors = diagnostics.floors || [] + const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor) if (!floors.length) { return createUnavailableReadiness('SDK 地图楼层数据为空') @@ -115,7 +118,7 @@ export class SdkGuideRouteRepository { if (this.targetsCache) return this.targetsCache const diagnostics = await this.sdkApiProvider.getMapDiagnostics() - const floors = diagnostics.floors || [] + const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor) const targets: GuideRouteTarget[] = [] @@ -196,7 +199,7 @@ export class SdkGuideRouteRepository { // 获取原始 floorId 用于 API 调用 const diagnostics = await this.sdkApiProvider.getMapDiagnostics() const floorIdMap = new Map() - for (const floor of diagnostics.floors || []) { + for (const floor of (diagnostics.floors || []).filter(isIndoorNavigableFloor)) { floorIdMap.set(toAppFloorId(floor.floorId), stringifyId(floor.floorId)) } const rawStartFloorId = floorIdMap.get(startTarget.floorId) || startTarget.floorId diff --git a/src/usecases/guideRouteUseCase.ts b/src/usecases/guideRouteUseCase.ts index 6f24d72..92cd386 100644 --- a/src/usecases/guideRouteUseCase.ts +++ b/src/usecases/guideRouteUseCase.ts @@ -3,6 +3,9 @@ import type { GuideRouteResult, GuideRouteTarget } from '@/domain/museum' +import { + isIndoorNavigableFloor +} from '@/domain/guideFloor' import { GuideRouteError, type GuideRouteRepository @@ -47,7 +50,11 @@ export class GuideRouteUseCase { async listTargets() { if (this.targetsCache) return this.targetsCache - this.targetsCache = await this.repository.listRouteTargets() + this.targetsCache = (await this.repository.listRouteTargets()) + .filter((target) => isIndoorNavigableFloor({ + floorId: target.floorId, + label: target.floorLabel + })) return this.targetsCache }