From 23769239158f936c2e2dfd3839fee0262be17841 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Tue, 30 Jun 2026 20:10:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E8=A7=88=E6=A5=BC?= =?UTF-8?q?=E5=B1=82=E5=88=87=E6=8D=A2=E7=82=B9=E4=BD=8D=E9=94=99=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/ThreeMap.vue | 439 ++++++++++++++++++-- src/components/navigation/GuideMapShell.vue | 16 +- src/pages/index/index.vue | 2 +- src/repositories/GuideModelRepository.ts | 35 +- 4 files changed, 457 insertions(+), 35 deletions(-) diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index b94e071..791503a 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -85,6 +85,7 @@ interface LoadFloorOptions { preserveCurrentSceneUntilReady?: boolean suppressProgress?: boolean allowOverviewFallback?: boolean + detachPoiBeforeLoad?: boolean } interface LoadModelOptions { @@ -118,6 +119,7 @@ interface PoiSpriteUserData { hitTargetBaseScale?: number visibleMarker?: THREE.Sprite hitTarget?: THREE.Sprite + labelSprite?: THREE.Sprite feedbackUntil?: number labelBaseScaleX?: number labelBaseScaleY?: number @@ -303,6 +305,8 @@ const poiHitTargetScaleMultiplier = 4.8 const poiHitTargetCoreScaleMultiplier = 5.4 const poiScreenHitRadiusPx = 64 const poiScreenHitRadiusCorePx = 76 +const poiAmbientLabelHallSpacingPx = 92 +const poiAmbientLabelServiceSpacingPx = 72 const indoorContextLockMs = 5000 const indoorGestureExitGraceMs = 900 const overviewEntryIntentWindowMs = 1200 @@ -347,6 +351,19 @@ const poiCategoryPriority: Record = { operation_experience: 46 } +const isHallPoi = (poi: RenderPoi) => ( + poi.kind === 'hall' + || poi.primaryCategory === 'exhibition_hall' + || poi.primaryCategory === 'touring_poi' +) + +const isServiceFacilityPoi = (poi: RenderPoi) => ( + poi.primaryCategory === 'basic_service_facility' + || poi.primaryCategory === 'accessibility_special_service' +) + +const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation' + const poiGlyphMap: Record = { accessible_restroom: '无', accessibility: '无', @@ -648,7 +665,6 @@ const getActiveModelSpan = () => { } const getPoiVisibilityTier = (): PoiVisibilityTier => { - if (activeView.value === 'floor') return 'full' if (!controls || !activeModel) return 'full' const ratio = controls.getDistance() / getActiveModelSpan() @@ -663,12 +679,19 @@ const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => { return true } + if (activeView.value === 'floor' && isHallPoi(poi)) { + return true + } + if (tier === 'tight') { - return corePoiCategories.has(poi.primaryCategory) + return corePoiCategories.has(poi.primaryCategory) && !isTransportPoi(poi) } if (tier === 'balanced') { - return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi' + return !isTransportPoi(poi) && ( + corePoiCategories.has(poi.primaryCategory) + || poi.primaryCategory === 'touring_poi' + ) } return true @@ -689,8 +712,173 @@ const getPoiScreenSpacing = (tier: PoiVisibilityTier) => { const getPoiPriority = (poi: RenderPoi) => { const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20 const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0 + const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0 + const transportPenalty = isTransportPoi(poi) ? 18 : 0 - return selectedBoost + categoryPriority + return selectedBoost + hallBoost + categoryPriority - transportPenalty +} + +const getPoiLabelDensityTier = (): PoiVisibilityTier => { + if (!controls || !activeModel) return 'full' + + const ratio = controls.getDistance() / getActiveModelSpan() + + if (ratio >= 0.42) return 'tight' + if (ratio >= 0.26) return 'balanced' + return 'full' +} + +const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => ( + isHallPoi(poi) + || isServiceFacilityPoi(poi) +) + +const shouldShowAmbientPoiLabel = ( + poi: RenderPoi, + markerVisible: boolean, + densityTier: PoiVisibilityTier +) => { + if (!markerVisible || activeView.value !== 'floor') return false + if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false + if (isHallPoi(poi)) return true + if (isTransportPoi(poi)) return false + if (isServiceFacilityPoi(poi)) return densityTier === 'full' + + return false +} + +const getPoiAmbientLabelPriority = (poi: RenderPoi) => { + if (isHallPoi(poi)) return getPoiPriority(poi) + 120 + if (isServiceFacilityPoi(poi)) return getPoiPriority(poi) + 20 + + return getPoiPriority(poi) +} + +const getAmbientLabelScaleBoost = (poi: RenderPoi) => { + if (!controls || !activeModel) return 1 + + const distanceRatio = controls.getDistance() / getActiveModelSpan() + const baseBoost = Math.min(1.55, Math.max(0.92, distanceRatio / 0.28)) + return isHallPoi(poi) ? Math.max(1, baseBoost) : baseBoost +} + +const updateAmbientLabelScale = (labelSprite: THREE.Sprite, poi: RenderPoi) => { + const userData = getPoiSpriteUserData(labelSprite) + const baseScaleX = typeof userData.labelBaseScaleX === 'number' + ? userData.labelBaseScaleX + : labelSprite.scale.x + const baseScaleY = typeof userData.labelBaseScaleY === 'number' + ? userData.labelBaseScaleY + : labelSprite.scale.y + const boost = getAmbientLabelScaleBoost(poi) + + labelSprite.scale.set(baseScaleX * boost, baseScaleY * boost, 1) +} + +const getProjectedScreenPosition = (object: THREE.Object3D) => { + if (!camera || !renderer) return null + + const projected = object.position.clone().project(camera) + if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) { + return null + } + + return new THREE.Vector2( + (projected.x + 1) * renderer.domElement.clientWidth * 0.5, + (1 - projected.y) * renderer.domElement.clientHeight * 0.5 + ) +} + +const getAmbientLabelBounds = (center: THREE.Vector2, poi: RenderPoi) => { + const width = isHallPoi(poi) ? 138 : 112 + const height = isHallPoi(poi) ? 44 : 34 + + return { + left: center.x - width * 0.5, + right: center.x + width * 0.5, + top: center.y - height * 0.5, + bottom: center.y + height * 0.5 + } +} + +const labelBoundsOverlap = ( + left: ReturnType, + right: ReturnType, + spacing: number +) => ( + left.left - spacing < right.right + && left.right + spacing > right.left + && left.top - spacing < right.bottom + && left.bottom + spacing > right.top +) + +const updateAmbientPoiLabels = () => { + if (!poiGroup || !camera || !renderer) return + + const densityTier = getPoiLabelDensityTier() + const acceptedBounds: Array> = [] + const markerSize = getPoiMarkerSize() + const candidates = getPoiSprites() + .map((sprite) => { + const poi = sprite.userData.poi as RenderPoi | undefined + const labelSprite = getPoiSpriteUserData(sprite).labelSprite + + return poi && labelSprite + ? { + marker: sprite, + labelSprite, + poi, + priority: getPoiAmbientLabelPriority(poi) + } + : null + }) + .filter((candidate): candidate is { + marker: THREE.Sprite + labelSprite: THREE.Sprite + poi: RenderPoi + priority: number + } => Boolean(candidate)) + .sort((left, right) => right.priority - left.priority) + + candidates.forEach(({ marker, labelSprite, poi }) => { + if (!poi.positionGltf || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) { + labelSprite.visible = false + return + } + + updateAmbientLabelScale(labelSprite, poi) + + const [x, y, z] = poi.positionGltf + const baseOffset = markerSize * (isHallPoi(poi) ? 1.82 : 1.5) + const offsetAttempts = [ + 0, + markerSize * 0.82, + -markerSize * 0.62, + markerSize * 1.48, + -markerSize * 1.08 + ] + const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx + let accepted = false + + for (const offset of offsetAttempts) { + labelSprite.position.set(x, y + baseOffset + offset, z) + const screenPosition = getProjectedScreenPosition(labelSprite) + if (!screenPosition) continue + + const bounds = getAmbientLabelBounds(screenPosition, poi) + const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing)) + if (overlaps) continue + + labelSprite.visible = true + acceptedBounds.push(bounds) + accepted = true + break + } + + if (!accepted) { + labelSprite.visible = false + } + }) } const updatePoiVisibilityByDistance = () => { @@ -725,16 +913,21 @@ const updatePoiVisibilityByDistance = () => { } const isSelected = poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview' + const isPinnedHall = activeView.value === 'floor' && isHallPoi(poi) const categoryVisible = shouldShowPoiAtDistance(poi, tier) const isTooClose = spacing > 0 && acceptedPositions.some((position) => position.distanceTo(screenPosition) < spacing) const exceedsLimit = Number.isFinite(limit) && visibleCount >= limit - if (!categoryVisible || (!isSelected && (isTooClose || exceedsLimit))) { + if (!categoryVisible || (!isSelected && !isPinnedHall && (isTooClose || exceedsLimit))) { sprite.visible = false const hitTarget = getPoiSpriteUserData(sprite).hitTarget + const labelSprite = getPoiSpriteUserData(sprite).labelSprite if (hitTarget) { hitTarget.visible = false } + if (labelSprite) { + labelSprite.visible = false + } return } @@ -746,6 +939,8 @@ const updatePoiVisibilityByDistance = () => { acceptedPositions.push(screenPosition) visibleCount += 1 }) + + updateAmbientPoiLabels() } const refreshPoiVisibilityByDistance = () => { @@ -903,6 +1098,8 @@ const setProgress = (progress: number, message: string) => { } const staleModelLoadMessage = 'STALE_MODEL_LOAD' +let isFloorSwitchLocked = false +let queuedFloorSwitchId = '' const startModelLoad = () => { modelLoadVersion += 1 @@ -933,6 +1130,22 @@ const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) throw createStaleModelLoadError() } +const assertActiveFloorModelState = (loadToken: number, requestedFloorId: string) => { + assertCurrentModelLoad(loadToken) + + const activeModelFloorId = typeof activeModel?.userData.floorId === 'string' + ? activeModel.userData.floorId + : '' + if ( + activeView.value !== 'floor' + || currentFloor.value !== requestedFloorId + || !activeModel + || activeModelFloorId !== requestedFloorId + ) { + throw new Error(`楼层模型状态不一致:请求 ${requestedFloorId},当前 ${currentFloor.value || '未知'},模型 ${activeModelFloorId || '未知'}`) + } +} + const waitForContainer = async () => { await nextTick() @@ -1272,6 +1485,14 @@ const detachPoiMarkerGroups = () => { }) } +const detachActivePoiLayer = () => { + disposeFocusLabel() + disposeFocusPulse() + disposeFocusBase() + clearRoutePreview() + clearPoiGroupChildren() +} + const disposePoiMarkerCache = () => { detachPoiMarkerGroups() poiMarkerGroupCache.forEach((entry) => { @@ -1783,10 +2004,39 @@ const canLoadFloorSilently = (floorId: string) => { return Boolean(floor && canAttachCachedSharedModel(floor.modelUrl)) } -const getDefaultFloorId = () => ( - floorIndex.value.find((floor) => floor.floorId === props.initialFloorId)?.floorId - || floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId +const getFloorMatchKeys = (floor: FloorIndexItem) => ( + [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])] + .filter((value): value is string => Boolean(value)) +) + +const resolveFloorIdFromRequest = (requestedFloorId?: string | null) => { + const requested = requestedFloorId?.trim() + if (!requested) return '' + + const normalizedRequest = normalizeModelMatchKey(requested) + return floorIndex.value.find((floor) => getFloorMatchKeys(floor).some((key) => ( + key === requested + || key.toLowerCase() === requested.toLowerCase() + || normalizeModelMatchKey(key) === normalizedRequest + )))?.floorId || '' +} + +const isGroundEntryFloor = (floor: FloorIndexItem) => ( + getFloorMatchKeys(floor).some((key) => { + const normalizedKey = normalizeModelMatchKey(key) + return normalizedKey === 'l1' || normalizedKey === '1f' + }) +) + +const getPreferredInitialFloorId = () => ( + floorIndex.value.find(isGroundEntryFloor)?.floorId || floorIndex.value[0]?.floorId + || '' +) + +const getDefaultFloorId = () => ( + resolveFloorIdFromRequest(props.initialFloorId) + || getPreferredInitialFloorId() || props.initialFloorId || 'L1' ) @@ -2051,7 +2301,8 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { () => loadFloor(targetFloorId, { preserveCurrentSceneUntilReady: hasSceneToKeep, suppressProgress: hasSceneToKeep, - allowOverviewFallback: true + allowOverviewFallback: false, + detachPoiBeforeLoad: true }), '单楼层模型加载失败', { showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) } @@ -2388,9 +2639,19 @@ const attachCachedSharedModel = ( const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { const floor = floorIndex.value.find((item) => item.floorId === floorId) - if (!floor || !scene) return + if (!floor) { + throw new Error(`未找到楼层模型:${floorId}`) + } + if (!scene) { + throw createStaleModelLoadError() + } const loadToken = startModelLoad() + const requestedFloorId = floor.floorId + if (options.detachPoiBeforeLoad || activeView.value === 'floor' || currentFloor.value !== requestedFloorId) { + detachActivePoiLayer() + } + const canUseExactCachedModel = canAttachCachedSharedModel(floor.modelUrl) const canUseOverviewFallback = !canUseExactCachedModel && Boolean(options.allowOverviewFallback) @@ -2414,6 +2675,7 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { loadToken, floor.floorId ) + assertActiveFloorModelState(loadToken, requestedFloorId) } else { let gltf: GLTF | null = null @@ -2448,6 +2710,7 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { floor.floorId, { allowAnyCachedOverview: true } ) + assertActiveFloorModelState(loadToken, requestedFloorId) } if (gltf) { @@ -2484,17 +2747,19 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { cachedSharedModelUrl = floor.modelUrl } targetScene.add(activeModel) + assertActiveFloorModelState(loadToken, requestedFloorId) } } if (!activeModel) throw createStaleModelLoadError() + assertActiveFloorModelState(loadToken, requestedFloorId) hasLoadedFloorViewOnce = true fitCameraToObject(activeModel) markFloorAutoSwitchEntry() if (shouldRenderPoiMarkers.value) { await loadFloorPOIs(floor, loadToken) - assertCurrentModelLoad(loadToken) + assertActiveFloorModelState(loadToken, requestedFloorId) } refreshPoiVisibilityByDistance() @@ -2789,6 +3054,82 @@ const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => { return sprite } +const createAmbientPoiLabelSprite = (poi: RenderPoi, markerSize: number) => { + const canvas = document.createElement('canvas') + canvas.width = 360 + canvas.height = 112 + const context = canvas.getContext('2d') + const labelLimit = isHallPoi(poi) ? 10 : 7 + const label = poi.name.length > labelLimit ? `${poi.name.slice(0, labelLimit)}…` : poi.name + + if (context) { + const color = getPoiColor(poi.primaryCategory) + const hallLabel = isHallPoi(poi) + const boxX = hallLabel ? 28 : 42 + const boxY = hallLabel ? 18 : 26 + const boxWidth = hallLabel ? 304 : 276 + const boxHeight = hallLabel ? 60 : 48 + const radius = hallLabel ? 22 : 18 + + context.clearRect(0, 0, canvas.width, canvas.height) + context.shadowColor = 'rgba(31, 35, 41, 0.18)' + context.shadowBlur = hallLabel ? 14 : 10 + context.shadowOffsetY = hallLabel ? 7 : 5 + context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)' + context.beginPath() + context.roundRect(boxX, boxY, boxWidth, boxHeight, radius) + context.fill() + context.shadowColor = 'transparent' + context.lineWidth = hallLabel ? 3 : 2 + context.strokeStyle = hallLabel ? `${color}66` : 'rgba(31, 35, 41, 0.1)' + context.stroke() + + context.beginPath() + context.moveTo(166, boxY + boxHeight) + context.lineTo(194, boxY + boxHeight) + context.lineTo(180, boxY + boxHeight + 18) + context.closePath() + context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)' + context.fill() + context.strokeStyle = hallLabel ? `${color}50` : 'rgba(31, 35, 41, 0.08)' + context.stroke() + + context.beginPath() + context.arc(boxX + 30, boxY + boxHeight * 0.5, hallLabel ? 15 : 12, 0, Math.PI * 2) + context.fillStyle = color + context.fill() + + context.font = `700 ${hallLabel ? 18 : 15}px ${CANVAS_FONT_FAMILY}` + context.textAlign = 'center' + context.textBaseline = 'middle' + context.fillStyle = '#ffffff' + context.fillText(getPoiGlyph(poi), boxX + 30, boxY + boxHeight * 0.5, 24) + + context.font = `700 ${hallLabel ? 24 : 20}px ${CANVAS_FONT_FAMILY}` + context.textAlign = 'left' + context.fillStyle = '#1f2329' + context.fillText(label, boxX + 56, boxY + boxHeight * 0.5, boxWidth - 76) + } + + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + depthWrite: false + })) + sprite.userData.isPoiLabel = true + sprite.userData.poi = poi + sprite.userData.labelBaseScaleX = markerSize * (isHallPoi(poi) ? 3.9 : 3.05) + sprite.userData.labelBaseScaleY = markerSize * (isHallPoi(poi) ? 1.22 : 0.95) + sprite.renderOrder = isHallPoi(poi) ? 18 : 15 + sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1) + sprite.visible = false + + return sprite +} + const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => { const canvas = document.createElement('canvas') canvas.width = 160 @@ -3012,6 +3353,9 @@ const findLoadedPoi = (poiId: string) => ( const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => { const sprite = new THREE.Sprite(createPoiMaterial(poi)) const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial()) + const labelSprite = shouldCreateAmbientPoiLabel(poi) + ? createAmbientPoiLabelSprite(poi, markerSize) + : null const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview' const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier) @@ -3019,6 +3363,9 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => { sprite.userData.poi = poi sprite.userData.isCorePoi = isCorePoi sprite.userData.hitTarget = hitTarget + if (labelSprite) { + sprite.userData.labelSprite = labelSprite + } sprite.userData.hitTargetBaseScale = hitTargetScale hitTarget.userData.baseScale = hitTargetScale @@ -3030,7 +3377,7 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => { setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value) - return { sprite, hitTarget } + return { sprite, hitTarget, labelSprite } } const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => { @@ -3078,13 +3425,20 @@ const createPoiMarkerGroup = ( pois.forEach((poi) => { const [x, y, z] = poi.positionGltf! - const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize) + const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize) sprite.position.set(x, y + markerSize * 0.5, z) hitTarget.position.copy(sprite.position) + if (labelSprite) { + labelSprite.position.set(x, y + markerSize * 1.82, z) + labelSprite.visible = false + } sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier()) hitTarget.visible = sprite.visible group.add(sprite) group.add(hitTarget) + if (labelSprite) { + group.add(labelSprite) + } }) return group @@ -3104,11 +3458,17 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { if (!poiGroup) return if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return + if (loadToken !== undefined) { + assertActiveFloorModelState(loadToken, floor.floorId) + } const displayMode = getPoiDisplayMode() const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode) const cachedEntry = poiMarkerGroupCache.get(markerCacheKey) if (cachedEntry) { + if (loadToken !== undefined) { + assertActiveFloorModelState(loadToken, floor.floorId) + } attachPoiMarkerGroup(cachedEntry) return } @@ -3116,6 +3476,9 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { const cachedPois = poiDataCache.get(floor.floorId) const floorPois = cachedPois || await props.modelSource.loadFloorPois(floor.floorId) if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return + if (loadToken !== undefined) { + assertActiveFloorModelState(loadToken, floor.floorId) + } poiDataCache.set(floor.floorId, floorPois) const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi)) @@ -3133,6 +3496,9 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { disposeObject(markerGroup) return } + if (loadToken !== undefined) { + assertActiveFloorModelState(loadToken, floor.floorId) + } poiMarkerGroupCache.set(markerCacheKey, entry) attachPoiMarkerGroup(entry) @@ -3210,7 +3576,8 @@ const handleSceneTap = async (event: PointerEvent) => { await loadFloor(selectedPoi.floorId, { preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(), suppressProgress: true, - allowOverviewFallback: true + allowOverviewFallback: false, + detachPoiBeforeLoad: true }) emit('floorChange', selectedPoi.floorId) } catch (error) { @@ -3339,7 +3706,8 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => { await loadFloor(request.floorId, { preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(), suppressProgress: true, - allowOverviewFallback: true + allowOverviewFallback: false, + detachPoiBeforeLoad: true }) isLoading.value = false emit('floorChange', request.floorId) @@ -3407,11 +3775,11 @@ const loadModelPackage = async () => { setProgress(14, '正在读取楼层索引...') floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom) - const requestedInitialFloor = floorIndex.value.find((floor) => floor.floorId === props.initialFloorId) - if (requestedInitialFloor) { - currentFloor.value = requestedInitialFloor.floorId + const requestedInitialFloorId = resolveFloorIdFromRequest(props.initialFloorId) + if (requestedInitialFloorId) { + currentFloor.value = requestedInitialFloorId } else if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) { - currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1' + currentFloor.value = getDefaultFloorId() } if (props.initialView === 'floor') { @@ -3454,18 +3822,28 @@ const init3DScene = async () => { } const handleFloorChange = async (floorId: string) => { - const hasSceneToKeep = hasRenderableSceneForFloorTransition() + const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId + if (isFloorSwitchLocked) { + queuedFloorSwitchId = requestedFloorId + return + } + + isFloorSwitchLocked = true + queuedFloorSwitchId = '' try { + isLoading.value = true loadError.value = false - await loadFloor(floorId, { - preserveCurrentSceneUntilReady: hasSceneToKeep, - suppressProgress: true, - allowOverviewFallback: true + setProgress(12, `正在切换到 ${formatFloorLabel(requestedFloorId)}...`) + await loadFloor(requestedFloorId, { + preserveCurrentSceneUntilReady: false, + suppressProgress: false, + allowOverviewFallback: false, + detachPoiBeforeLoad: true }) updatePoiVisibilityByDistance() isLoading.value = false - emit('floorChange', floorId) + emit('floorChange', requestedFloorId) } catch (error) { if (isStaleModelLoadError(error)) return @@ -3473,6 +3851,15 @@ const handleFloorChange = async (floorId: string) => { loadError.value = true isLoading.value = false setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败') + } finally { + isFloorSwitchLocked = false + if (queuedFloorSwitchId && queuedFloorSwitchId !== currentFloor.value) { + const nextFloorId = queuedFloorSwitchId + queuedFloorSwitchId = '' + void handleFloorChange(nextFloorId) + } else { + queuedFloorSwitchId = '' + } } } @@ -3615,7 +4002,7 @@ const syncRequestedIndoorView = async () => { } if (props.initialView === 'floor') { - const requestedFloorId = floorIndex.value.find((floor) => floor.floorId === props.initialFloorId)?.floorId + const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId) || currentFloor.value if (requestedFloorId && (activeView.value !== 'floor' || currentFloor.value !== requestedFloorId)) { await handleFloorChange(requestedFloorId) diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 3c62321..e23859d 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -151,7 +151,7 @@ :key="floor.id" class="floor-item" :class="{ active: activeFloorId === floor.id && layerMode !== 'multi' }" - @tap="handleFloorChange(floor.label)" + @tap.stop="handleFloorChange(floor)" > {{ floor.label }} @@ -445,6 +445,7 @@ const activeFloorId = computed(() => { return normalizedFloor?.id || normalizedFloorId }) const activeFloorScrollIntoView = ref('') +let suppressFloorAutoScrollUntil = 0 const syncActiveFloorScroll = () => { if (props.mapType !== 'indoor' || props.indoorView === 'overview' || props.layerMode === 'multi') { @@ -452,6 +453,11 @@ const syncActiveFloorScroll = () => { return } + if (Date.now() < suppressFloorAutoScrollUntil) { + activeFloorScrollIntoView.value = '' + return + } + const matchedFloor = floorItems.value.find((floor) => floor.id === activeFloorId.value) activeFloorScrollIntoView.value = matchedFloor?.scrollId || '' } @@ -517,13 +523,15 @@ const handleModeChange = (mode: '2d' | '3d') => { emit('modeChange', mode) } -const handleFloorChange = (floor: string) => { - const floorId = props.normalizeFloorId(floor) +const handleFloorChange = (floor: { id: string; label: string }) => { + const floorId = floor.id || props.normalizeFloorId(floor.label) + suppressFloorAutoScrollUntil = Date.now() + 650 + activeFloorScrollIntoView.value = '' indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) void indoorRendererRef.value?.switchFloor?.(floorId) emit('indoorViewChange', 'floor') emit('layerModeChange', 'single') - emit('floorChange', floor) + emit('floorChange', floor.label) } const handleLayerModeChange = (mode: LayerDisplayMode) => { diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index ef96f79..0a72875 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -528,7 +528,7 @@ const loadGuideFloors = async () => { const floors = await guideUseCase.getFloors() guideFloors.value = floors activeGuideFloor.value = floors.find((floor) => floor.label === activeGuideFloor.value)?.label - || floors.find((floor) => floor.id === 'L1')?.label + || floors.find((floor) => floor.label === '1F')?.label || floors[0]?.label || activeGuideFloor.value } catch (error) { diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index 62430aa..5d39cb3 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -12,6 +12,7 @@ import { isIndoorNavigableFloor } from '@/domain/guideFloor' import { + buildSgsFloorAliases, createSgsHallPoiDiagnostics, formatSgsFloorLabel, toMuseumHallPoisFromSgs, @@ -193,6 +194,16 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { const floorModelItems = floors .map((floor, index) => { const bundle = bundles[index] + const bundleFloor = bundle.floor + if (bundleFloor && String(bundleFloor.floorId) !== String(floor.floorId)) { + warnSgsGuideModelDiagnostics('floor bundle metadata does not match requested floor', { + requestedFloorId: String(floor.floorId), + requestedFloorCode: floor.floorCode, + bundleFloorId: String(bundleFloor.floorId), + bundleFloorCode: bundleFloor.floorCode + }) + } + const modelUrl = resolveSgsAssetUrl(bundle.model?.modelUrl || bundle.model?.fallbackModelUrl) const label = formatSgsFloorLabel(floor.floorCode, floor.floorName) @@ -228,12 +239,28 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { async loadFloorPois(floorId: string) { const manifest = await this.provider.getManifest() this.floorsCache = sortSgsFloors(manifest.floors).filter(isIndoorNavigableFloor) + const aliases = buildSgsFloorAliases(this.floorsCache) + const requestedFloorId = floorId.trim() + const normalizedFloorId = aliases.get(requestedFloorId) + || aliases.get(requestedFloorId.toLowerCase()) + || requestedFloorId const matchedFloor = this.floorsCache.find((floor) => ( - String(floor.floorId) === floorId - || floor.floorCode === floorId - || formatSgsFloorLabel(floor.floorCode, floor.floorName) === floorId + String(floor.floorId) === normalizedFloorId + || floor.floorCode === normalizedFloorId + || formatSgsFloorLabel(floor.floorCode, floor.floorName) === normalizedFloorId )) - if (!matchedFloor) return [] + if (!matchedFloor) { + warnSgsGuideModelDiagnostics('unable to resolve floor for render POI request', { + requestedFloorId: floorId, + normalizedFloorId, + knownFloors: this.floorsCache.map((floor) => ({ + floorId: String(floor.floorId), + floorCode: floor.floorCode, + label: formatSgsFloorLabel(floor.floorCode, floor.floorName) + })) + }) + return [] + } const resolvedFloorId = String(matchedFloor.floorId) const loadFloorData = async (