统一楼层视觉基线与详情返回复位
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-14 23:17:33 +08:00
parent 84fbbc0694
commit c08e1afe85
6 changed files with 274 additions and 34 deletions

View File

@@ -209,6 +209,7 @@ interface LoadFloorOptions {
detachPoiBeforeLoad?: boolean detachPoiBeforeLoad?: boolean
allowSameFloorReload?: boolean allowSameFloorReload?: boolean
cameraSnapshot?: CameraSnapshot cameraSnapshot?: CameraSnapshot
applyFloorBaseline?: boolean
onCameraStable?: () => void onCameraStable?: () => void
} }
@@ -243,6 +244,21 @@ interface CameraSnapshot {
distance: number distance: number
} }
type ResetViewBaselineOptions = {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}
interface FloorViewBaseline {
floorId: string
modelUrl: string
packageEpoch: number
aspectRatio: number
boundsSignature: string
camera: CameraSnapshot
}
// 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。 // 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。
const referenceOverviewCameraState: CameraSnapshot = (() => { const referenceOverviewCameraState: CameraSnapshot = (() => {
const referenceCamera = new THREE.PerspectiveCamera( const referenceCamera = new THREE.PerspectiveCamera(
@@ -517,6 +533,8 @@ let firstModelLoadStartedAt = 0
let firstModelVisibleReported = false let firstModelVisibleReported = false
let initialModelSettled = false let initialModelSettled = false
let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null
const floorViewBaselines = new Map<string, FloorViewBaseline>()
let modelPackageEpoch = 0
const cameraTweenEnabled = true const cameraTweenEnabled = true
const cameraTweenDurationMs = 480 const cameraTweenDurationMs = 480
@@ -1983,7 +2001,9 @@ const handleResize = () => {
const width = Math.max(1, container.clientWidth) const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight) const height = Math.max(1, container.clientHeight)
camera.aspect = width / height const nextAspect = width / height
if (Math.abs(camera.aspect - nextAspect) > 1e-6) clearFloorViewBaselines()
camera.aspect = nextAspect
camera.updateProjectionMatrix() camera.updateProjectionMatrix()
renderer.setSize(width, height) renderer.setSize(width, height)
} }
@@ -3390,6 +3410,99 @@ const applyReferenceOverviewCameraState = () => {
restoreCameraSnapshot(referenceOverviewCameraState) restoreCameraSnapshot(referenceOverviewCameraState)
} }
const clearFloorViewBaselines = () => {
floorViewBaselines.clear()
}
const getBoxSignature = (box: THREE.Box3) => [
box.min.x,
box.min.y,
box.min.z,
box.max.x,
box.max.y,
box.max.z
].map((value) => value.toFixed(6)).join(':')
const createFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string): FloorViewBaseline | null => {
if (!camera || !controls) return null
const overviewCamera = initialGuideState?.camera || referenceOverviewCameraState
const box = getObjectBox(model)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 1)
const verticalFov = overviewCamera.fov * (Math.PI / 180)
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * camera.aspect)
const visibleFraction = FLOOR_CAMERA_DISTANCE_FACTOR
const distance = Math.max(
size.y / (2 * Math.tan(verticalFov / 2) * visibleFraction),
size.x / (2 * Math.tan(horizontalFov / 2) * visibleFraction),
size.z / (2 * Math.tan(verticalFov / 2) * visibleFraction),
1
)
const direction = overviewCamera.position.clone().sub(overviewCamera.target).normalize()
const target = center.clone()
const position = target.clone().add(direction.multiplyScalar(distance))
const offsetRatio = SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio
const viewDirection = target.clone().sub(position).normalize()
const cameraRight = new THREE.Vector3().crossVectors(viewDirection, overviewCamera.up).normalize()
const viewHeight = 2 * distance * Math.tan(verticalFov / 2)
const viewWidth = viewHeight * camera.aspect
const screenOffset = cameraRight.multiplyScalar(offsetRatio.x * viewWidth)
.add(overviewCamera.up.clone().multiplyScalar(offsetRatio.y * viewHeight))
position.add(screenOffset)
target.add(screenOffset)
const baselineCamera = new THREE.PerspectiveCamera(
overviewCamera.fov,
camera.aspect,
Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1000),
Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20)
)
baselineCamera.position.copy(position)
baselineCamera.up.copy(overviewCamera.up)
baselineCamera.zoom = overviewCamera.zoom
baselineCamera.lookAt(target)
baselineCamera.updateProjectionMatrix()
return {
floorId,
modelUrl,
packageEpoch: modelPackageEpoch,
aspectRatio: camera.aspect,
boundsSignature: getBoxSignature(box),
camera: {
position: baselineCamera.position.clone(),
target,
up: baselineCamera.up.clone(),
quaternion: baselineCamera.quaternion.clone(),
fov: baselineCamera.fov,
zoom: baselineCamera.zoom,
near: baselineCamera.near,
far: baselineCamera.far,
distance
}
}
}
const getFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string) => {
const cached = floorViewBaselines.get(floorId)
const boundsSignature = getBoxSignature(getObjectBox(model))
if (
cached
&& cached.modelUrl === modelUrl
&& cached.packageEpoch === modelPackageEpoch
&& cached.boundsSignature === boundsSignature
&& Math.abs(cached.aspectRatio - (camera?.aspect || 1)) < 1e-6
) {
return cached
}
const baseline = createFloorViewBaseline(floorId, model, modelUrl)
if (baseline) floorViewBaselines.set(floorId, baseline)
return baseline || null
}
const serializeCameraSnapshot = (snapshot: CameraSnapshot) => ({ const serializeCameraSnapshot = (snapshot: CameraSnapshot) => ({
position: { x: snapshot.position.x, y: snapshot.position.y, z: snapshot.position.z }, position: { x: snapshot.position.x, y: snapshot.position.y, z: snapshot.position.z },
target: { x: snapshot.target.x, y: snapshot.target.y, z: snapshot.target.z }, target: { x: snapshot.target.x, y: snapshot.target.y, z: snapshot.target.z },
@@ -3542,6 +3655,10 @@ const installVisualStabilityDiagnostics = () => {
label: floor.label, label: floor.label,
modelMatchKeys: floor.modelMatchKeys || [] modelMatchKeys: floor.modelMatchKeys || []
})), })),
getFloorBaseline: (floorId: string) => {
const baseline = floorViewBaselines.get(floorId)
return baseline ? serializeCameraSnapshot(baseline.camera) : null
},
switchFloor: handleFloorChange, switchFloor: handleFloorChange,
showOverview, showOverview,
resetCamera, resetCamera,
@@ -3563,6 +3680,7 @@ const installVisualStabilityDiagnostics = () => {
return targetFocusQueue return targetFocusQueue
}, },
clearTargetFocus, clearTargetFocus,
resetToViewBaseline,
resetToInitialState resetToInitialState
} }
} }
@@ -4003,7 +4121,10 @@ const commitPreparedFloorScene = (
selectedPOI.value = null selectedPOI.value = null
updatePoiMarkerFocus() updatePoiMarkerFocus()
hasLoadedFloorViewOnce = true hasLoadedFloorViewOnce = true
const cameraSnapshot = options.cameraSnapshot || referenceOverviewCameraState const floorBaseline = options.applyFloorBaseline
? getFloorViewBaseline(expectedFloorId, activeModel, prepared.floor.modelUrl)
: null
const cameraSnapshot = floorBaseline?.camera || options.cameraSnapshot || referenceOverviewCameraState
restoreCameraSnapshot(cameraSnapshot) restoreCameraSnapshot(cameraSnapshot)
if (!referenceBuildingAnchors.length) { if (!referenceBuildingAnchors.length) {
updateReferenceBuildingAnchors(activeModel) updateReferenceBuildingAnchors(activeModel)
@@ -5272,6 +5393,8 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
const loadModelPackage = async () => { const loadModelPackage = async () => {
setProgress(8, '正在读取馆内导览资源...') setProgress(8, '正在读取馆内导览资源...')
const packageData = await props.modelSource.loadPackage() const packageData = await props.modelSource.loadPackage()
modelPackageEpoch += 1
clearFloorViewBaselines()
renderPackage.value = packageData renderPackage.value = packageData
setProgress(14, '正在读取楼层索引...') setProgress(14, '正在读取楼层索引...')
@@ -5314,6 +5437,7 @@ const loadModelPackage = async () => {
const init3DScene = async () => { const init3DScene = async () => {
try { try {
disposeScene() disposeScene()
clearFloorViewBaselines()
isDisposed = false isDisposed = false
firstModelLoadStartedAt = getNow() firstModelLoadStartedAt = getNow()
firstModelVisibleReported = false firstModelVisibleReported = false
@@ -5461,7 +5585,7 @@ const showMultiFloor = async () => {
// Restores business and camera state on the existing scene. It never initializes WebGL or // Restores business and camera state on the existing scene. It never initializes WebGL or
// reloads the model package, so closing a detail page cannot remount ThreeMap. // reloads the model package, so closing a detail page cannot remount ThreeMap.
const resetToInitialState = async () => { const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
clearTargetFocus() clearTargetFocus()
invalidateModelLoads() invalidateModelLoads()
adjacentPreloadSeq += 1 adjacentPreloadSeq += 1
@@ -5476,7 +5600,7 @@ const resetToInitialState = async () => {
disposeFocusBase() disposeFocusBase()
clearFocusHallHighlight() clearFocusHallHighlight()
updatePoiMarkerFocus() updatePoiMarkerFocus()
autoSwitchStateMachine.reset('overview') autoSwitchStateMachine.reset(options.view)
resetInteractionGateState() resetInteractionGateState()
const baseline = initialGuideState const baseline = initialGuideState
@@ -5484,15 +5608,38 @@ const resetToInitialState = async () => {
isLoading.value = true isLoading.value = true
try { try {
currentFloor.value = baseline.floorId if (options.view === 'floor') {
if (activeView.value !== 'overview') { const floorId = options.floorId
await loadOverview({ cameraSnapshot: baseline.camera }) if (!floorId) return false
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor) return false
if (activeView.value !== 'floor' || currentFloor.value !== floorId || activeModel?.userData.floorId !== floorId) {
await loadFloor(floorId, { applyFloorBaseline: true, detachPoiBeforeLoad: true })
} else {
const floorBaseline = getFloorViewBaseline(floorId, activeModel, floor.modelUrl)
if (!floorBaseline) return false
restoreCameraSnapshot(floorBaseline.camera)
}
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false
activeView.value = 'floor'
currentFloor.value = floorId
autoSwitchStateMachine.reset('floor')
} else {
currentFloor.value = baseline.floorId
if (activeView.value !== 'overview') {
await loadOverview({ cameraSnapshot: baseline.camera })
}
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false
restoreCameraSnapshot(baseline.camera)
currentFloor.value = baseline.floorId
activeView.value = 'overview'
autoSwitchStateMachine.reset('overview')
}
if (controls && activeView.value === 'floor') {
ensureFloorAutoExitZoomRange()
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
} }
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false
restoreCameraSnapshot(baseline.camera)
currentFloor.value = baseline.floorId
activeView.value = 'overview'
autoSwitchStateMachine.reset('overview')
refreshPoiVisibilityByDistance() refreshPoiVisibilityByDistance()
return true return true
} catch (error) { } catch (error) {
@@ -5505,6 +5652,11 @@ const resetToInitialState = async () => {
} }
} }
const resetToInitialState = () => resetToViewBaseline({
view: 'overview',
reason: 'manual-reset'
})
const retryLoad = () => { const retryLoad = () => {
init3DScene() init3DScene()
} }
@@ -5595,6 +5747,7 @@ defineExpose({
}, },
clearSelection: clearMapSelection, clearSelection: clearMapSelection,
clearRoute: clearRoutePreview, clearRoute: clearRoutePreview,
resetToViewBaseline,
resetToInitialState, resetToInitialState,
clearNavigation: () => clearMapSelection(false), clearNavigation: () => clearMapSelection(false),
disableAutoSwitchTemporarily disableAutoSwitchTemporarily

View File

@@ -460,6 +460,11 @@ const indoorRendererRef = ref<{
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
clearSelection?: (shouldEmit?: boolean) => void clearSelection?: (shouldEmit?: boolean) => void
clearRoute?: () => Promise<void> | void clearRoute?: () => Promise<void> | void
resetToViewBaseline?: (options: {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}) => Promise<boolean> | boolean
resetToInitialState?: () => Promise<boolean> | boolean resetToInitialState?: () => Promise<boolean> | boolean
disableAutoSwitchTemporarily?: (durationMs: number) => void disableAutoSwitchTemporarily?: (durationMs: number) => void
} | null>(null) } | null>(null)
@@ -781,6 +786,11 @@ defineExpose({
indoorRendererRef.value?.clearRoute?.() indoorRendererRef.value?.clearRoute?.()
}, },
showOverview: handleShowOverview, showOverview: handleShowOverview,
resetToViewBaseline: (options: {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}) => indoorRendererRef.value?.resetToViewBaseline?.(options) || false,
resetToInitialState: () => indoorRendererRef.value?.resetToInitialState?.() || false resetToInitialState: () => indoorRendererRef.value?.resetToInitialState?.() || false
}) })
</script> </script>

View File

@@ -18,6 +18,12 @@ export type GuideModelInitialState = Readonly<{
autoFloorSwitchActive: boolean autoFloorSwitchActive: boolean
}> }>
export type PoiDetailReturnContext = Readonly<{
floorId: string
resetMode: 'floor-baseline'
requestId: number
}>
// The object is frozen so callers cannot accidentally turn a captured baseline into live state. // The object is frozen so callers cannot accidentally turn a captured baseline into live state.
export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({ export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
mode: '3d', mode: '3d',
@@ -38,8 +44,9 @@ export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
}) })
const initialState = ref<GuideModelInitialState>(GUIDE_MODEL_INITIAL_STATE) const initialState = ref<GuideModelInitialState>(GUIDE_MODEL_INITIAL_STATE)
const pendingModelResetAfterPoiDetail = ref(false) const pendingModelResetAfterPoiDetail = ref<PoiDetailReturnContext | null>(null)
const requestGeneration = ref(0) const requestGeneration = ref(0)
let poiDetailRequestId = 0
export const useGuideModelState = () => { export const useGuideModelState = () => {
const initializeModel = (floorId: string) => { const initializeModel = (floorId: string) => {
@@ -52,18 +59,24 @@ export const useGuideModelState = () => {
return initialState.value return initialState.value
} }
const beginPoiDetailReturn = () => { const beginPoiDetailReturn = (floorId: string) => {
pendingModelResetAfterPoiDetail.value = true const context = Object.freeze({
floorId,
resetMode: 'floor-baseline' as const,
requestId: ++poiDetailRequestId
})
pendingModelResetAfterPoiDetail.value = context
return context
} }
const cancelPoiDetailReturn = () => { const cancelPoiDetailReturn = () => {
pendingModelResetAfterPoiDetail.value = false pendingModelResetAfterPoiDetail.value = null
} }
const consumePoiDetailReturn = () => { const consumePoiDetailReturn = () => {
if (!pendingModelResetAfterPoiDetail.value) return false const context = pendingModelResetAfterPoiDetail.value
pendingModelResetAfterPoiDetail.value = false pendingModelResetAfterPoiDetail.value = null
return true return context
} }
// All async focus/floor operations compare this generation before committing. // All async focus/floor operations compare this generation before committing.

View File

@@ -327,7 +327,7 @@ import type {
import { import {
type TargetPoiFocusRequestViewModel type TargetPoiFocusRequestViewModel
} from '@/view-models/guideViewModels' } from '@/view-models/guideViewModels'
import { useGuideModelState } from '@/composables/useGuideModelState' import { useGuideModelState, type PoiDetailReturnContext } from '@/composables/useGuideModelState'
import { import {
ARRIVAL_TARGETS_BY_TYPE, ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget, type ArrivalSearchTarget,
@@ -418,6 +418,11 @@ const returningToOverview = ref(false)
const guideMapShellRef = ref<{ const guideMapShellRef = ref<{
clearRoute?: () => void clearRoute?: () => void
showOverview?: () => Promise<void> | void showOverview?: () => Promise<void> | void
resetToViewBaseline?: (options: {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}) => Promise<boolean> | boolean
resetToInitialState?: () => Promise<boolean> | boolean resetToInitialState?: () => Promise<boolean> | boolean
} | null>(null) } | null>(null)
const homeSearchPanelRef = ref<{ const homeSearchPanelRef = ref<{
@@ -430,7 +435,7 @@ const homeSearchExpanded = ref(false)
const homeCategoryModeActive = ref(false) const homeCategoryModeActive = ref(false)
const searchVisiblePoiIds = ref<string[] | null>(null) const searchVisiblePoiIds = ref<string[] | null>(null)
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null) const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
let pendingGuideModelResetUntilReady = false let pendingGuideModelResetUntilReady: PoiDetailReturnContext | null = null
type PoiSearchOverlayHistoryState = { type PoiSearchOverlayHistoryState = {
museumGuideOverlay?: 'poi-search' museumGuideOverlay?: 'poi-search'
@@ -1036,8 +1041,9 @@ const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: strin
guideModelState.initializeModel(event.floorId || activeGuideFloor.value) guideModelState.initializeModel(event.floorId || activeGuideFloor.value)
settleLaunchOverlayByModel('ready', event) settleLaunchOverlayByModel('ready', event)
if (pendingGuideModelResetUntilReady) { if (pendingGuideModelResetUntilReady) {
pendingGuideModelResetUntilReady = false const context = pendingGuideModelResetUntilReady
void resetGuideModelToInitial({ reason: 'queued-poi-detail-close' }) pendingGuideModelResetUntilReady = null
void resetGuideModelToInitial({ reason: 'queued-poi-detail-close', floorId: context.floorId })
} }
} }
@@ -1456,7 +1462,13 @@ const closeHomeSearchDock = () => {
homeSearchExpanded.value = false homeSearchExpanded.value = false
} }
const resetGuideModelToInitial = async ({ reason }: { reason: 'poi-detail-close' | 'queued-poi-detail-close' }) => { const resetGuideModelToInitial = async ({
reason,
floorId
}: {
reason: 'poi-detail-close' | 'queued-poi-detail-close'
floorId: string
}) => {
const initial = guideModelState.initialState.value const initial = guideModelState.initialState.value
guideModelState.invalidatePendingRequests() guideModelState.invalidatePendingRequests()
searchTargetFocusRequest.value = null searchTargetFocusRequest.value = null
@@ -1476,25 +1488,34 @@ const resetGuideModelToInitial = async ({ reason }: { reason: 'poi-detail-close'
failedFloorId.value = '' failedFloorId.value = ''
is3DMode.value = true is3DMode.value = true
guideOutdoorState.value = 'home' guideOutdoorState.value = 'home'
indoorView.value = initial.indoorView indoorView.value = 'floor'
activeGuideFloor.value = initial.defaultActiveFloorId || activeGuideFloor.value activeGuideFloor.value = floorId || initial.defaultActiveFloorId || activeGuideFloor.value
renderedFloorId.value = initial.loadedFloorId || activeGuideFloor.value renderedFloorId.value = activeGuideFloor.value
closeArrivalPanel() closeArrivalPanel()
await homeSearchPanelRef.value?.resetSearchState?.() await homeSearchPanelRef.value?.resetSearchState?.()
closeHomeSearchDock() closeHomeSearchDock()
const resetApplied = await guideMapShellRef.value?.resetToInitialState?.() const resetApplied = await guideMapShellRef.value?.resetToViewBaseline?.({
view: 'floor',
floorId: activeGuideFloor.value,
reason: 'poi-detail-close'
})
if (!resetApplied) { if (!resetApplied) {
// The renderer is still becoming ready; retain only the latest requested reset. // The renderer is still becoming ready; retain only the latest requested reset.
pendingGuideModelResetUntilReady = true pendingGuideModelResetUntilReady = Object.freeze({
floorId: activeGuideFloor.value,
resetMode: 'floor-baseline',
requestId: guideModelState.invalidatePendingRequests()
})
} }
console.info('馆内三维模型已恢复统一初始状态:', { reason }) console.info('馆内三维模型已恢复统一初始状态:', { reason })
} }
onShow(async () => { onShow(async () => {
await nextTick() await nextTick()
if (guideModelState.consumePoiDetailReturn()) { const detailReturnContext = guideModelState.consumePoiDetailReturn()
await resetGuideModelToInitial({ reason: 'poi-detail-close' }) if (detailReturnContext) {
await resetGuideModelToInitial({ reason: 'poi-detail-close', floorId: detailReturnContext.floorId })
return return
} }
@@ -1596,7 +1617,7 @@ const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => {
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => { const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
// The homepage keeps its renderer mounted; detail close is consumed by onShow once. // The homepage keeps its renderer mounted; detail close is consumed by onShow once.
searchTargetFocusRequest.value = null searchTargetFocusRequest.value = null
guideModelState.beginPoiDetailReturn() guideModelState.beginPoiDetailReturn(poi.floorId)
uni.navigateTo({ uni.navigateTo({
url: createSearchDetailUrl(poi, context), url: createSearchDetailUrl(poi, context),
fail: () => { fail: () => {

View File

@@ -60,7 +60,13 @@ interface VisualStabilityApi {
getFloors: () => FloorReport[] getFloors: () => FloorReport[]
switchFloor: (floorId: string) => Promise<void> switchFloor: (floorId: string) => Promise<void>
showOverview: () => Promise<void> showOverview: () => Promise<void>
resetToViewBaseline: (options: {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}) => Promise<boolean>
resetToInitialState: () => Promise<boolean> resetToInitialState: () => Promise<boolean>
getFloorBaseline: (floorId: string) => CameraReport | null
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]> getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
focusTargetPoi: (request: { focusTargetPoi: (request: {
requestId: number | string requestId: number | string
@@ -432,3 +438,34 @@ test('reset invalidates an in-flight cross-floor focus before it can overwrite t
expect(report.hasPendingTargetFocus).toBe(false) expect(report.hasPendingTargetFocus).toBe(false)
expect(report.focus).toBeNull() expect(report.focus).toBeNull()
}) })
test('floor view baseline is deterministic across repeated resets', async ({ page }) => {
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))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0)
const state = await getApiState(page)
expect(state).not.toBeNull()
for (const floor of state!.floors) {
await page.evaluate(async (floorId) => {
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.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
}, floor.floorId)
const first = await getReport(page)
await page.evaluate(async (floorId) => {
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.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
}, floor.floorId)
const second = await getReport(page)
expect(second.activeView).toBe('floor')
expect(second.floorId).toBe(floor.floorId)
expect(getMaximumCameraDelta(first.camera, second.camera)).toBeLessThan(1e-6)
}
})

View File

@@ -321,7 +321,7 @@ describe('首页搜索与地图闭环', () => {
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
}) })
it('分类结果进入详情后统一恢复首页模型初始状态', async () => { it('分类结果进入详情后恢复目标楼层标准视图', async () => {
const wrapper = await mountIndex() const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub) const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub) const map = wrapper.getComponent(GuideMapShellStub)
@@ -356,9 +356,14 @@ describe('首页搜索与地图闭环', () => {
expect(testState.searchResetCount).toBeGreaterThan(0) expect(testState.searchResetCount).toBeGreaterThan(0)
expect(testState.searchRestoreCount).toBe(0) expect(testState.searchRestoreCount).toBe(0)
expect(map.props('activeFloor')).toBe('L1') expect(map.props('activeFloor')).toBe('L2')
expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull()
const resetCount = testState.searchResetCount
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchResetCount).toBe(resetCount)
}) })
it('全屏搜索结果进入详情并返回后同样恢复为默认收缩态', async () => { it('全屏搜索结果进入详情并返回后同样恢复为默认收缩态', async () => {
@@ -380,6 +385,7 @@ describe('首页搜索与地图闭环', () => {
expect(testState.searchRestoreCount).toBe(0) expect(testState.searchRestoreCount).toBe(0)
expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull()
expect(map.props('activeFloor')).toBe('L2')
}) })
it('页面重新显示时要求搜索面板恢复原列表位置', async () => { it('页面重新显示时要求搜索面板恢复原列表位置', async () => {