From c08e1afe858b25e2209c8e450967716f41be071d Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Tue, 14 Jul 2026 23:17:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=A5=BC=E5=B1=82=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E5=9F=BA=E7=BA=BF=E4=B8=8E=E8=AF=A6=E6=83=85=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/ThreeMap.vue | 177 +++++++++++++++++-- src/components/navigation/GuideMapShell.vue | 10 ++ src/composables/useGuideModelState.ts | 27 ++- src/pages/index/index.vue | 47 +++-- tests/e2e/three-map-camera-stability.spec.ts | 37 ++++ tests/unit/IndexPoiSearchFlow.spec.ts | 10 +- 6 files changed, 274 insertions(+), 34 deletions(-) diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 3aed225..0736124 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -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() +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 diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index a4c3aa0..7e7fd9d 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -460,6 +460,11 @@ const indoorRendererRef = ref<{ zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void clearSelection?: (shouldEmit?: boolean) => void clearRoute?: () => Promise | void + resetToViewBaseline?: (options: { + view: 'overview' | 'floor' + floorId?: string + reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' + }) => Promise | boolean resetToInitialState?: () => Promise | 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 }) diff --git a/src/composables/useGuideModelState.ts b/src/composables/useGuideModelState.ts index 1b9d8eb..6a4b37b 100644 --- a/src/composables/useGuideModelState.ts +++ b/src/composables/useGuideModelState.ts @@ -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(GUIDE_MODEL_INITIAL_STATE) -const pendingModelResetAfterPoiDetail = ref(false) +const pendingModelResetAfterPoiDetail = ref(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. diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 84416f2..d567a16 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -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 + resetToViewBaseline?: (options: { + view: 'overview' | 'floor' + floorId?: string + reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' + }) => Promise | boolean resetToInitialState?: () => Promise | boolean } | null>(null) const homeSearchPanelRef = ref<{ @@ -430,7 +435,7 @@ const homeSearchExpanded = ref(false) const homeCategoryModeActive = ref(false) const searchVisiblePoiIds = ref(null) const searchTargetFocusRequest = ref(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: () => { diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts index c289ff6..edfb9f6 100644 --- a/tests/e2e/three-map-camera-stability.spec.ts +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -60,7 +60,13 @@ interface VisualStabilityApi { getFloors: () => FloorReport[] switchFloor: (floorId: string) => Promise showOverview: () => Promise + resetToViewBaseline: (options: { + view: 'overview' | 'floor' + floorId?: string + reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' + }) => Promise resetToInitialState: () => Promise + getFloorBaseline: (floorId: string) => CameraReport | null getFloorPois: (floorId: string) => Promise 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) + } +}) diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index 7ed34c6..e1b8a28 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -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 () => {