修复跨楼层点位聚焦视觉跳动
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-14 01:16:31 +08:00
parent 231ecb465e
commit abaeda189e
3 changed files with 296 additions and 240 deletions

View File

@@ -227,6 +227,7 @@ interface CameraTweenState {
toTarget: THREE.Vector3
startedAt: number
durationMs: number
reason?: 'poi-focus'
onComplete?: () => void
}
@@ -344,26 +345,11 @@ interface PoiMarkerCacheEntry {
pois: RenderPoi[]
group: THREE.Group
markerSize: number
displayOffsetY: number
rawPoiCount?: number
rawPositionedPoiCount?: number
filteredCategoryCounts?: Record<string, number>
}
interface PoiYDistribution {
count: number
min: number
max: number
median: number
}
interface ModelYBounds {
min: number
max: number
center: number
height: number
}
interface PreparedFloorScene {
floor: FloorIndexItem
model: THREE.Object3D
@@ -481,8 +467,7 @@ let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
const poiDisplayOffsetYCache = new Map<string, number>()
const poiDisplayDiagnosticsKeys = new Set<string>()
const poiCoordinateDiagnosticsKeys = new Set<string>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
@@ -523,6 +508,8 @@ let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
let cameraTween: CameraTweenState | null = null
let poiFocusCameraAnimationCount = 0
let cameraSnapshotRestoreCount = 0
let adjacentPreloadSeq = 0
let defaultFloorPreloadSeq = 0
let firstModelLoadStartedAt = 0
@@ -545,11 +532,6 @@ const adjacentPreloadDelayMs = 900
const manualAutoSwitchPauseMs = 1500
const autoSwitchExitDistanceRatio = 0.2
const autoSwitchExitDistanceMarginRatio = 0.08
const poiMarkerVisualLiftFactor = 0.92
const poiAmbientLabelLiftFactor = 2.1
const poiFocusLabelLiftFactor = 2.72
const poiFocusBaseLiftFactor = 0.18
const poiFocusPulseLiftFactor = 0.34
type ThreeMapDiagnosticEvent =
| 'init-start'
@@ -570,6 +552,7 @@ type ThreeMapDiagnosticEvent =
| 'poi-background-start'
| 'poi-background-ready'
| 'poi-filter-diagnostics'
| 'poi-coordinate-diagnostics'
| 'poi-marker-render-diagnostics'
| 'poi-hit-diagnostics'
| 'adjacent-preload-skip'
@@ -1127,131 +1110,69 @@ const countPoiMarkerObjects = (group: THREE.Group) => {
return counts
}
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) => {
const getPoiDisplayPosition = (poi: RenderPoi) => {
if (!poi.positionGltf) return null
const [x, y, z] = poi.positionGltf
return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z)
return new THREE.Vector3(x, y, z)
}
const getPoiLiftedPosition = (
displayPosition: THREE.Vector3,
markerSize: number,
liftFactor: number
) => new THREE.Vector3(
displayPosition.x,
displayPosition.y + markerSize * liftFactor,
displayPosition.z
)
const getPoiMarkerPosition = (displayPosition: THREE.Vector3, markerSize: number) => (
getPoiLiftedPosition(displayPosition, markerSize, poiMarkerVisualLiftFactor)
)
const getPoiAmbientLabelPosition = (displayPosition: THREE.Vector3, markerSize: number) => (
getPoiLiftedPosition(displayPosition, markerSize, poiAmbientLabelLiftFactor)
)
const resolvePoiDisplayOffsetY = (
const recordPoiCoordinateDiagnostics = (
floorId: string,
model: THREE.Object3D,
pois: RenderPoi[]
) => {
const modelY = getModelYBounds(model)
const poiY = summarizePoiYDistribution(pois)
let offsetY = 0
let reason = 'no-positioned-poi'
if (!import.meta.env.DEV) return
if (poiY) {
const modelTolerance = Math.max(2, modelY.height * 0.35)
const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance
const box = getObjectBox(model)
const positionedPois = pois.filter((poi): poi is RenderPoi & { positionGltf: [number, number, number] } => (
Boolean(poi.positionGltf)
))
const coordinateErrors = positionedPois.map((poi) => {
const [x, y, z] = poi.positionGltf
const error = Math.hypot(
Math.max(box.min.x - x, 0, x - box.max.x),
Math.max(box.min.y - y, 0, y - box.max.y),
Math.max(box.min.z - z, 0, z - box.max.z)
)
return { poi, error }
})
const maxCoordinateError = Math.max(0, ...coordinateErrors.map(({ error }) => error))
const diagnosticsKey = [
floorId,
positionedPois.length,
roundDiagnosticNumber(maxCoordinateError),
roundDiagnosticNumber(box.min.x),
roundDiagnosticNumber(box.min.y),
roundDiagnosticNumber(box.min.z),
roundDiagnosticNumber(box.max.x),
roundDiagnosticNumber(box.max.y),
roundDiagnosticNumber(box.max.z)
].join(':')
if (overlapsModelRange) {
reason = 'poi-y-overlaps-model-y'
} else {
offsetY = modelY.center - poiY.median
reason = 'align-poi-median-to-model-center'
}
}
if (poiCoordinateDiagnosticsKeys.has(diagnosticsKey)) return
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
poiCoordinateDiagnosticsKeys.add(diagnosticsKey)
logThreeMapDiagnostic('poi-coordinate-diagnostics', {
floorId,
coordinateSpace: 'GLB_METER',
modelBounds: {
min: serializeVector3(box.min),
max: serializeVector3(box.max)
},
positionedPoiCount: positionedPois.length,
maxCoordinateError: roundDiagnosticNumber(maxCoordinateError),
mismatchedPois: coordinateErrors
.filter(({ error }) => error > 1e-6)
.slice(0, 12)
.map(({ poi, error }) => ({
poiId: poi.id,
positionGltf: poi.positionGltf,
coordinateError: roundDiagnosticNumber(error)
}))
})
}
const getPoiGlyph = (poi: RenderPoi) => (
@@ -1449,7 +1370,6 @@ const updateAmbientPoiLabels = () => {
const densityTier = getPoiLabelDensityTier()
const acceptedBounds: Array<ReturnType<typeof getAmbientLabelBounds>> = []
const markerSize = getPoiMarkerSize()
const candidates = getPoiSprites()
.map((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
@@ -1481,44 +1401,19 @@ const updateAmbientPoiLabels = () => {
updateAmbientLabelScale(labelSprite, poi)
const baseOffset = markerSize * (isHallPoi(poi) ? poiAmbientLabelLiftFactor : 1.82)
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(displayPosition.x, displayPosition.y + baseOffset + offset, displayPosition.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
labelSprite.position.copy(displayPosition)
const screenPosition = getProjectedScreenPosition(labelSprite)
if (!screenPosition) {
labelSprite.visible = false
return
}
if (!accepted) {
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
}
const bounds = getAmbientLabelBounds(screenPosition, poi)
const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing))
labelSprite.visible = !overlaps
if (!overlaps) {
acceptedBounds.push(bounds)
}
})
}
@@ -1662,12 +1557,20 @@ const cancelCameraTween = (options: { manual?: boolean } = {}) => {
const moveCameraTo = (
position: THREE.Vector3,
target: THREE.Vector3,
options: { durationMs?: number; immediate?: boolean; onComplete?: () => void } = {}
options: {
durationMs?: number
immediate?: boolean
reason?: 'poi-focus'
onComplete?: () => void
} = {}
) => {
if (!camera || !controls) return
const toPosition = position.clone()
const toTarget = target.clone()
if (options.reason === 'poi-focus') {
poiFocusCameraAnimationCount += 1
}
isProgrammaticCameraChange = true
clearProgrammaticCameraTimer()
@@ -1688,6 +1591,7 @@ const moveCameraTo = (
toTarget,
startedAt: performance.now(),
durationMs: options.durationMs ?? cameraTweenDurationMs,
reason: options.reason,
onComplete: options.onComplete
}
}
@@ -2358,8 +2262,7 @@ const disposePoiMarkerCache = () => {
})
poiMarkerGroupCache.clear()
poiDataCache.clear()
poiDisplayOffsetYCache.clear()
poiDisplayDiagnosticsKeys.clear()
poiCoordinateDiagnosticsKeys.clear()
}
const clearPoiGroupChildren = () => {
@@ -2572,22 +2475,8 @@ const clearRoutePreview = () => {
}
}
const getRouteFloorOffsetY = (floorId: string) => {
if (activeView.value !== 'multi' || !activeModel) return 0
let offsetY = 0
activeModel.traverse((child) => {
if (offsetY) return
if (child.userData.floorId === floorId && typeof child.userData.multiFloorOffsetY === 'number') {
offsetY = child.userData.multiFloorOffsetY
}
})
return offsetY
}
const routePointToVector = (position: [number, number, number], floorId: string, lift = 0.45) => (
new THREE.Vector3(position[0], position[1] + getRouteFloorOffsetY(floorId) + lift, position[2])
const routePointToVector = (position: [number, number, number]) => (
new THREE.Vector3(position[0], position[1], position[2])
)
const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
@@ -2677,7 +2566,7 @@ const renderRouteEndpoint = (
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
const marker = createRouteMarkerSprite(label, color, markerSize)
marker.position.copy(routePointToVector(endpoint.position, endpoint.floorId, markerSize * 0.45))
marker.position.copy(routePointToVector(endpoint.position))
routeGroup.add(marker)
}
@@ -2688,7 +2577,7 @@ const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
.forEach((point) => {
const marker = createRouteMarkerSprite('换', '#8a5cf6', markerSize * 0.72)
marker.position.copy(routePointToVector(point.position, point.floorId, markerSize * 0.25))
marker.position.copy(routePointToVector(point.position))
routeGroup!.add(marker)
})
}
@@ -2746,7 +2635,7 @@ const renderRoutePreview = () => {
if (segment.points.length < 2) return
targetRouteGroup.add(createRouteLine(
segment.points.map((point) => routePointToVector(point.position, point.floorId)),
segment.points.map((point) => routePointToVector(point.position)),
activeView.value === 'floor' ? 1 : 0.88
))
})
@@ -3477,6 +3366,7 @@ const captureCameraSnapshot = (): CameraSnapshot => {
const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
if (!camera || !controls) return
cameraSnapshotRestoreCount += 1
cancelCameraTween()
clearProgrammaticCameraTimer()
isProgrammaticCameraChange = false
@@ -3609,13 +3499,30 @@ const getProjectedBuildingAnchors = () => {
})
}
const getVisualFocusReport = () => {
if (!selectedPOI.value?.positionGltf) return null
const marker = findPoiSprite(selectedPOI.value.id)
return {
poiId: selectedPOI.value.id,
floorId: selectedPOI.value.floorId,
positionGltf: [...selectedPOI.value.positionGltf] as [number, number, number],
markerPosition: marker
? { x: marker.position.x, y: marker.position.y, z: marker.position.z }
: null
}
}
const getVisualStabilityReport = () => ({
camera: serializeCameraSnapshot(captureCameraSnapshot()),
modelRoot: getModelRootTransformAudit(activeModel),
anchors: getProjectedBuildingAnchors(),
activeView: activeView.value,
floorId: currentFloor.value,
isCameraTweening: Boolean(cameraTween)
isCameraTweening: Boolean(cameraTween),
poiFocusCameraAnimationCount,
cameraSnapshotRestoreCount,
focus: getVisualFocusReport()
})
const installVisualStabilityDiagnostics = () => {
@@ -3633,7 +3540,25 @@ const installVisualStabilityDiagnostics = () => {
})),
switchFloor: handleFloorChange,
showOverview,
resetCamera
resetCamera,
getFloorPois: async (floorId: string) => {
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor) return []
const pois = poiDataCache.get(floorId) || await props.modelSource.loadFloorPois(floorId)
return pois
.filter((poi) => Boolean(poi.positionGltf))
.map((poi) => ({
poiId: poi.id,
floorId: poi.floorId,
name: poi.name,
positionGltf: poi.positionGltf!
}))
},
focusTargetPoi: async (request: TargetPoiFocusRequest) => {
queueTargetFocus(request)
return targetFocusQueue
},
clearTargetFocus
}
}
@@ -4629,7 +4554,7 @@ const showFocusPoiLabel = (poi: RenderPoi) => {
const markerSize = getPoiMarkerSize()
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
activeFocusLabelSprite.frustumCulled = false
activeFocusLabelSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusLabelLiftFactor))
activeFocusLabelSprite.position.copy(displayPosition)
updateFocusLabelScale()
poiGroup.add(activeFocusLabelSprite)
}
@@ -4643,7 +4568,7 @@ const showFocusPoiBase = (poi: RenderPoi) => {
const markerSize = getPoiMarkerSize()
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
activeFocusBaseSprite.frustumCulled = false
activeFocusBaseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusBaseLiftFactor))
activeFocusBaseSprite.position.copy(displayPosition)
poiGroup.add(activeFocusBaseSprite)
}
@@ -4656,7 +4581,7 @@ const showFocusPoiPulse = (poi: RenderPoi) => {
const markerSize = getPoiMarkerSize()
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
activeFocusPulseSprite.frustumCulled = false
activeFocusPulseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusPulseLiftFactor))
activeFocusPulseSprite.position.copy(displayPosition)
poiGroup.add(activeFocusPulseSprite)
}
@@ -4800,13 +4725,13 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null
const poi = createPoiFromFocusRequest(request)
const displayPosition = poi ? getPoiDisplayPosition(poi, request.floorId) : null
const displayPosition = poi ? getPoiDisplayPosition(poi) : null
if (!poi || !displayPosition) return null
const markerSize = getPoiMarkerSize()
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
hitTarget.position.copy(sprite.position)
sprite.position.copy(displayPosition)
hitTarget.position.copy(displayPosition)
poiGroup.add(sprite)
poiGroup.add(hitTarget)
@@ -4826,14 +4751,14 @@ const createPoiMarkerGroup = (
group.userData.displayMode = displayMode
pois.forEach((poi) => {
const displayPosition = getPoiDisplayPosition(poi, floorId)
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition) return
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
hitTarget.position.copy(sprite.position)
sprite.position.copy(displayPosition)
hitTarget.position.copy(displayPosition)
if (labelSprite) {
labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
labelSprite.position.copy(displayPosition)
labelSprite.visible = false
}
sprite.visible = isPoiIncludedByVisibleFilter(poi)
@@ -4877,8 +4802,7 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
poiGroupCurrentFloor: poiGroup.userData.currentFloor,
selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value),
markerSize: roundDiagnosticNumber(entry.markerSize),
displayOffsetY: roundDiagnosticNumber(entry.displayOffsetY),
visualLiftY: roundDiagnosticNumber(entry.markerSize * poiMarkerVisualLiftFactor)
coordinateSpace: 'GLB_METER'
})
}
@@ -4945,9 +4869,9 @@ const prepareFloorPOIs = async (
renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length,
filteredCategoryCounts: countPoisByCategory(filteredPois)
})
const displayOffsetY = markerModel
? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois)
: getCachedPoiDisplayOffsetY(floor.floorId)
if (markerModel) {
recordPoiCoordinateDiagnostics(floor.floorId, markerModel, validPois)
}
const markerSize = getPoiMarkerSizeForModel(markerModel)
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
const entry: PoiMarkerCacheEntry = {
@@ -4956,7 +4880,6 @@ const prepareFloorPOIs = async (
pois: validPois,
group: markerGroup,
markerSize,
displayOffsetY,
rawPoiCount: floorPois.length,
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
filteredCategoryCounts: countPoisByCategory(filteredPois)
@@ -5232,30 +5155,25 @@ const isSceneReadyForTargetFocus = () => (
)
const focusCameraOnPoi = (poi: RenderPoi) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!camera || !controls || !displayPosition) return
const target = getPoiDisplayPosition(poi)
if (!camera || !controls || !target) return
const target = displayPosition.clone()
const modelSize = activeModel
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
: new THREE.Vector3(80, 80, 80)
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
const currentOffset = camera.position.clone().sub(controls.target)
const currentDistance = currentOffset.length()
const direction = currentDistance > 1e-6
? currentOffset.multiplyScalar(1 / currentDistance)
: new THREE.Vector3(0.72, 0.58, 1).normalize()
const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4)
const distance = Math.min(
Math.max(maxDim * focusDistanceFactor, 28),
Math.max(maxDim * focusMaxDistanceFactor, 80)
const minimumDistance = Math.max(controls.minDistance, 1)
const maximumDistance = Math.max(minimumDistance, controls.maxDistance)
const distance = THREE.MathUtils.clamp(
currentDistance * focusDistanceFactor,
minimumDistance,
maximumDistance
)
const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
const nextPosition = target.clone().add(direction.multiplyScalar(distance))
camera.near = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1200)
camera.far = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20)
camera.updateProjectionMatrix()
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.025)
controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 6)
moveCameraTo(nextPosition, target)
moveCameraTo(nextPosition, target, { reason: 'poi-focus' })
}
const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
@@ -5387,6 +5305,8 @@ const init3DScene = async () => {
firstModelLoadStartedAt = getNow()
firstModelVisibleReported = false
initialModelSettled = false
poiFocusCameraAnimationCount = 0
cameraSnapshotRestoreCount = 0
isLoading.value = true
loadError.value = false
selectedPOI.value = null
@@ -5570,6 +5490,8 @@ const disposeScene = () => {
clearModelAdjustReportTimer()
hasPendingManualModelAdjustment = false
cameraTween = null
poiFocusCameraAnimationCount = 0
cameraSnapshotRestoreCount = 0
isProgrammaticCameraChange = false
activeAutoSwitchInputSource = 'gesture'
resetInteractionGateState()

View File

@@ -30,6 +30,14 @@ interface VisualReport {
activeView: string
floorId: string
isCameraTweening: boolean
poiFocusCameraAnimationCount: number
cameraSnapshotRestoreCount: number
focus: {
poiId: string
floorId: string
positionGltf: [number, number, number]
markerPosition: { x: number; y: number; z: number } | null
} | null
}
interface FloorReport {
@@ -38,11 +46,27 @@ interface FloorReport {
modelMatchKeys: string[]
}
interface PoiFocusCandidate {
poiId: string
floorId: string
name: string
positionGltf: [number, number, number]
}
interface VisualStabilityApi {
getReport: () => VisualReport
getFloors: () => FloorReport[]
switchFloor: (floorId: string) => Promise<void>
showOverview: () => Promise<void>
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
focusTargetPoi: (request: {
requestId: number | string
poiId: string
floorId: string
name?: string
positionGltf: [number, number, number]
}) => Promise<unknown>
clearTargetFocus: () => void
}
const getApiState = async (page: Page) => {
@@ -219,6 +243,43 @@ const resolveRequiredFloorId = (floors: FloorReport[], requested: string) => {
return floor!.floorId
}
const getFloorPois = async (page: Page, floorId: string) => {
const pois = await page.evaluate(async (id) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
return api.getFloorPois(id)
}, floorId)
expect(pois.length, `floor ${floorId} must contain a positioned POI`).toBeGreaterThan(0)
return pois
}
const getVectorDelta = (left: { x: number; y: number; z: number }, right: [number, number, number]) => (
Math.hypot(left.x - right[0], left.y - right[1], left.z - right[2])
)
const waitForPoiFocusToSettle = async (page: Page, testInfo: TestInfo, name: string) => {
let sawTween = false
let stableFrames = 0
for (let frame = 0; frame < 180; frame += 1) {
const report = await getReport(page)
expect(report.modelRoot, `blank POI focus frame ${frame}`).not.toBeNull()
expect(report.modelRoot!.maxError).toBeLessThan(1e-12)
sawTween ||= report.isCameraTweening
stableFrames = report.isCameraTweening ? 0 : stableFrames + 1
if (frame === 0) {
await screenshot(page, testInfo, `${name}-during`)
}
if (sawTween && stableFrames >= 3) return report
await page.waitForTimeout(40)
}
throw new Error(`${name} did not settle after one POI focus animation`)
}
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
await page.goto('/')
await Promise.all([
@@ -235,14 +296,14 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project
const lMinus2 = resolveRequiredFloorId(state!.floors, 'L-2')
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
const l1Point5 = resolveRequiredFloorId(state!.floors, 'L1.5')
const l2 = resolveRequiredFloorId(state!.floors, 'L2')
const l3 = resolveRequiredFloorId(state!.floors, 'L3')
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
const l4 = resolveRequiredFloorId(state!.floors, 'L4')
const l5 = resolveRequiredFloorId(state!.floors, 'L5')
await switchFloor(page, testInfo, l1, 'exterior-l1')
await switchFloor(page, testInfo, l2, 'l1-l2')
await switchFloor(page, testInfo, l3, 'l2-l3')
await switchFloor(page, testInfo, l1, 'l3-l1')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1')
await switchFloor(page, testInfo, l4, 'lminus1-l4')
await switchFloor(page, testInfo, l1, 'l4-l1')
await showOverview(page, testInfo, 'l1-exterior')
await switchFloor(page, testInfo, lMinus2, 'exterior-lminus2')
@@ -254,14 +315,87 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project
await showOverview(page, testInfo, 'l1point5-exterior')
await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit')
await switchFloor(page, testInfo, l2, 'l1-l2-auto-exit')
await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-button-exit', 'button')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-auto-exit')
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-button-exit', 'button')
await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit')
await switchFloor(page, testInfo, l2, 'l1-l2-wheel-exit')
await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-wheel-exit', 'wheel')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-wheel-exit')
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-wheel-exit', 'wheel')
await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit')
await switchFloor(page, testInfo, l2, 'l1-l2-touch-exit')
await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-touch-exit', 'touch')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-touch-exit')
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-touch-exit', 'touch')
})
test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and does not rebound', async ({ page }, testInfo) => {
await page.goto('/')
await Promise.all([
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
page.getByText('馆内', { exact: true }).click()
])
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
const state = await getApiState(page)
expect(state).not.toBeNull()
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
const [targetPoi] = await getFloorPois(page, lMinus1)
await switchFloor(page, testInfo, l1, 'search-focus-exterior-l1')
const beforeFocus = await getReport(page)
await screenshot(page, testInfo, 'search-focus-before')
const focusTransition = page.evaluate(async (request) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
await api.focusTargetPoi(request)
}, {
requestId: 'e2e-cross-floor-search-focus',
poiId: targetPoi.poiId,
floorId: targetPoi.floorId,
name: targetPoi.name,
positionGltf: targetPoi.positionGltf
})
let focusRequestCompleted = false
void focusTransition.then(() => { focusRequestCompleted = true })
for (let frame = 0; !focusRequestCompleted && frame < 180; frame += 1) {
const report = await getReport(page)
expect(report.modelRoot, `blank cross-floor POI load frame ${frame}`).not.toBeNull()
expect(report.modelRoot!.maxError).toBeLessThan(1e-12)
await page.waitForTimeout(40)
}
expect(focusRequestCompleted, 'cross-floor POI focus exceeded the sampling window').toBe(true)
await focusTransition
const afterFocus = await waitForPoiFocusToSettle(page, testInfo, 'search-focus')
await screenshot(page, testInfo, 'search-focus-after')
expect(afterFocus.activeView).toBe('floor')
expect(afterFocus.floorId).toBe(lMinus1)
expect(afterFocus.focus).not.toBeNull()
expect(afterFocus.focus!.poiId).toBe(targetPoi.poiId)
expect(afterFocus.focus!.positionGltf).toEqual(targetPoi.positionGltf)
expect(getVectorDelta(afterFocus.camera.target, targetPoi.positionGltf)).toBeLessThan(1e-6)
expect(afterFocus.focus!.markerPosition).not.toBeNull()
expect(getVectorDelta(afterFocus.focus!.markerPosition!, targetPoi.positionGltf)).toBeLessThan(1e-6)
expect(afterFocus.cameraSnapshotRestoreCount - beforeFocus.cameraSnapshotRestoreCount).toBe(1)
expect(afterFocus.poiFocusCameraAnimationCount - beforeFocus.poiFocusCameraAnimationCount).toBe(1)
await page.waitForTimeout(240)
const afterIdle = await getReport(page)
expect(afterIdle.isCameraTweening).toBe(false)
expect(afterIdle.poiFocusCameraAnimationCount).toBe(afterFocus.poiFocusCameraAnimationCount)
expect(getMaximumCameraDelta(afterFocus.camera, afterIdle.camera)).toBeLessThan(1e-6)
await page.evaluate(() => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
api.clearTargetFocus()
})
await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1')
await showOverview(page, testInfo, 'search-focus-clear-l1-exterior')
})

View File

@@ -156,7 +156,7 @@ describe('点位详情实际渲染与返回闭环', () => {
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
const mapShell = wrapper.getComponent(GuideMapShellStub)
expect(sheet.text()).toContain('二层卫生间')
expect(sheet.text()).toContain('类别卫生间')
expect(sheet.text()).toContain('类别洗手间')
expect(sheet.text()).toContain('楼层2F')
expect(sheet.text()).not.toContain('可无障碍到达')
expect(sheet.text()).not.toContain('展示点位')