修复馆内模型初始视图与点位返回复位
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-15 18:20:18 +08:00
parent 1072b25c46
commit 6566d38cb0
11 changed files with 568 additions and 44 deletions

View File

@@ -1999,8 +1999,12 @@ const handleResize = () => {
const container = getContainerElement()
if (!container || !camera || !renderer) return
const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight)
const width = container.clientWidth
const height = container.clientHeight
// Kept-alive uni-app pages briefly report a zero-sized canvas while a detail page is on top.
// Treat that as hidden state instead of a real aspect-ratio change, otherwise a return reset
// would rebuild the floor baseline against a synthetic 1:1 viewport.
if (width <= 1 || height <= 1) return
const nextAspect = width / height
if (Math.abs(camera.aspect - nextAspect) > 1e-6) clearFloorViewBaselines()
camera.aspect = nextAspect
@@ -3391,19 +3395,39 @@ const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
cameraSnapshotRestoreCount += 1
cancelCameraTween()
clearProgrammaticCameraTimer()
isProgrammaticCameraChange = false
camera.position.copy(snapshot.position)
camera.up.copy(snapshot.up)
camera.fov = snapshot.fov
camera.zoom = snapshot.zoom
camera.near = snapshot.near
camera.far = snapshot.far
camera.updateProjectionMatrix()
controls.target.copy(snapshot.target)
controls.update()
// OrbitControls 以 position/target 同步内部球坐标后,恢复快照四元数以保存精确观察方向。
camera.quaternion.copy(snapshot.quaternion)
camera.updateMatrixWorld()
isProgrammaticCameraChange = true
// OrbitControls clamps the camera during update(). Make the immutable snapshot part of the
// valid range before synchronizing controls, otherwise a large floor baseline is clipped by
// the previous view's maxDistance and each reset converges to a different camera distance.
const snapshotDistance = snapshot.position.distanceTo(snapshot.target)
if (Number.isFinite(snapshotDistance) && snapshotDistance > 0) {
controls.minDistance = Math.min(controls.minDistance, snapshotDistance)
controls.maxDistance = Math.max(controls.maxDistance, snapshotDistance)
}
// Consume any gesture delta left in OrbitControls without damping before applying the
// snapshot. Subsequent render frames must not continue a drag or wheel gesture after reset.
const dampingEnabled = controls.enableDamping
controls.enableDamping = false
try {
controls.update()
camera.position.copy(snapshot.position)
camera.up.copy(snapshot.up)
camera.fov = snapshot.fov
camera.zoom = snapshot.zoom
camera.near = snapshot.near
camera.far = snapshot.far
camera.updateProjectionMatrix()
controls.target.copy(snapshot.target)
controls.update()
// OrbitControls 以 position/target 同步内部球坐标后,恢复快照四元数以保存精确观察方向。
camera.quaternion.copy(snapshot.quaternion)
camera.updateMatrixWorld()
} finally {
controls.enableDamping = dampingEnabled
isProgrammaticCameraChange = false
}
}
const applyReferenceOverviewCameraState = () => {

View File

@@ -10,7 +10,7 @@
:asset-base-url="indoorAssetBaseUrl"
:model-source="effectiveIndoorModelSource"
:initial-floor-id="activeFloorId"
:initial-view="indoorView"
:initial-view="indoorInitialView"
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:visible-poi-ids="visiblePoiIds"

View File

@@ -24,6 +24,11 @@ export type PoiDetailReturnContext = Readonly<{
requestId: number
}>
type GuideModelReadyState = Readonly<{
view: 'overview' | 'floor' | 'multi'
floorId: string
}>
// 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',
@@ -45,12 +50,14 @@ export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
const initialState = ref<GuideModelInitialState>(GUIDE_MODEL_INITIAL_STATE)
const pendingModelResetAfterPoiDetail = ref<PoiDetailReturnContext | null>(null)
const confirmedPoiDetailRequestId = ref<number | null>(null)
const requestGeneration = ref(0)
let poiDetailRequestId = 0
export const useGuideModelState = () => {
const initializeModel = (floorId: string) => {
const initializeModel = ({ view, floorId }: GuideModelReadyState) => {
if (initialState.value.defaultActiveFloorId) return initialState.value
if (view !== 'overview' || !floorId.trim()) return initialState.value
initialState.value = Object.freeze({
...GUIDE_MODEL_INITIAL_STATE,
defaultActiveFloorId: floorId,
@@ -66,16 +73,31 @@ export const useGuideModelState = () => {
requestId: ++poiDetailRequestId
})
pendingModelResetAfterPoiDetail.value = context
confirmedPoiDetailRequestId.value = null
return context
}
const cancelPoiDetailReturn = () => {
const confirmPoiDetailReturn = (requestId: number) => {
if (pendingModelResetAfterPoiDetail.value?.requestId !== requestId) return false
confirmedPoiDetailRequestId.value = requestId
return true
}
const cancelPoiDetailReturn = (requestId?: number) => {
if (
requestId !== undefined
&& pendingModelResetAfterPoiDetail.value?.requestId !== requestId
) return false
pendingModelResetAfterPoiDetail.value = null
confirmedPoiDetailRequestId.value = null
return true
}
const consumePoiDetailReturn = () => {
const context = pendingModelResetAfterPoiDetail.value
if (!context || confirmedPoiDetailRequestId.value !== context.requestId) return null
pendingModelResetAfterPoiDetail.value = null
confirmedPoiDetailRequestId.value = null
return context
}
@@ -91,6 +113,7 @@ export const useGuideModelState = () => {
requestGeneration: readonly(requestGeneration),
initializeModel,
beginPoiDetailReturn,
confirmPoiDetailReturn,
cancelPoiDetailReturn,
consumePoiDetailReturn,
invalidatePendingRequests

View File

@@ -918,11 +918,9 @@ const loadGuideFloors = async () => {
? normalizeGuideFloorId(activeGuideFloor.value)
: ''
activeGuideFloor.value = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id
|| floors.find((floor) => floor.label === '1F')?.id
|| floors[0]?.id
|| activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
guideModelState.initializeModel(activeGuideFloor.value)
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
@@ -1038,7 +1036,12 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
}
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
guideModelState.initializeModel(event.floorId || activeGuideFloor.value)
const readyFloorId = event.floorId || activeGuideFloor.value
if (event.view === 'overview' && readyFloorId) {
activeGuideFloor.value = readyFloorId
renderedFloorId.value = readyFloorId
guideModelState.initializeModel({ view: event.view, floorId: readyFloorId })
}
settleLaunchOverlayByModel('ready', event)
if (pendingGuideModelResetUntilReady) {
const context = pendingGuideModelResetUntilReady
@@ -1127,12 +1130,49 @@ const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
}) || null
}
const navigateToHallDetail = (hallId: string) => {
const navigateToPoiDetail = ({
url,
floorId,
failureMessage
}: {
url: string
floorId: string
failureMessage: string
}) => {
const returnContext = guideModelState.beginPoiDetailReturn(floorId)
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
url,
success: () => {
guideModelState.confirmPoiDetailReturn(returnContext.requestId)
},
fail: () => {
guideModelState.cancelPoiDetailReturn(returnContext.requestId)
uni.showToast({
title: failureMessage,
icon: 'none'
})
}
})
}
const navigateFromSelectedPoiToDetail = (url: string) => {
const poi = selectedGuidePoi.value
if (!poi) return
// Detail pages do not own the mounted renderer. The homepage consumes this once on return.
navigateToPoiDetail({
url,
floorId: poi.floorId,
failureMessage: '讲解详情打开失败,请重试'
})
}
const navigateToHallDetail = (hallId: string) => {
navigateFromSelectedPoiToDetail(
`/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
)
}
const handleViewSelectedHall = async () => {
const hall = await resolveSelectedPoiHall()
if (!hall) {
@@ -1166,9 +1206,9 @@ const handleSelectedPoiRelated = async () => {
}
if (firstExhibit?.exhibitId) {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
})
navigateFromSelectedPoiToDetail(
`/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
)
return
}
@@ -1470,7 +1510,7 @@ const resetGuideModelToInitial = async ({
floorId: string
}) => {
const initial = guideModelState.initialState.value
guideModelState.invalidatePendingRequests()
const resetRequestId = guideModelState.invalidatePendingRequests()
searchTargetFocusRequest.value = null
searchVisiblePoiIds.value = null
homeCategoryModeActive.value = false
@@ -1490,7 +1530,6 @@ const resetGuideModelToInitial = async ({
guideOutdoorState.value = 'home'
indoorView.value = 'floor'
activeGuideFloor.value = floorId || initial.defaultActiveFloorId || activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
closeArrivalPanel()
await homeSearchPanelRef.value?.resetSearchState?.()
closeHomeSearchDock()
@@ -1500,12 +1539,15 @@ const resetGuideModelToInitial = async ({
floorId: activeGuideFloor.value,
reason: 'poi-detail-close'
})
if (!resetApplied) {
if (resetApplied) {
renderedFloorId.value = activeGuideFloor.value
pendingGuideModelResetUntilReady = null
} else {
// The renderer is still becoming ready; retain only the latest requested reset.
pendingGuideModelResetUntilReady = Object.freeze({
floorId: activeGuideFloor.value,
resetMode: 'floor-baseline',
requestId: guideModelState.invalidatePendingRequests()
requestId: resetRequestId
})
}
console.info('馆内三维模型已恢复统一初始状态:', { reason })
@@ -1617,16 +1659,10 @@ 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(poi.floorId)
uni.navigateTo({
navigateToPoiDetail({
url: createSearchDetailUrl(poi, context),
fail: () => {
guideModelState.cancelPoiDetailReturn()
uni.showToast({
title: '点位详情打开失败,请重试',
icon: 'none'
})
}
floorId: poi.floorId,
failureMessage: '点位详情打开失败,请重试'
})
}