修复馆内导览楼层点位展示

This commit is contained in:
lyf
2026-07-01 00:07:25 +08:00
parent f13d53d536
commit cf37e7a40e
5 changed files with 1379 additions and 781 deletions

View File

@@ -85,6 +85,7 @@ interface LoadFloorOptions {
preserveCurrentSceneUntilReady?: boolean
suppressProgress?: boolean
detachPoiBeforeLoad?: boolean
allowSameFloorReload?: boolean
}
interface LoadModelOptions {
@@ -176,6 +177,21 @@ interface PoiMarkerCacheEntry {
pois: RenderPoi[]
group: THREE.Group
markerSize: number
displayOffsetY: number
}
interface PoiYDistribution {
count: number
min: number
max: number
median: number
}
interface ModelYBounds {
min: number
max: number
center: number
height: number
}
interface PreparedFloorScene {
@@ -267,6 +283,8 @@ let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const poiDisplayOffsetYCache = new Map<string, number>()
const poiDisplayDiagnosticsKeys = new Set<string>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
@@ -279,6 +297,9 @@ let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = Promise.resolve()
let modelLoadVersion = 0
let pendingRequestedFloorId = ''
let floorSwitchLoadToken = 0
let floorSwitchRequestedFloorId = ''
let isFloorSwitching = false
let hasLoadedFloorViewOnce = false
let pointerDownState: { x: number; y: number } | null = null
let cachedOverviewModel: THREE.Object3D | null = null
@@ -631,6 +652,115 @@ const getPoiDisplayMode = (): PoiDisplayMode => {
const getPoiMarkerCacheKey = (floorId: string, displayMode = getPoiDisplayMode()) => `${floorId}:${displayMode}`
const summarizePoiYDistribution = (pois: RenderPoi[]): PoiYDistribution | null => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return null
const middle = Math.floor(values.length / 2)
const median = values.length % 2
? values[middle]
: (values[middle - 1] + values[middle]) / 2
return {
count: values.length,
min: values[0],
max: values[values.length - 1],
median
}
}
const getModelYBounds = (model: THREE.Object3D): ModelYBounds => {
const box = getObjectBox(model)
const height = Math.max(box.max.y - box.min.y, 0)
return {
min: box.min.y,
max: box.max.y,
center: (box.min.y + box.max.y) / 2,
height
}
}
const roundDiagnosticNumber = (value: number) => Math.round(value * 1000) / 1000
const getPoiDisplayOffsetCacheKey = (floorId: string) => floorId
const getCachedPoiDisplayOffsetY = (floorId: string) => (
poiDisplayOffsetYCache.get(getPoiDisplayOffsetCacheKey(floorId)) || 0
)
const getPoiDisplayPosition = (poi: RenderPoi, floorId = poi.floorId) => {
if (!poi.positionGltf) return null
const [x, y, z] = poi.positionGltf
return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z)
}
const resolvePoiDisplayOffsetY = (
floorId: string,
model: THREE.Object3D,
pois: RenderPoi[]
) => {
const modelY = getModelYBounds(model)
const poiY = summarizePoiYDistribution(pois)
let offsetY = 0
let reason = 'no-positioned-poi'
if (poiY) {
const modelTolerance = Math.max(2, modelY.height * 0.35)
const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance
if (overlapsModelRange) {
reason = 'poi-y-overlaps-model-y'
} else {
offsetY = modelY.center - poiY.median
reason = 'align-poi-median-to-model-center'
}
}
poiDisplayOffsetYCache.set(getPoiDisplayOffsetCacheKey(floorId), offsetY)
if (import.meta.env.DEV) {
const diagnosticsKey = [
floorId,
roundDiagnosticNumber(modelY.min),
roundDiagnosticNumber(modelY.max),
poiY ? roundDiagnosticNumber(poiY.min) : 'none',
poiY ? roundDiagnosticNumber(poiY.max) : 'none',
roundDiagnosticNumber(offsetY)
].join(':')
if (!poiDisplayDiagnosticsKeys.has(diagnosticsKey)) {
poiDisplayDiagnosticsKeys.add(diagnosticsKey)
console.info('[ThreeMap] floor POI display coordinate diagnostics', {
floorId,
modelY: {
min: roundDiagnosticNumber(modelY.min),
max: roundDiagnosticNumber(modelY.max),
center: roundDiagnosticNumber(modelY.center),
height: roundDiagnosticNumber(modelY.height)
},
poiY: poiY
? {
count: poiY.count,
min: roundDiagnosticNumber(poiY.min),
max: roundDiagnosticNumber(poiY.max),
median: roundDiagnosticNumber(poiY.median)
}
: null,
displayOffsetY: roundDiagnosticNumber(offsetY),
reason
})
}
}
return offsetY
}
const getPoiGlyph = (poi: RenderPoi) => (
poiGlyphMap[poi.iconType] || poi.name.charAt(0) || '点'
)
@@ -850,14 +980,14 @@ const updateAmbientPoiLabels = () => {
.sort((left, right) => right.priority - left.priority)
candidates.forEach(({ marker, labelSprite, poi }) => {
if (!poi.positionGltf || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition || !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,
@@ -870,7 +1000,7 @@ const updateAmbientPoiLabels = () => {
let accepted = false
for (const offset of offsetAttempts) {
labelSprite.position.set(x, y + baseOffset + offset, z)
labelSprite.position.set(displayPosition.x, displayPosition.y + baseOffset + offset, displayPosition.z)
const screenPosition = getProjectedScreenPosition(labelSprite)
if (!screenPosition) continue
@@ -885,7 +1015,17 @@ const updateAmbientPoiLabels = () => {
}
if (!accepted) {
labelSprite.visible = false
if (isHallPoi(poi)) {
const fallbackOffset = offsetAttempts[offsetAttempts.length - 1]
labelSprite.position.set(
displayPosition.x,
displayPosition.y + baseOffset + fallbackOffset,
displayPosition.z
)
labelSprite.visible = true
} else {
labelSprite.visible = false
}
}
})
}
@@ -1116,6 +1256,9 @@ const startModelLoad = () => {
const invalidateModelLoads = () => {
modelLoadVersion += 1
pendingRequestedFloorId = ''
floorSwitchLoadToken = 0
floorSwitchRequestedFloorId = ''
isFloorSwitching = false
}
const isCurrentModelLoad = (loadToken: number) => (
@@ -1128,6 +1271,46 @@ const isStaleModelLoadError = (error: unknown) => (
error instanceof Error && error.message === staleModelLoadMessage
)
const startFloorContextTransaction = (requestedFloorId: string) => {
const loadToken = startModelLoad()
pendingRequestedFloorId = requestedFloorId
floorSwitchLoadToken = loadToken
floorSwitchRequestedFloorId = requestedFloorId
isFloorSwitching = true
return loadToken
}
const isCurrentFloorContextTransaction = (loadToken: number, requestedFloorId: string) => (
isFloorSwitching
&& floorSwitchLoadToken === loadToken
&& floorSwitchRequestedFloorId === requestedFloorId
&& pendingRequestedFloorId === requestedFloorId
&& isCurrentModelLoad(loadToken)
)
const assertCurrentFloorContextTransaction = (
loadToken: number,
requestedFloorId: string,
staleObject?: THREE.Object3D
) => {
if (isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return
if (staleObject) {
disposeObject(staleObject)
}
throw createStaleModelLoadError()
}
const completeFloorContextTransaction = (loadToken: number, requestedFloorId: string) => {
if (!isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return
isFloorSwitching = false
floorSwitchLoadToken = 0
floorSwitchRequestedFloorId = ''
pendingRequestedFloorId = ''
}
const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => {
if (isCurrentModelLoad(loadToken)) return
@@ -1556,8 +1739,9 @@ const detachActivePoiLayer = () => {
disposeFocusLabel()
disposeFocusPulse()
disposeFocusBase()
clearFocusHallHighlight()
clearRoutePreview()
clearPoiGroupChildren()
detachPoiMarkerGroups()
}
const disposePoiMarkerCache = () => {
@@ -1567,6 +1751,8 @@ const disposePoiMarkerCache = () => {
})
poiMarkerGroupCache.clear()
poiDataCache.clear()
poiDisplayOffsetYCache.clear()
poiDisplayDiagnosticsKeys.clear()
}
const clearPoiGroupChildren = () => {
@@ -1679,7 +1865,8 @@ const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set<THR
}
const createFocusHallGlowMesh = (poi: RenderPoi) => {
if (!poi.positionGltf) return null
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition) return null
const canvas = document.createElement('canvas')
canvas.width = 256
@@ -1711,10 +1898,9 @@ const createFocusHallGlowMesh = (poi: RenderPoi) => {
const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14)
const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72)
const mesh = new THREE.Mesh(geometry, material)
const [x, y, z] = poi.positionGltf
mesh.name = 'GuideFocusHallGlow'
mesh.position.set(x, y + 0.08, z)
mesh.position.set(displayPosition.x, displayPosition.y + 0.08, displayPosition.z)
mesh.rotation.x = -Math.PI / 2
mesh.renderOrder = 7
@@ -2363,11 +2549,13 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => {
trigger: 'zoom-in',
distance
},
() => loadFloor(targetFloorId, {
preserveCurrentSceneUntilReady: hasSceneToKeep,
suppressProgress: hasSceneToKeep,
detachPoiBeforeLoad: true
}),
async () => {
await loadFloor(targetFloorId, {
preserveCurrentSceneUntilReady: hasSceneToKeep,
suppressProgress: hasSceneToKeep,
detachPoiBeforeLoad: true
})
},
'单楼层模型加载失败',
{ showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) }
)
@@ -2436,7 +2624,7 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => {
const runAutoSwitchLoad = async (
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
loadTask: () => Promise<void>,
loadTask: () => Promise<unknown>,
fallbackMessage: string,
options: { showLoading?: boolean } = {}
) => {
@@ -2611,7 +2799,16 @@ const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value) || floorIndex.value[0]
if (!floor) return
await loadFloorPOIs(floor, loadToken)
if (activeView.value === 'floor') {
await loadFloorPOIs(floor, loadToken)
assertCurrentModelLoad(loadToken)
return
}
const entry = await prepareFloorPOIs(floor, loadToken)
if (entry) {
attachPoiMarkerGroup(entry)
}
assertCurrentModelLoad(loadToken)
}
@@ -2742,7 +2939,7 @@ const prepareFloorScene = async (
let poiEntry: PoiMarkerCacheEntry | undefined | null = null
try {
poiEntry = shouldRenderPoiMarkers.value
? await prepareFloorPOIs(floor, loadToken)
? await prepareFloorPOIs(floor, loadToken, model)
: null
} catch (error) {
if (ownsModel) {
@@ -2769,6 +2966,7 @@ const commitPreparedFloorScene = (
loadToken: number,
expectedFloorId: string
) => {
assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined)
assertPreparedFloorScene(prepared, loadToken, expectedFloorId)
const targetScene = scene
@@ -2783,6 +2981,7 @@ const commitPreparedFloorScene = (
currentFloor.value = expectedFloorId
clearSceneData()
activeModel = prepared.model
activeModel.userData.floorId = expectedFloorId
if (prepared.cacheAsSharedModel) {
cachedOverviewModel = activeModel
@@ -2798,6 +2997,15 @@ const commitPreparedFloorScene = (
}
assertCommittedFloorScene(loadToken, expectedFloorId)
activeFocusPoiId.value = ''
selectedPOI.value = null
updatePoiMarkerFocus()
hasLoadedFloorViewOnce = true
fitCameraToObject(activeModel)
markFloorAutoSwitchEntry()
refreshPoiVisibilityByDistance()
renderRoutePreview()
}
const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
@@ -2809,24 +3017,35 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
throw createStaleModelLoadError()
}
const loadToken = startModelLoad()
const requestedFloorId = floor.floorId
pendingRequestedFloorId = requestedFloorId
if (options.detachPoiBeforeLoad || activeView.value === 'floor' || currentFloor.value !== requestedFloorId) {
if (
!options.allowSameFloorReload
&& activeView.value === 'floor'
&& currentFloor.value === requestedFloorId
&& activeModel?.userData.floorId === requestedFloorId
&& !isFloorSwitching
) {
return false
}
const loadToken = startFloorContextTransaction(requestedFloorId)
if (options.detachPoiBeforeLoad) {
detachActivePoiLayer()
}
const prepared = await prepareFloorScene(floor, loadToken, options)
commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
if (!activeModel) throw createStaleModelLoadError()
assertCommittedFloorScene(loadToken, requestedFloorId)
hasLoadedFloorViewOnce = true
fitCameraToObject(activeModel)
markFloorAutoSwitchEntry()
refreshPoiVisibilityByDistance()
renderRoutePreview()
try {
const prepared = await prepareFloorScene(floor, loadToken, options)
assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
return true
} catch (error) {
if (!isStaleModelLoadError(error)) {
restoreCommittedFloorPoiLayer()
}
throw error
} finally {
completeFloorContextTransaction(loadToken, requestedFloorId)
}
}
const loadMultiFloor = async () => {
@@ -3290,39 +3509,39 @@ const disposeFocusBase = () => {
}
const showFocusPoiLabel = (poi: RenderPoi) => {
if (!poiGroup || !poi.positionGltf) return
const displayPosition = getPoiDisplayPosition(poi)
if (!poiGroup || !displayPosition) return
disposeFocusLabel()
const markerSize = getPoiMarkerSize()
const [x, y, z] = poi.positionGltf
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
activeFocusLabelSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 2.35, displayPosition.z)
updateFocusLabelScale()
poiGroup.add(activeFocusLabelSprite)
}
const showFocusPoiBase = (poi: RenderPoi) => {
if (!poiGroup || !poi.positionGltf) return
const displayPosition = getPoiDisplayPosition(poi)
if (!poiGroup || !displayPosition) return
disposeFocusBase()
const markerSize = getPoiMarkerSize()
const [x, y, z] = poi.positionGltf
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
activeFocusBaseSprite.position.set(x, y + markerSize * 0.04, z)
activeFocusBaseSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.04, displayPosition.z)
poiGroup.add(activeFocusBaseSprite)
}
const showFocusPoiPulse = (poi: RenderPoi) => {
if (!poiGroup || !poi.positionGltf) return
const displayPosition = getPoiDisplayPosition(poi)
if (!poiGroup || !displayPosition) return
disposeFocusPulse()
const markerSize = getPoiMarkerSize()
const [x, y, z] = poi.positionGltf
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
activeFocusPulseSprite.position.set(x, y + markerSize * 0.15, z)
activeFocusPulseSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.15, displayPosition.z)
poiGroup.add(activeFocusPulseSprite)
}
@@ -3462,12 +3681,12 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null
const poi = createPoiFromFocusRequest(request)
if (!poi?.positionGltf) return null
const displayPosition = poi ? getPoiDisplayPosition(poi, request.floorId) : null
if (!poi || !displayPosition) return null
const [x, y, z] = poi.positionGltf
const markerSize = getPoiMarkerSize()
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
sprite.position.set(x, y + markerSize * 0.5, z)
sprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.5, displayPosition.z)
hitTarget.position.copy(sprite.position)
poiGroup.add(sprite)
poiGroup.add(hitTarget)
@@ -3488,12 +3707,14 @@ const createPoiMarkerGroup = (
group.userData.displayMode = displayMode
pois.forEach((poi) => {
const [x, y, z] = poi.positionGltf!
const displayPosition = getPoiDisplayPosition(poi, floorId)
if (!displayPosition) return
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
sprite.position.set(x, y + markerSize * 0.5, z)
sprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.5, displayPosition.z)
hitTarget.position.copy(sprite.position)
if (labelSprite) {
labelSprite.position.set(x, y + markerSize * 1.82, z)
labelSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 1.82, displayPosition.z)
labelSprite.visible = false
}
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
@@ -3520,7 +3741,39 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
updatePoiMarkerFocus()
}
const prepareFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
const restoreCommittedFloorPoiLayer = () => {
if (!poiGroup || activeView.value !== 'floor' || !activeModel) return
const floorId = currentFloor.value
const modelFloorId = typeof activeModel.userData.floorId === 'string'
? activeModel.userData.floorId
: ''
if (!floorId || modelFloorId !== floorId) return
const entry = shouldRenderPoiMarkers.value
? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floorId))
: null
if (entry?.floorId === floorId) {
attachPoiMarkerGroup(entry)
refreshPoiVisibilityByDistance()
return
}
poiGroup.userData.floorId = floorId
poiGroup.userData.currentFloor = floorId
}
const getPoiMarkerSizeForModel = (model: THREE.Object3D | null) => (
model
? Math.max(new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
: getPoiMarkerSize()
)
const prepareFloorPOIs = async (
floor: FloorIndexItem,
loadToken?: number,
markerModel: THREE.Object3D | null = activeModel
) => {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
const displayMode = getPoiDisplayMode()
@@ -3536,14 +3789,18 @@ const prepareFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
poiDataCache.set(floor.floorId, floorPois)
const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi))
const markerSize = getPoiMarkerSize()
const displayOffsetY = markerModel
? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois)
: getCachedPoiDisplayOffsetY(floor.floorId)
const markerSize = getPoiMarkerSizeForModel(markerModel)
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
const entry: PoiMarkerCacheEntry = {
floorId: floor.floorId,
displayMode,
pois: validPois,
group: markerGroup,
markerSize
markerSize,
displayOffsetY
}
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
@@ -3727,10 +3984,10 @@ const isSceneReadyForTargetFocus = () => (
)
const focusCameraOnPoi = (poi: RenderPoi) => {
if (!camera || !controls || !poi.positionGltf) return
const displayPosition = getPoiDisplayPosition(poi)
if (!camera || !controls || !displayPosition) return
const [x, y, z] = poi.positionGltf
const target = new THREE.Vector3(x, y, z)
const target = displayPosition.clone()
const modelSize = activeModel
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
: new THREE.Vector3(80, 80, 80)
@@ -3889,26 +4146,46 @@ const init3DScene = async () => {
const handleFloorChange = async (floorId: string) => {
const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId
const isCommittedSameFloor = (
activeView.value === 'floor'
&& currentFloor.value === requestedFloorId
&& activeModel?.userData.floorId === requestedFloorId
&& !isFloorSwitching
)
if (isCommittedSameFloor) {
return
}
if (isFloorSwitching && floorSwitchRequestedFloorId === requestedFloorId) {
return
}
const previousLoadToken = modelLoadVersion
try {
isLoading.value = true
loadError.value = false
setProgress(12, `正在切换到 ${formatFloorLabel(requestedFloorId)}...`)
await loadFloor(requestedFloorId, {
const didCommit = await loadFloor(requestedFloorId, {
preserveCurrentSceneUntilReady: true,
suppressProgress: false,
detachPoiBeforeLoad: true
})
updatePoiVisibilityByDistance()
isLoading.value = false
emit('floorChange', requestedFloorId)
if (didCommit) {
emit('floorChange', requestedFloorId)
}
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('楼层模型加载失败:', error)
loadError.value = true
isLoading.value = false
restoreCommittedFloorPoiLayer()
setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败')
} finally {
const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1
if (!requestWasSuperseded || floorSwitchRequestedFloorId === requestedFloorId) {
isLoading.value = false
}
}
}

View File

@@ -150,7 +150,12 @@
:id="floor.scrollId"
:key="floor.id"
class="floor-item"
:class="{ active: activeFloorId === floor.id && layerMode !== 'multi' }"
:class="{
active: activeFloorId === floor.id && layerMode !== 'multi',
pending: loadingFloorId === floor.id,
failed: failedFloorId === floor.id,
disabled: isFloorSwitchDisabled(floor.id)
}"
@tap.stop="handleFloorChange(floor)"
>
<text class="floor-label">{{ floor.label }}</text>
@@ -247,6 +252,11 @@ interface GuideFloorOption {
label: string
}
interface FloorSwitchEvent {
floorId: string
floorLabel: string
}
interface OutdoorNavPolyline {
points: Array<{ latitude: number; longitude: number }>
color: string
@@ -381,7 +391,9 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
searchTap: []
modeChange: [mode: '2d' | '3d']
floorRequest: [event: FloorSwitchEvent]
floorChange: [floor: string]
floorSwitchFailed: [event: FloorSwitchEvent]
toolClick: [tool: string]
moreClick: []
indoorViewChange: [view: IndoorViewMode]
@@ -445,8 +457,15 @@ const activeFloorId = computed(() => {
return normalizedFloor?.id || normalizedFloorId
})
const activeFloorScrollIntoView = ref('')
const pendingFloorId = ref('')
let suppressFloorAutoScrollUntil = 0
const requestedFloorId = ref('')
const requestedFloorLabel = ref('')
const loadingFloorId = ref('')
const renderedFloorId = ref(activeFloorId.value)
const activeFloorSwitchRequestSeq = ref(0)
const renderedFloorSwitchRequestSeq = ref(0)
const failedFloorId = ref('')
let suppressFloorAutoScrollForFloorId = ''
let floorSwitchRequestSeq = 0
const syncActiveFloorScroll = () => {
if (props.mapType !== 'indoor' || props.indoorView === 'overview' || props.layerMode === 'multi') {
@@ -454,10 +473,12 @@ const syncActiveFloorScroll = () => {
return
}
if (Date.now() < suppressFloorAutoScrollUntil) {
if (activeFloorId.value === suppressFloorAutoScrollForFloorId) {
activeFloorScrollIntoView.value = ''
suppressFloorAutoScrollForFloorId = ''
return
}
suppressFloorAutoScrollForFloorId = ''
const matchedFloor = floorItems.value.find((floor) => floor.id === activeFloorId.value)
activeFloorScrollIntoView.value = matchedFloor?.scrollId || ''
@@ -475,6 +496,11 @@ watch(
{ immediate: true }
)
watch(activeFloorId, (floorId) => {
if (!floorId || loadingFloorId.value === floorId) return
renderedFloorId.value = floorId
})
const searchFieldStyle = computed(() => ({
top: props.searchTop
}))
@@ -528,27 +554,77 @@ const isStaleFloorSwitchError = (error: unknown) => (
error instanceof Error && error.message === 'STALE_MODEL_LOAD'
)
const handleFloorChange = (floor: { id: string; label: string }) => {
const floorId = floor.id || props.normalizeFloorId(floor.label)
if (!floorId || pendingFloorId.value === floorId) return
const findFloorItemById = (floorId: string) => (
floorItems.value.find((floor) => floor.id === floorId)
)
pendingFloorId.value = floorId
suppressFloorAutoScrollUntil = Date.now() + 650
const isFloorSwitchDisabled = (floorId: string) => (
Boolean(loadingFloorId.value) && loadingFloorId.value !== floorId
)
const clearFloorLoadingIfCurrent = (floorId: string) => {
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
}
const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number) => {
if (
loadingFloorId.value !== floorId
|| activeFloorSwitchRequestSeq.value !== requestSeq
|| renderedFloorSwitchRequestSeq.value === requestSeq
) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
emit('floorSwitchFailed', {
floorId,
floorLabel: requestedFloorId.value === floorId
? requestedFloorLabel.value || findFloorItemById(floorId)?.label || floorId
: findFloorItemById(floorId)?.label || floorId
})
}
const handleFloorChange = (floor: { id: string; label: string }) => {
const floorId = floor.id
if (!floorId || loadingFloorId.value) return
if (floorId === renderedFloorId.value && activeFloorId.value === floorId && props.layerMode !== 'multi') {
emit('floorChange', floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
return
}
requestedFloorId.value = floorId
requestedFloorLabel.value = floor.label
loadingFloorId.value = floorId
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
failedFloorId.value = ''
suppressFloorAutoScrollForFloorId = floorId
activeFloorScrollIntoView.value = ''
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
emit('floorRequest', {
floorId,
floorLabel: floor.label
})
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
.catch((error) => {
if (isStaleFloorSwitchError(error)) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
emit('floorSwitchFailed', {
floorId,
floorLabel: floor.label
})
console.error('楼层切换失败:', error)
})
.finally(() => {
if (pendingFloorId.value === floorId) {
pendingFloorId.value = ''
}
})
}
const handleLayerModeChange = (mode: LayerDisplayMode) => {
@@ -556,22 +632,45 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (mode === 'multi') {
pendingFloorId.value = ''
loadingFloorId.value = ''
requestedFloorId.value = ''
requestedFloorLabel.value = ''
void indoorRendererRef.value?.showMultiFloor?.()
emit('indoorViewChange', 'multi')
} else {
const floorId = activeFloorId.value
pendingFloorId.value = floorId
const floorLabel = findFloorItemById(floorId)?.label || floorId
if (!floorId) return
if (floorId === renderedFloorId.value && props.layerMode !== 'multi') {
emit('indoorViewChange', 'floor')
emit('layerModeChange', mode)
return
}
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
failedFloorId.value = ''
emit('floorRequest', {
floorId,
floorLabel
})
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
.catch((error) => {
if (isStaleFloorSwitchError(error)) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
emit('floorSwitchFailed', {
floorId,
floorLabel
})
console.error('楼层切换失败:', error)
})
.finally(() => {
if (pendingFloorId.value === floorId) {
pendingFloorId.value = ''
}
})
emit('indoorViewChange', 'floor')
}
@@ -622,6 +721,14 @@ const handleMoreTap = () => {
}
const handleThreeFloorChange = (floorId: string) => {
renderedFloorId.value = floorId
if (loadingFloorId.value === floorId) {
renderedFloorSwitchRequestSeq.value = activeFloorSwitchRequestSeq.value
}
if (failedFloorId.value === floorId) {
failedFloorId.value = ''
}
clearFloorLoadingIfCurrent(floorId)
emit('floorChange', floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
@@ -1120,6 +1227,28 @@ defineExpose({
background: #000000;
}
.floor-item.pending {
pointer-events: none;
opacity: 0.72;
}
.floor-item.pending::before {
content: '';
position: absolute;
inset: 7px;
border: 1px solid rgba(224, 225, 0, 0.9);
border-radius: 6px;
}
.floor-item.disabled {
pointer-events: none;
opacity: 0.46;
}
.floor-item.failed:not(.pending) .floor-label {
color: #b84a4a;
}
.floor-label {
font-size: 13px;
line-height: 18px;

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,9 @@
:outdoor-nav-polylines="outdoorNavPolylines"
@search-tap="handleGuideSearchTap"
@mode-change="handleModeChange"
@floor-request="handleFloorRequest"
@floor-change="handleFloorChange"
@floor-switch-failed="handleFloorSwitchFailed"
@indoor-view-change="handleIndoorViewChange"
@poi-click="handleGuidePoiClick"
@selection-clear="handleGuideSelectionClear"
@@ -291,7 +293,12 @@ const initialTopTab = (): GuideTopTab => topTabFromHash() || 'guide'
const is3DMode = ref(true)
const guideOutdoorState = ref<'home' | 'entrance'>('home')
const indoorView = ref<GuideIndoorView>('overview')
const activeGuideFloor = ref('1F')
const activeGuideFloor = ref('')
const requestedFloorId = ref('')
const requestedFloorLabel = ref('')
const loadingFloorId = ref('')
const renderedFloorId = ref('')
const failedFloorId = ref('')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
// 状态
@@ -330,6 +337,12 @@ const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const getGuideFloorById = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)
)
const getGuideFloorLabel = (floorId: string) => (
getGuideFloorById(floorId)?.label || floorId
)
const indoorLayerMode = computed<LayerDisplayMode>(() => (
indoorView.value === 'multi' ? 'multi' : 'single'
))
@@ -527,10 +540,14 @@ const loadGuideFloors = async () => {
try {
const floors = await guideUseCase.getFloors()
guideFloors.value = floors
activeGuideFloor.value = floors.find((floor) => floor.label === activeGuideFloor.value)?.label
|| floors.find((floor) => floor.label === '1F')?.label
|| floors[0]?.label
const normalizedActiveFloorId = activeGuideFloor.value
? normalizeGuideFloorId(activeGuideFloor.value)
: ''
activeGuideFloor.value = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id
|| floors.find((floor) => floor.label === '1F')?.id
|| floors[0]?.id
|| activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
@@ -632,13 +649,36 @@ const handleGuideSearchTap = () => {
})
}
const handleFloorChange = (floor: string) => {
activeGuideFloor.value = floor
const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => {
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
failedFloorId.value = ''
}
const handleFloorChange = (floorId: string) => {
activeGuideFloor.value = floorId
renderedFloorId.value = floorId
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
if (failedFloorId.value === floorId) {
failedFloorId.value = ''
}
indoorView.value = 'floor'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
showIndoorHint(`已切换到 ${floor},单指旋转、双指可平移`, 3200)
console.log('切换楼层:', floor)
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指旋转、双指可平移`, 3200)
console.log('楼层渲染完成:', floorId)
}
const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => {
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
failedFloorId.value = floorId
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
console.warn('楼层渲染失败:', floorId)
}
const handleIndoorViewChange = (view: GuideIndoorView) => {
@@ -682,8 +722,8 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => {
isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
indoorView.value = 'floor'
isSimulatingRoute.value = false
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
activeGuideFloor.value = matchedFloor?.label || poi.floorId
activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId
showIndoorHint('已在当前楼层标出点位', 3000)
}
@@ -887,9 +927,9 @@ const handleRoutePickerSearch = ({ keyword }: { mode: 'start' | 'end'; keyword:
}
const focusRouteFloor = (route: GuideRouteResult) => {
const floor = guideFloors.value.find((item) => item.id === route.start.floorId)
const routeFloorCount = new Set(route.floorSegments.map((segment) => segment.floorId)).size
activeGuideFloor.value = floor?.label || route.start.floorLabel
activeGuideFloor.value = route.start.floorId
renderedFloorId.value = route.start.floorId
indoorView.value = routeFloorCount > 1 ? 'multi' : 'floor'
}
@@ -975,7 +1015,13 @@ const guideStatusLabel = computed(() => {
if (is3DMode.value) {
if (indoorView.value === 'overview') return '建筑外观'
if (indoorView.value === 'multi') return '馆内多层'
return '馆内单层'
if (loadingFloorId.value) {
return `切换${requestedFloorLabel.value || getGuideFloorLabel(requestedFloorId.value || loadingFloorId.value)}`
}
if (failedFloorId.value) {
return `${getGuideFloorLabel(failedFloorId.value)}失败`
}
return getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'
}
if (guideOutdoorState.value === 'entrance') {

View File

@@ -126,6 +126,43 @@ const warnSgsGuideModelDiagnostics = (
console.warn(`[SGS guide model] ${message}`, payload)
}
const summarizeYValues = (pois: GuideRenderPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) {
return {
count: 0,
min: null,
median: null,
max: null
}
}
return {
count: values.length,
min: values[0],
median: values[Math.floor(values.length / 2)],
max: values[values.length - 1]
}
}
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return undefined
const middle = Math.floor(values.length / 2)
return values.length % 2
? values[middle]
: (values[middle - 1] + values[middle]) / 2
}
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
[
String(floor.floorId),
@@ -285,7 +322,10 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId))
])
const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
fallbackY: floorPoiMedianY
})
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
@@ -300,8 +340,21 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
...hallPois,
...ordinaryPois
])
.filter((poi) => poi.floorId === resolvedFloorId)
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
poiCount: renderPois.length,
hallPoiCount: renderHallPois.length,
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
poiY: summarizeYValues(renderPois),
hallPoiY: summarizeYValues(renderHallPois),
fallbackHallY: floorPoiMedianY ?? null
})
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderPois.some((poi) => poi.primaryCategory === 'exhibition_hall')) {
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
floorId: resolvedFloorId,