This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '点位详情打开失败,请重试'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user