统一楼层视觉基线与详情返回复位
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
allowSameFloorReload?: boolean
cameraSnapshot?: CameraSnapshot
applyFloorBaseline?: boolean
onCameraStable?: () => void
}
@@ -243,6 +244,21 @@ interface CameraSnapshot {
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 referenceCamera = new THREE.PerspectiveCamera(
@@ -517,6 +533,8 @@ let firstModelLoadStartedAt = 0
let firstModelVisibleReported = false
let initialModelSettled = false
let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null
const floorViewBaselines = new Map<string, FloorViewBaseline>()
let modelPackageEpoch = 0
const cameraTweenEnabled = true
const cameraTweenDurationMs = 480
@@ -1983,7 +2001,9 @@ const handleResize = () => {
const width = Math.max(1, container.clientWidth)
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()
renderer.setSize(width, height)
}
@@ -3390,6 +3410,99 @@ const applyReferenceOverviewCameraState = () => {
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) => ({
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 },
@@ -3542,6 +3655,10 @@ const installVisualStabilityDiagnostics = () => {
label: floor.label,
modelMatchKeys: floor.modelMatchKeys || []
})),
getFloorBaseline: (floorId: string) => {
const baseline = floorViewBaselines.get(floorId)
return baseline ? serializeCameraSnapshot(baseline.camera) : null
},
switchFloor: handleFloorChange,
showOverview,
resetCamera,
@@ -3563,6 +3680,7 @@ const installVisualStabilityDiagnostics = () => {
return targetFocusQueue
},
clearTargetFocus,
resetToViewBaseline,
resetToInitialState
}
}
@@ -4003,7 +4121,10 @@ const commitPreparedFloorScene = (
selectedPOI.value = null
updatePoiMarkerFocus()
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)
if (!referenceBuildingAnchors.length) {
updateReferenceBuildingAnchors(activeModel)
@@ -5272,6 +5393,8 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
const loadModelPackage = async () => {
setProgress(8, '正在读取馆内导览资源...')
const packageData = await props.modelSource.loadPackage()
modelPackageEpoch += 1
clearFloorViewBaselines()
renderPackage.value = packageData
setProgress(14, '正在读取楼层索引...')
@@ -5314,6 +5437,7 @@ const loadModelPackage = async () => {
const init3DScene = async () => {
try {
disposeScene()
clearFloorViewBaselines()
isDisposed = false
firstModelLoadStartedAt = getNow()
firstModelVisibleReported = false
@@ -5461,7 +5585,7 @@ const showMultiFloor = async () => {
// 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.
const resetToInitialState = async () => {
const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
clearTargetFocus()
invalidateModelLoads()
adjacentPreloadSeq += 1
@@ -5476,7 +5600,7 @@ const resetToInitialState = async () => {
disposeFocusBase()
clearFocusHallHighlight()
updatePoiMarkerFocus()
autoSwitchStateMachine.reset('overview')
autoSwitchStateMachine.reset(options.view)
resetInteractionGateState()
const baseline = initialGuideState
@@ -5484,15 +5608,38 @@ const resetToInitialState = async () => {
isLoading.value = true
try {
currentFloor.value = baseline.floorId
if (activeView.value !== 'overview') {
await loadOverview({ cameraSnapshot: baseline.camera })
if (options.view === 'floor') {
const floorId = options.floorId
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()
return true
} catch (error) {
@@ -5505,6 +5652,11 @@ const resetToInitialState = async () => {
}
}
const resetToInitialState = () => resetToViewBaseline({
view: 'overview',
reason: 'manual-reset'
})
const retryLoad = () => {
init3DScene()
}
@@ -5595,6 +5747,7 @@ defineExpose({
},
clearSelection: clearMapSelection,
clearRoute: clearRoutePreview,
resetToViewBaseline,
resetToInitialState,
clearNavigation: () => clearMapSelection(false),
disableAutoSwitchTemporarily

View File

@@ -460,6 +460,11 @@ const indoorRendererRef = ref<{
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
clearSelection?: (shouldEmit?: boolean) => 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
disableAutoSwitchTemporarily?: (durationMs: number) => void
} | null>(null)
@@ -781,6 +786,11 @@ defineExpose({
indoorRendererRef.value?.clearRoute?.()
},
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
})
</script>

View File

@@ -18,6 +18,12 @@ export type GuideModelInitialState = Readonly<{
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.
export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
mode: '3d',
@@ -38,8 +44,9 @@ export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
})
const initialState = ref<GuideModelInitialState>(GUIDE_MODEL_INITIAL_STATE)
const pendingModelResetAfterPoiDetail = ref(false)
const pendingModelResetAfterPoiDetail = ref<PoiDetailReturnContext | null>(null)
const requestGeneration = ref(0)
let poiDetailRequestId = 0
export const useGuideModelState = () => {
const initializeModel = (floorId: string) => {
@@ -52,18 +59,24 @@ export const useGuideModelState = () => {
return initialState.value
}
const beginPoiDetailReturn = () => {
pendingModelResetAfterPoiDetail.value = true
const beginPoiDetailReturn = (floorId: string) => {
const context = Object.freeze({
floorId,
resetMode: 'floor-baseline' as const,
requestId: ++poiDetailRequestId
})
pendingModelResetAfterPoiDetail.value = context
return context
}
const cancelPoiDetailReturn = () => {
pendingModelResetAfterPoiDetail.value = false
pendingModelResetAfterPoiDetail.value = null
}
const consumePoiDetailReturn = () => {
if (!pendingModelResetAfterPoiDetail.value) return false
pendingModelResetAfterPoiDetail.value = false
return true
const context = pendingModelResetAfterPoiDetail.value
pendingModelResetAfterPoiDetail.value = null
return context
}
// All async focus/floor operations compare this generation before committing.

View File

@@ -327,7 +327,7 @@ import type {
import {
type TargetPoiFocusRequestViewModel
} from '@/view-models/guideViewModels'
import { useGuideModelState } from '@/composables/useGuideModelState'
import { useGuideModelState, type PoiDetailReturnContext } from '@/composables/useGuideModelState'
import {
ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget,
@@ -418,6 +418,11 @@ const returningToOverview = ref(false)
const guideMapShellRef = ref<{
clearRoute?: () => 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
} | null>(null)
const homeSearchPanelRef = ref<{
@@ -430,7 +435,7 @@ const homeSearchExpanded = ref(false)
const homeCategoryModeActive = ref(false)
const searchVisiblePoiIds = ref<string[] | null>(null)
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
let pendingGuideModelResetUntilReady = false
let pendingGuideModelResetUntilReady: PoiDetailReturnContext | null = null
type PoiSearchOverlayHistoryState = {
museumGuideOverlay?: 'poi-search'
@@ -1036,8 +1041,9 @@ const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: strin
guideModelState.initializeModel(event.floorId || activeGuideFloor.value)
settleLaunchOverlayByModel('ready', event)
if (pendingGuideModelResetUntilReady) {
pendingGuideModelResetUntilReady = false
void resetGuideModelToInitial({ reason: 'queued-poi-detail-close' })
const context = pendingGuideModelResetUntilReady
pendingGuideModelResetUntilReady = null
void resetGuideModelToInitial({ reason: 'queued-poi-detail-close', floorId: context.floorId })
}
}
@@ -1456,7 +1462,13 @@ const closeHomeSearchDock = () => {
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
guideModelState.invalidatePendingRequests()
searchTargetFocusRequest.value = null
@@ -1476,25 +1488,34 @@ const resetGuideModelToInitial = async ({ reason }: { reason: 'poi-detail-close'
failedFloorId.value = ''
is3DMode.value = true
guideOutdoorState.value = 'home'
indoorView.value = initial.indoorView
activeGuideFloor.value = initial.defaultActiveFloorId || activeGuideFloor.value
renderedFloorId.value = initial.loadedFloorId || activeGuideFloor.value
indoorView.value = 'floor'
activeGuideFloor.value = floorId || initial.defaultActiveFloorId || activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
closeArrivalPanel()
await homeSearchPanelRef.value?.resetSearchState?.()
closeHomeSearchDock()
const resetApplied = await guideMapShellRef.value?.resetToInitialState?.()
const resetApplied = await guideMapShellRef.value?.resetToViewBaseline?.({
view: 'floor',
floorId: activeGuideFloor.value,
reason: 'poi-detail-close'
})
if (!resetApplied) {
// 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 })
}
onShow(async () => {
await nextTick()
if (guideModelState.consumePoiDetailReturn()) {
await resetGuideModelToInitial({ reason: 'poi-detail-close' })
const detailReturnContext = guideModelState.consumePoiDetailReturn()
if (detailReturnContext) {
await resetGuideModelToInitial({ reason: 'poi-detail-close', floorId: detailReturnContext.floorId })
return
}
@@ -1596,7 +1617,7 @@ const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => {
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
// The homepage keeps its renderer mounted; detail close is consumed by onShow once.
searchTargetFocusRequest.value = null
guideModelState.beginPoiDetailReturn()
guideModelState.beginPoiDetailReturn(poi.floorId)
uni.navigateTo({
url: createSearchDetailUrl(poi, context),
fail: () => {

View File

@@ -60,7 +60,13 @@ interface VisualStabilityApi {
getFloors: () => FloorReport[]
switchFloor: (floorId: string) => 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>
getFloorBaseline: (floorId: string) => CameraReport | null
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
focusTargetPoi: (request: {
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.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)
})
it('分类结果进入详情后统一恢复首页模型初始状态', async () => {
it('分类结果进入详情后恢复目标楼层标准视图', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
@@ -356,9 +356,14 @@ describe('首页搜索与地图闭环', () => {
expect(testState.searchResetCount).toBeGreaterThan(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('targetFocusRequest')).toBeNull()
const resetCount = testState.searchResetCount
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchResetCount).toBe(resetCount)
})
it('全屏搜索结果进入详情并返回后同样恢复为默认收缩态', async () => {
@@ -380,6 +385,7 @@ describe('首页搜索与地图闭环', () => {
expect(testState.searchRestoreCount).toBe(0)
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
expect(map.props('activeFloor')).toBe('L2')
})
it('页面重新显示时要求搜索面板恢复原列表位置', async () => {