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

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
}
}
}