优化三维导览建筑内外自动切换
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-13 18:54:55 +08:00
parent d265089875
commit 9f642ca6ed
7 changed files with 964 additions and 96 deletions

View File

@@ -18,6 +18,7 @@
"build:mp-weixin": "uni build -p mp-weixin --mode production",
"verify:arrival-build": "node scripts/verify-arrival-navigation-build.cjs",
"test:unit": "vitest run",
"test:e2e:camera-stability": "playwright test tests/e2e/three-map-camera-stability.spec.ts",
"type-check": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\""
},
@@ -36,6 +37,7 @@
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260519001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260519001",
"@types/three": "0.163.0",
"@playwright/test": "^1.54.1",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@vitejs/plugin-vue": "5.2.4",

22
playwright.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:5173'
export default defineConfig({
testDir: './tests/e2e',
timeout: 180_000,
fullyParallel: false,
use: {
baseURL,
viewport: { width: 1280, height: 900 },
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
webServer: {
command: 'pnpm dev:h5 -- --host 127.0.0.1 --port 5173',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
})

38
pnpm-lock.yaml generated
View File

@@ -42,6 +42,9 @@ importers:
'@dcloudio/vite-plugin-uni':
specifier: 3.0.0-alpha-5010120260519001
version: 3.0.0-alpha-5010120260519001(postcss@8.5.15)(rollup@4.60.4)(vite@5.2.8(@types/node@25.9.1)(sass@1.99.0)(terser@5.47.1))(vue@3.4.21(typescript@5.3.3))
'@playwright/test':
specifier: ^1.54.1
version: 1.61.1
'@types/three':
specifier: 0.163.0
version: 0.163.0
@@ -1372,6 +1375,11 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@rollup/pluginutils@5.1.0':
resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
engines: {node: '>=14.0.0'}
@@ -2549,6 +2557,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3438,6 +3451,16 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
pngjs@3.4.0:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
@@ -6379,6 +6402,10 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@rollup/pluginutils@5.1.0(rollup@4.60.4)':
dependencies:
'@types/estree': 1.0.9
@@ -7675,6 +7702,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -8737,6 +8767,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
pngjs@3.4.0: {}
postcss-import@14.1.0(postcss@8.5.15):

View File

@@ -199,6 +199,7 @@ interface CameraFitOptions {
targetOffsetRatio?: THREE.Vector3
screenOffsetRatio?: THREE.Vector2
up?: THREE.Vector3
durationMs?: number
onComplete?: () => void
}
@@ -207,6 +208,12 @@ interface LoadFloorOptions {
suppressProgress?: boolean
detachPoiBeforeLoad?: boolean
allowSameFloorReload?: boolean
cameraSnapshot?: CameraSnapshot
onCameraStable?: () => void
}
interface LoadOverviewOptions {
cameraSnapshot?: CameraSnapshot
}
interface LoadModelOptions {
@@ -223,6 +230,45 @@ interface CameraTweenState {
onComplete?: () => void
}
interface CameraSnapshot {
position: THREE.Vector3
target: THREE.Vector3
up: THREE.Vector3
quaternion: THREE.Quaternion
fov: number
zoom: number
near: number
far: number
distance: number
}
// 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。
const referenceOverviewCameraState: CameraSnapshot = (() => {
const referenceCamera = new THREE.PerspectiveCamera(
SGS_VISUAL_RENDER_CONFIG.camera.fov,
1,
SGS_VISUAL_RENDER_CONFIG.camera.near,
SGS_VISUAL_RENDER_CONFIG.camera.far
)
referenceCamera.position.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.position)
referenceCamera.up.set(0, 1, 0)
referenceCamera.zoom = OVERVIEW_INITIAL_CAMERA_PARAMS.zoom
referenceCamera.lookAt(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
referenceCamera.updateProjectionMatrix()
return {
position: referenceCamera.position.clone(),
target: OVERVIEW_INITIAL_CAMERA_PARAMS.target.clone(),
up: referenceCamera.up.clone(),
quaternion: referenceCamera.quaternion.clone(),
fov: referenceCamera.fov,
zoom: referenceCamera.zoom,
near: referenceCamera.near,
far: referenceCamera.far,
distance: referenceCamera.position.distanceTo(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
}
})()
type FloorIndexItem = GuideModelFloorAsset
interface FloorOption {
@@ -327,6 +373,12 @@ interface PreparedFloorScene {
protectedResources?: ReusableModelResources
}
interface PreparedFloorModelCacheEntry {
floorId: string
modelUrl: string
model: THREE.Object3D
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
const props = withDefaults(defineProps<{
@@ -428,6 +480,7 @@ let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
const poiDisplayOffsetYCache = new Map<string, number>()
const poiDisplayDiagnosticsKeys = new Set<string>()
let activeFocusLabelSprite: THREE.Sprite | null = null
@@ -471,12 +524,15 @@ let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
let cameraTween: CameraTweenState | null = null
let adjacentPreloadSeq = 0
let defaultFloorPreloadSeq = 0
let firstModelLoadStartedAt = 0
let firstModelVisibleReported = false
let initialModelSettled = false
const cameraTweenEnabled = true
const cameraTweenDurationMs = 480
const buttonZoomTweenDurationMs = 240
const programmaticCameraTailMs = 40
const poiTapFeedbackDurationMs = 180
const poiHitTargetScaleMultiplier = 4.8
const poiHitTargetCoreScaleMultiplier = 5.4
@@ -497,8 +553,17 @@ type ThreeMapDiagnosticEvent =
| 'init-start'
| 'package-ready'
| 'model-load-start'
| 'model-request-start'
| 'model-request-complete'
| 'model-parse-complete'
| 'model-load-complete'
| 'model-load-failed'
| 'model-scene-prepare-complete'
| 'model-commit-complete'
| 'model-camera-stability'
| 'model-coordinate-audit'
| 'auto-switch-request'
| 'auto-switch-stable'
| 'first-model-visible'
| 'poi-background-start'
| 'poi-background-ready'
@@ -506,6 +571,15 @@ type ThreeMapDiagnosticEvent =
| 'poi-marker-render-diagnostics'
| 'poi-hit-diagnostics'
| 'adjacent-preload-skip'
| 'default-floor-preload-start'
| 'default-floor-preload-complete'
interface BuildingAnchor {
id: string
position: THREE.Vector3
}
let referenceBuildingAnchors: BuildingAnchor[] = []
const getNow = () => (
typeof performance !== 'undefined' && typeof performance.now === 'function'
@@ -1552,7 +1626,7 @@ const endProgrammaticCameraChangeSoon = (onComplete?: () => void) => {
isProgrammaticCameraChange = false
programmaticCameraTimer = null
onComplete?.()
}, 120)
}, programmaticCameraTailMs)
}
const easeInOutCubic = (t: number) => (
@@ -1562,10 +1636,11 @@ const easeInOutCubic = (t: number) => (
)
const cancelCameraTween = (options: { manual?: boolean } = {}) => {
if (!cameraTween) return
cameraTween = null
if (cameraTween) {
cameraTween = null
}
if (options.manual) {
// 手动手势必须立即接管相机,避免已结束动画遗留的保护状态继续拦截自动切换。
isProgrammaticCameraChange = false
clearProgrammaticCameraTimer()
const distance = controls?.getDistance()
@@ -1914,6 +1989,8 @@ const initThree = async () => {
controls.minPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.minPolarAngle
controls.maxPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.maxPolarAngle
syncControlInteractionOptions()
// 首次进入外观或任意单层前,先建立唯一的外观参考投影。
applyReferenceOverviewCameraState()
loader = new GLTFLoader()
dracoLoader = new DRACOLoader()
@@ -2471,6 +2548,13 @@ const disposeCachedOverviewModel = () => {
cachedSharedModelUrl = ''
}
const disposePreparedFloorModelCache = () => {
preparedFloorModelCache.forEach((entry) => {
disposeObject(entry.model)
})
preparedFloorModelCache.clear()
}
const clearRoutePreview = () => {
activeRoutePreviewSignature = ''
if (!routeGroup) return
@@ -2732,22 +2816,53 @@ const loadModelOnce = (
}
const startedAt = getNow()
let requestCompletedAt: number | null = null
logThreeMapDiagnostic('model-load-start', {
label,
url
})
logThreeMapDiagnostic('model-request-start', {
label,
url
})
loader.load(
url,
(gltf) => {
const completedAt = getNow()
if (requestCompletedAt === null) {
requestCompletedAt = completedAt
logThreeMapDiagnostic('model-request-complete', {
label,
url,
elapsedMs: Math.round(requestCompletedAt - startedAt),
timingIncludesParse: true
})
}
logThreeMapDiagnostic('model-parse-complete', {
label,
url,
requestElapsedMs: Math.round(requestCompletedAt - startedAt),
parseElapsedMs: Math.max(0, Math.round(completedAt - requestCompletedAt)),
totalElapsedMs: Math.round(completedAt - startedAt)
})
logThreeMapDiagnostic('model-load-complete', {
label,
url,
elapsedMs: Math.round(getNow() - startedAt)
elapsedMs: Math.round(completedAt - startedAt)
})
resolve(gltf)
},
(event) => {
if (event.total > 0 && event.loaded >= event.total && requestCompletedAt === null) {
requestCompletedAt = getNow()
logThreeMapDiagnostic('model-request-complete', {
label,
url,
elapsedMs: Math.round(requestCompletedAt - startedAt),
bytes: event.loaded
})
}
if (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return
if (event.total > 0) {
@@ -2904,42 +3019,138 @@ const getOverviewModelUrls = (packageData: GuideModelRenderPackage) => (
packageData.overviewModelUrls?.length ? packageData.overviewModelUrls : [packageData.overviewModelUrl]
)
const preloadModelUrls = async (urls: string[], label: string, preloadSeq: number) => {
const candidates = urls.map((url) => url.trim()).filter(Boolean)
if (!candidates.length || preloadSeq !== adjacentPreloadSeq || isDisposed) return
type ModelPreloadScope = 'adjacent' | 'default-entry'
const availableCandidates = candidates.filter((url) => {
const isCurrentPreload = (scope: ModelPreloadScope, preloadSeq: number) => (
scope === 'default-entry'
? preloadSeq === defaultFloorPreloadSeq
: preloadSeq === adjacentPreloadSeq
)
const preloadModelUrls = async (
urls: string[],
label: string,
preloadSeq: number,
scope: ModelPreloadScope = 'adjacent'
) => {
const candidates = urls.map((url) => url.trim()).filter(Boolean)
if (!candidates.length || !isCurrentPreload(scope, preloadSeq) || isDisposed) return
const existingCandidate = candidates.find((url) => {
const isCached = guideModelLoadManager.has(url)
const isPending = guideModelLoadManager.isInFlight(url)
if (isCached || isPending) {
logThreeMapDiagnostic('adjacent-preload-skip', {
label,
url,
reason: isCached ? 'cache-hit' : 'in-flight-hit',
diagnostics: guideModelLoadManager.getDiagnostics()
})
return false
}
return true
return isCached || isPending
})
if (!availableCandidates.length) return
if (existingCandidate) {
logThreeMapDiagnostic('adjacent-preload-skip', {
label,
url: existingCandidate,
scope,
reason: guideModelLoadManager.has(existingCandidate) ? 'cache-hit' : 'in-flight-hit',
diagnostics: guideModelLoadManager.getDiagnostics()
})
return
}
try {
await guideModelLoadManager.preload({
urls: availableCandidates,
urls: candidates,
label,
loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
})
} catch (error) {
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
urls: availableCandidates,
urls: candidates,
label,
error: error instanceof Error ? error.message : String(error)
})
}
}
const prepareDefaultFloorAssetsInBackground = async (
floor: FloorIndexItem,
preloadSeq: number
) => {
const existing = preparedFloorModelCache.get(floor.floorId)
if (existing?.modelUrl === floor.modelUrl) return
const gltf = await loadModelWithFallback(
getFloorModelUrls(floor),
`准备默认进入楼层 ${formatFloorLabel(floor.floorId)} 场景`,
undefined,
{ suppressProgress: true }
)
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) {
disposeObject(gltf.scene)
return
}
const model = gltf.scene
model.name = `GuideFloorModel_${floor.floorId}`
model.userData.floorId = floor.floorId
prepareModel(model)
applyIndoorInitialModelTransform(model)
applyModelVisibilityForView(model, 'floor', floor.floorId)
const previous = preparedFloorModelCache.get(floor.floorId)
if (previous) {
disposeObject(previous.model)
}
preparedFloorModelCache.set(floor.floorId, {
floorId: floor.floorId,
modelUrl: floor.modelUrl,
model
})
if (shouldRenderPoiMarkers.value) {
await prepareFloorPOIs(floor)
}
}
const scheduleDefaultFloorPreload = () => {
const floorId = getAutoEntryTargetFloorId()
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor) return
const preloadSeq = defaultFloorPreloadSeq + 1
defaultFloorPreloadSeq = preloadSeq
// 外观首帧已经提交后再启动默认楼层预加载,不占用首屏模型的关键路径。
window.setTimeout(() => {
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed || activeView.value !== 'overview') return
const startedAt = getNow()
logThreeMapDiagnostic('default-floor-preload-start', {
floorId,
modelUrl: floor.modelUrl
})
void (async () => {
await preloadModelUrls(
getFloorModelUrls(floor),
`预加载默认进入楼层 ${formatFloorLabel(floorId)} 模型`,
preloadSeq,
'default-entry'
)
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) return
await prepareDefaultFloorAssetsInBackground(floor, preloadSeq)
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) return
logThreeMapDiagnostic('default-floor-preload-complete', {
floorId,
modelUrl: floor.modelUrl,
scenePrepared: preparedFloorModelCache.has(floorId),
poiPrepared: poiMarkerGroupCache.has(getPoiMarkerCacheKey(floorId, 'detail')),
elapsedMs: Math.round(getNow() - startedAt),
diagnostics: guideModelLoadManager.getDiagnostics()
})
})().catch((error) => {
console.warn('[ThreeMap] 默认进入楼层后台准备失败:', {
floorId,
modelUrl: floor.modelUrl,
error: error instanceof Error ? error.message : String(error)
})
})
}, 0)
}
const getAdjacentFloors = (floorId: string) => {
const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
const currentIndex = orderedFloors.findIndex((floor) => floor.floorId === floorId)
@@ -3080,7 +3291,7 @@ const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
|| autoSwitchTemporarilyDisabled
|| isLoading.value
|| isAutoSwitchLocked
|| isProgrammaticCameraChange
|| (isProgrammaticCameraChange && activeAutoSwitchInputSource !== 'button')
|| !controls
|| !activeModel
|| activeView.value === 'multi'
@@ -3102,6 +3313,14 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
}
isAutoSwitchLocked = true
const thresholdToRequestMs = Math.max(0, request.confirmedAt - request.candidateStartedAt)
const thresholdReachedAt = getNow() - thresholdToRequestMs
logThreeMapDiagnostic('auto-switch-request', {
direction: request.direction,
source: request.source,
distance: roundModelAdjustValue(request.distance),
debounceMs: thresholdToRequestMs
})
if (request.direction === 'enter-floor') {
const targetFloorId = getAutoEntryTargetFloorId()
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
@@ -3116,7 +3335,16 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
await loadFloor(targetFloorId, {
preserveCurrentSceneUntilReady: hasSceneToKeep,
suppressProgress: hasSceneToKeep,
detachPoiBeforeLoad: true
detachPoiBeforeLoad: true,
onCameraStable: () => {
activeAutoSwitchInputSource = 'gesture'
logThreeMapDiagnostic('auto-switch-stable', {
direction: request.direction,
source: request.source,
floorId: targetFloorId,
elapsedSinceThresholdMs: Math.round(getNow() - thresholdReachedAt)
})
}
})
},
{ showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) }
@@ -3136,6 +3364,10 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
}
const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
enterHoldMs: 120,
exitHoldMs: 400,
intentTimeoutMs: 2000,
reverseToleranceRatio: 0.008,
cooldownMs: props.autoSwitchCooldown,
canSwitch: canRunAutoSwitch,
onSwitchRequested: requestAutoSwitch
@@ -3195,6 +3427,220 @@ const disableAutoSwitchTemporarily = (durationMs: number) => {
}, durationMs)
}
const cloneCameraSnapshot = (snapshot: CameraSnapshot): CameraSnapshot => ({
position: snapshot.position.clone(),
target: snapshot.target.clone(),
up: snapshot.up.clone(),
quaternion: snapshot.quaternion.clone(),
fov: snapshot.fov,
zoom: snapshot.zoom,
near: snapshot.near,
far: snapshot.far,
distance: snapshot.distance
})
const captureCameraSnapshot = (): CameraSnapshot => {
if (!camera || !controls) return cloneCameraSnapshot(referenceOverviewCameraState)
return {
position: camera.position.clone(),
target: controls.target.clone(),
up: camera.up.clone(),
quaternion: camera.quaternion.clone(),
fov: camera.fov,
zoom: camera.zoom,
near: camera.near,
far: camera.far,
distance: controls.getDistance()
}
}
const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
if (!camera || !controls) return
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()
}
const applyReferenceOverviewCameraState = () => {
restoreCameraSnapshot(referenceOverviewCameraState)
}
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 },
up: { x: snapshot.up.x, y: snapshot.up.y, z: snapshot.up.z },
quaternion: {
x: snapshot.quaternion.x,
y: snapshot.quaternion.y,
z: snapshot.quaternion.z,
w: snapshot.quaternion.w
},
fov: snapshot.fov,
zoom: snapshot.zoom,
near: snapshot.near,
far: snapshot.far,
distance: snapshot.distance
})
const getCameraSnapshotDelta = (
expected: CameraSnapshot,
actual: CameraSnapshot = captureCameraSnapshot()
) => {
const position = expected.position.distanceTo(actual.position)
const target = expected.target.distanceTo(actual.target)
const up = expected.up.distanceTo(actual.up)
const quaternion = expected.quaternion.angleTo(actual.quaternion)
const fov = Math.abs(expected.fov - actual.fov)
const zoom = Math.abs(expected.zoom - actual.zoom)
const near = Math.abs(expected.near - actual.near)
const far = Math.abs(expected.far - actual.far)
const distance = Math.abs(expected.distance - actual.distance)
return {
position,
target,
up,
quaternion,
fov,
zoom,
near,
far,
distance,
max: Math.max(position, target, up, quaternion, fov, zoom, near, far, distance)
}
}
const getModelRootTransformAudit = (model: THREE.Object3D | null) => {
if (!model) return null
model.updateMatrixWorld(true)
const positionError = model.position.length()
const rotationError = Math.max(
Math.abs(model.rotation.x),
Math.abs(model.rotation.y),
Math.abs(model.rotation.z)
)
const scaleError = Math.max(
Math.abs(model.scale.x - 1),
Math.abs(model.scale.y - 1),
Math.abs(model.scale.z - 1)
)
return {
name: model.name,
position: serializeVector3(model.position),
rotationDeg: serializeEulerDegrees(model.rotation),
scale: serializeVector3(model.scale),
positionError,
rotationError,
scaleError,
maxError: Math.max(positionError, rotationError, scaleError)
}
}
const updateReferenceBuildingAnchors = (model: THREE.Object3D) => {
const box = getOverviewBuildingSubjectBox(model)
if (box.isEmpty()) return
const createAnchor = (id: string, x: number, y: number, z: number): BuildingAnchor => ({
id,
position: new THREE.Vector3(
THREE.MathUtils.lerp(box.min.x, box.max.x, x),
THREE.MathUtils.lerp(box.min.y, box.max.y, y),
THREE.MathUtils.lerp(box.min.z, box.max.z, z)
)
})
referenceBuildingAnchors = [
createAnchor('building-anchor-a', 0.25, 0.26, 0.28),
createAnchor('building-anchor-b', 0.5, 0.56, 0.5),
createAnchor('building-anchor-c', 0.76, 0.34, 0.72)
]
}
const getProjectedBuildingAnchors = () => {
if (!camera || !renderer) return []
const rect = renderer.domElement.getBoundingClientRect()
return referenceBuildingAnchors.map((anchor) => {
const ndc = anchor.position.clone().project(camera!)
return {
id: anchor.id,
world: { x: anchor.position.x, y: anchor.position.y, z: anchor.position.z },
screen: {
x: rect.left + (ndc.x + 1) * rect.width / 2,
y: rect.top + (1 - ndc.y) * rect.height / 2,
z: ndc.z,
visible: ndc.z >= -1 && ndc.z <= 1
}
}
})
}
const getVisualStabilityReport = () => ({
camera: serializeCameraSnapshot(captureCameraSnapshot()),
modelRoot: getModelRootTransformAudit(activeModel),
anchors: getProjectedBuildingAnchors(),
activeView: activeView.value,
floorId: currentFloor.value,
isCameraTweening: Boolean(cameraTween)
})
const installVisualStabilityDiagnostics = () => {
if (!import.meta.env.DEV || typeof window === 'undefined') return
const diagnosticsWindow = window as unknown as {
__GUIDE_3D_VISUAL_STABILITY__?: Record<string, unknown>
}
diagnosticsWindow.__GUIDE_3D_VISUAL_STABILITY__ = {
getReport: getVisualStabilityReport,
getFloorIds: () => floorIndex.value.map((floor) => floor.floorId),
switchFloor: handleFloorChange,
showOverview,
resetCamera
}
}
const disposeVisualStabilityDiagnostics = () => {
if (!import.meta.env.DEV || typeof window === 'undefined') return
const diagnosticsWindow = window as unknown as {
__GUIDE_3D_VISUAL_STABILITY__?: Record<string, unknown>
}
delete diagnosticsWindow.__GUIDE_3D_VISUAL_STABILITY__
}
const recordCameraSnapshotRestoration = (
expected: CameraSnapshot,
transition: string
) => {
const cameraDelta = getCameraSnapshotDelta(expected)
const modelRoot = getModelRootTransformAudit(activeModel)
logThreeMapDiagnostic('model-camera-stability', {
transition,
cameraDelta,
modelRoot,
anchors: getProjectedBuildingAnchors()
})
logThreeMapDiagnostic('model-coordinate-audit', {
transition,
modelRoot
})
}
const setCameraView = (
center: THREE.Vector3,
maxDim: number,
@@ -3237,10 +3683,20 @@ const setCameraView = (
}
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08)
controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 8)
moveCameraTo(nextPosition, nextTarget, { onComplete: options.onComplete })
moveCameraTo(nextPosition, nextTarget, {
durationMs: options.durationMs,
onComplete: options.onComplete
})
}
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
const fitCameraToObject = (
object: THREE.Object3D,
preset: CameraPreset = 'oblique',
transitionOptions: {
durationMs?: number
onComplete?: () => void
} = {}
) => {
if (!camera || !controls) return
const useOverviewBuildingFit = activeView.value === 'overview' && preset !== 'top'
@@ -3263,18 +3719,21 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
? {
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
up: new THREE.Vector3(0, 1, 0)
up: camera.up.clone()
}
: {}
overviewFitOptions.durationMs = transitionOptions.durationMs
overviewFitOptions.onComplete = () => {
if (activeView.value !== 'floor' || !controls) return
const distance = controls.getDistance()
if (Number.isFinite(distance)) {
// 楼层切换或相机复位完成后,以最终拟合距离作为退出基准。
autoSwitchStateMachine.setFloorInitialDistance(distance)
if (activeView.value === 'floor' && controls) {
const distance = controls.getDistance()
if (Number.isFinite(distance)) {
// 楼层切换或相机复位完成后,以最终稳定距离作为退出基准。
autoSwitchStateMachine.setFloorInitialDistance(distance)
}
}
transitionOptions.onComplete?.()
}
setCameraView(center, maxDim, direction, overviewFitOptions)
}
@@ -3286,20 +3745,12 @@ const applyIndoorInitialModelTransform = (model: THREE.Object3D) => {
model.updateMatrixWorld(true)
}
const applyOverviewInitialCamera = () => {
if (!camera || !controls) return
camera.position.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.position)
camera.zoom = OVERVIEW_INITIAL_CAMERA_PARAMS.zoom
camera.updateProjectionMatrix()
controls.target.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
controls.update()
}
const resetCamera = () => {
if (activeModel) {
fitCameraToObject(activeModel)
applyReferenceOverviewCameraState()
if (activeView.value === 'floor' && controls) {
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
}
refreshPoiVisibilityByDistance()
}
const setCameraPreset = (preset: CameraPreset) => {
@@ -3324,11 +3775,17 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
if (shouldTrackUserZoom) {
activeAutoSwitchInputSource = 'button'
autoSwitchStateMachine.beginInput(currentDistance, 'button')
// 防抖与按钮动画同时开始,达到阈值时直接衔接楼层过渡,避免连续播放两段完整变焦。
autoSwitchStateMachine.updateDistance(nextDistance, {
previousDistance: currentDistance,
source: 'button'
})
}
offset.setLength(nextDistance)
const nextPosition = controls.target.clone().add(offset)
moveCameraTo(nextPosition, controls.target, {
durationMs: shouldTrackUserZoom ? buttonZoomTweenDurationMs : undefined,
onComplete: () => handleControlChange()
})
}
@@ -3378,10 +3835,13 @@ const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
})
}
const loadOverview = async () => {
const loadOverview = async (options: LoadOverviewOptions = {}) => {
const packageData = renderPackage.value
if (!packageData || !scene) return
const cameraSnapshot = cloneCameraSnapshot(options.cameraSnapshot || captureCameraSnapshot())
const previousView = activeView.value
const previousFloorId = currentFloor.value
const loadToken = startModelLoad()
activeView.value = 'overview'
autoSwitchStateMachine.setView('overview')
@@ -3398,7 +3858,9 @@ const loadOverview = async () => {
applyIndoorInitialModelTransform(activeModel)
applyModelVisibilityForView(activeModel, 'overview')
targetScene.add(activeModel)
applyOverviewInitialCamera()
restoreCameraSnapshot(cameraSnapshot)
updateReferenceBuildingAnchors(activeModel)
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
loadCurrentFloorPoiMarkersInBackground(loadToken)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
@@ -3407,6 +3869,7 @@ const loadOverview = async () => {
source: 'cached-overview',
modelUrl: cachedSharedModelUrl
})
scheduleDefaultFloorPreload()
return
}
@@ -3429,7 +3892,9 @@ const loadOverview = async () => {
cachedOverviewModel = activeModel
cachedSharedModelUrl = packageData.overviewModelUrl
targetScene.add(activeModel)
applyOverviewInitialCamera()
restoreCameraSnapshot(cameraSnapshot)
updateReferenceBuildingAnchors(activeModel)
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
loadCurrentFloorPoiMarkersInBackground(loadToken)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
@@ -3438,6 +3903,7 @@ const loadOverview = async () => {
source: 'network',
modelUrl: packageData.overviewModelUrl
})
scheduleDefaultFloorPreload()
}
const prepareFloorScene = async (
@@ -3450,12 +3916,25 @@ const prepareFloorScene = async (
let ownsModel = true
let cacheAsSharedModel = false
let protectedResources: ReusableModelResources | undefined
let scenePrepareStartedAt = getNow()
const cachedPreparedModel = preparedFloorModelCache.get(requestedFloorId)
if (cachedPreparedModel && cachedPreparedModel.modelUrl !== floor.modelUrl) {
preparedFloorModelCache.delete(requestedFloorId)
disposeObject(cachedPreparedModel.model)
}
const preparedModel = cachedPreparedModel?.modelUrl === floor.modelUrl
? cachedPreparedModel
: null
const cachedExactSharedModel = canAttachCachedSharedModel(floor.modelUrl)
? cachedOverviewModel
: null
if (cachedExactSharedModel) {
if (preparedModel) {
preparedFloorModelCache.delete(requestedFloorId)
model = preparedModel.model
} else if (cachedExactSharedModel) {
model = cachedExactSharedModel
ownsModel = false
protectedResources = collectReusableModelResources(cachedExactSharedModel)
@@ -3483,6 +3962,7 @@ const prepareFloorScene = async (
throw error
}
scenePrepareStartedAt = getNow()
model = gltf.scene
model.name = `GuideFloorModel_${requestedFloorId}`
model.userData.floorId = requestedFloorId
@@ -3509,6 +3989,16 @@ const prepareFloorScene = async (
protectedResources
}
assertPreparedFloorScene(prepared, loadToken, requestedFloorId)
logThreeMapDiagnostic('model-scene-prepare-complete', {
floorId: requestedFloorId,
modelUrl: floor.modelUrl,
source: preparedModel
? 'prepared-floor-cache'
: cachedExactSharedModel
? 'shared-scene'
: 'model-cache-or-network',
elapsedMs: Math.round(getNow() - scenePrepareStartedAt)
})
return prepared
}
@@ -3516,8 +4006,10 @@ const prepareFloorScene = async (
const commitPreparedFloorScene = (
prepared: PreparedFloorScene,
loadToken: number,
expectedFloorId: string
expectedFloorId: string,
options: LoadFloorOptions = {}
) => {
const commitStartedAt = getNow()
assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined)
assertPreparedFloorScene(prepared, loadToken, expectedFloorId)
@@ -3557,7 +4049,16 @@ const commitPreparedFloorScene = (
selectedPOI.value = null
updatePoiMarkerFocus()
hasLoadedFloorViewOnce = true
fitCameraToObject(activeModel)
const cameraSnapshot = options.cameraSnapshot || referenceOverviewCameraState
restoreCameraSnapshot(cameraSnapshot)
if (!referenceBuildingAnchors.length) {
updateReferenceBuildingAnchors(activeModel)
}
recordCameraSnapshotRestoration(cameraSnapshot, `floor:${expectedFloorId}`)
if (controls) {
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
}
options.onCameraStable?.()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('floor', {
@@ -3567,6 +4068,13 @@ const commitPreparedFloorScene = (
})
loadCurrentFloorPoiMarkersInBackground(loadToken)
scheduleAdjacentFloorPreload(expectedFloorId)
logThreeMapDiagnostic('model-commit-complete', {
floorId: expectedFloorId,
modelUrl: prepared.floor.modelUrl,
preserveCameraContinuity: true,
cameraTransitionMs: 0,
elapsedMs: Math.round(getNow() - commitStartedAt)
})
}
const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
@@ -3589,6 +4097,7 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
return false
}
const cameraSnapshot = cloneCameraSnapshot(options.cameraSnapshot || captureCameraSnapshot())
const loadToken = startFloorContextTransaction(requestedFloorId)
if (options.detachPoiBeforeLoad) {
detachActivePoiLayer()
@@ -3597,7 +4106,10 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
try {
const prepared = await prepareFloorScene(floor, loadToken, options)
assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
commitPreparedFloorScene(prepared, loadToken, requestedFloorId, {
...options,
cameraSnapshot
})
return true
} catch (error) {
if (!isStaleModelLoadError(error)) {
@@ -4861,6 +5373,7 @@ const init3DScene = async () => {
})
await initThree()
installVisualStabilityDiagnostics()
await loadModelPackage()
setProgress(100, '馆内三维模型加载完成')
@@ -4989,6 +5502,8 @@ const retryLoad = () => {
const disposeScene = () => {
isDisposed = true
adjacentPreloadSeq += 1
defaultFloorPreloadSeq += 1
referenceBuildingAnchors = []
invalidateModelLoads()
if (animationId) {
@@ -5025,6 +5540,7 @@ const disposeScene = () => {
}
autoSwitchStateMachine.dispose()
disposeVisualStabilityDiagnostics()
clearProgrammaticCameraTimer()
clearModelAdjustReportTimer()
hasPendingManualModelAdjustment = false
@@ -5035,6 +5551,7 @@ const disposeScene = () => {
clearSceneData()
disposePoiMarkerCache()
disposePreparedFloorModelCache()
disposeCachedOverviewModel()
scene?.clear()
scene = null

View File

@@ -6,6 +6,8 @@ export interface GuideAutoSwitchRequest {
direction: GuideAutoSwitchDirection
distance: number
source: GuideAutoSwitchInputSource
candidateStartedAt: number
confirmedAt: number
}
interface GuideAutoSwitchStateMachineOptions {
@@ -14,9 +16,11 @@ interface GuideAutoSwitchStateMachineOptions {
now?: () => number
enterRatio?: number
exitRatio?: number
holdMs?: number
enterHoldMs?: number
exitHoldMs?: number
cooldownMs?: number
intentTimeoutMs?: number
reverseToleranceRatio?: number
}
interface DistanceUpdateOptions {
@@ -31,6 +35,8 @@ interface BeginInputOptions {
interface PendingCandidate {
direction: GuideAutoSwitchDirection
timer: ReturnType<typeof setTimeout>
distanceAtStart: number
startedAt: number
}
const isValidDistance = (distance: number) => Number.isFinite(distance) && distance > 0
@@ -41,9 +47,11 @@ export class GuideAutoSwitchStateMachine {
private readonly now: () => number
private readonly enterRatio: number
private readonly exitRatio: number
private readonly holdMs: number
private readonly enterHoldMs: number
private readonly exitHoldMs: number
private readonly cooldownMs: number
private readonly intentTimeoutMs: number
private readonly reverseToleranceRatio: number
// 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。
private view: GuideAutoSwitchView = 'overview'
@@ -51,6 +59,7 @@ export class GuideAutoSwitchStateMachine {
private overviewLastDistance = 0
private overviewLastInputAt: number | null = null
private floorInitialDistance = 0
private floorLastDistance = 0
private currentDistance = 0
private currentSource: GuideAutoSwitchInputSource = 'gesture'
private cooldownUntil = 0
@@ -63,9 +72,11 @@ export class GuideAutoSwitchStateMachine {
this.now = options.now || Date.now
this.enterRatio = options.enterRatio ?? 0.15
this.exitRatio = options.exitRatio ?? 0.2
this.holdMs = options.holdMs ?? 500
this.enterHoldMs = options.enterHoldMs ?? 120
this.exitHoldMs = options.exitHoldMs ?? 400
this.cooldownMs = options.cooldownMs ?? 1500
this.intentTimeoutMs = options.intentTimeoutMs ?? 1200
this.intentTimeoutMs = options.intentTimeoutMs ?? 2000
this.reverseToleranceRatio = options.reverseToleranceRatio ?? 0.008
}
setView(view: GuideAutoSwitchView) {
@@ -76,6 +87,7 @@ export class GuideAutoSwitchStateMachine {
this.resetOverviewTracking()
if (view !== 'floor') {
this.floorInitialDistance = 0
this.floorLastDistance = 0
}
}
@@ -88,7 +100,9 @@ export class GuideAutoSwitchStateMachine {
const now = this.now()
const intentExpired = this.hasOverviewIntentExpired(now)
const reversed = this.overviewLastDistance > 0 && distance > this.overviewLastDistance
const reverseRatio = this.getOverviewReverseRatio(distance)
const reversed = reverseRatio > this.reverseToleranceRatio
const isReverseJitter = reverseRatio > 0 && !reversed
if (
options.resetBaseline
|| !this.overviewStartDistance
@@ -99,7 +113,9 @@ export class GuideAutoSwitchStateMachine {
this.overviewStartDistance = distance
}
this.overviewLastDistance = distance
if (!isReverseJitter) {
this.overviewLastDistance = distance
}
this.overviewLastInputAt = now
this.currentDistance = distance
this.currentSource = source
@@ -113,6 +129,7 @@ export class GuideAutoSwitchStateMachine {
const now = this.now()
const source = options.source || this.currentSource
const previousCurrentDistance = this.currentDistance
this.currentDistance = distance
this.currentSource = source
@@ -121,20 +138,33 @@ export class GuideAutoSwitchStateMachine {
return
}
const floorReverseRatio = this.getFloorReverseRatio(distance)
const isReverseJitter = floorReverseRatio > 0
&& floorReverseRatio <= this.reverseToleranceRatio
if (!isReverseJitter || distance >= this.floorLastDistance) {
this.floorLastDistance = distance
}
const shouldExit = this.floorInitialDistance > 0
&& distance >= this.floorInitialDistance * (1 + this.exitRatio)
this.updateCandidate('exit-overview', shouldExit)
this.updateCandidate(
'exit-overview',
shouldExit,
isReverseJitter && distance < previousCurrentDistance
)
}
setFloorInitialDistance(distance: number) {
this.cancelCandidate('exit-overview')
this.floorInitialDistance = isValidDistance(distance) ? distance : 0
this.floorLastDistance = this.floorInitialDistance
this.currentDistance = this.floorInitialDistance
}
clearFloorInitialDistance() {
this.cancelCandidate('exit-overview')
this.floorInitialDistance = 0
this.floorLastDistance = 0
}
markTransitionSucceeded() {
@@ -155,6 +185,7 @@ export class GuideAutoSwitchStateMachine {
this.transitionPending = false
this.cooldownUntil = 0
this.floorInitialDistance = 0
this.floorLastDistance = 0
this.currentDistance = 0
this.resetOverviewTracking()
}
@@ -168,18 +199,32 @@ export class GuideAutoSwitchStateMachine {
? previousDistance
: distance
const intentExpired = this.hasOverviewIntentExpired(now)
const reversed = this.overviewLastDistance > 0 && distance > this.overviewLastDistance
const reverseRatio = this.getOverviewReverseRatio(distance)
const reversed = reverseRatio > this.reverseToleranceRatio
const isReverseJitter = reverseRatio > 0 && !reversed
if (!this.overviewStartDistance || intentExpired || reversed) {
this.cancelCandidate('enter-floor')
this.overviewStartDistance = reversed ? distance : fallbackStartDistance
}
this.overviewLastDistance = distance
if (!isReverseJitter) {
this.overviewLastDistance = distance
}
this.overviewLastInputAt = now
const shouldEnter = distance <= this.overviewStartDistance * (1 - this.enterRatio)
this.updateCandidate('enter-floor', shouldEnter)
this.updateCandidate('enter-floor', shouldEnter, isReverseJitter)
}
private getOverviewReverseRatio(distance: number) {
if (!this.overviewLastDistance || distance <= this.overviewLastDistance) return 0
return (distance - this.overviewLastDistance) / this.overviewLastDistance
}
private getFloorReverseRatio(distance: number) {
if (!this.floorLastDistance || distance >= this.floorLastDistance) return 0
return (this.floorLastDistance - distance) / this.floorLastDistance
}
private hasOverviewIntentExpired(now: number) {
@@ -187,8 +232,13 @@ export class GuideAutoSwitchStateMachine {
&& now - this.overviewLastInputAt > this.intentTimeoutMs
}
private updateCandidate(direction: GuideAutoSwitchDirection, conditionMet: boolean) {
private updateCandidate(
direction: GuideAutoSwitchDirection,
conditionMet: boolean,
keepOnReverseJitter = false
) {
if (!conditionMet) {
if (keepOnReverseJitter && this.candidate?.direction === direction) return
this.cancelCandidate(direction)
return
}
@@ -197,19 +247,28 @@ export class GuideAutoSwitchStateMachine {
if (!this.canSwitch(direction)) return
this.cancelCandidate()
// 条件必须保持 500ms;冷却尚未结束时主动等到更晚的确认时刻。
const delay = Math.max(this.holdMs, this.cooldownUntil - this.now())
// 进入和退出分别使用短、长防抖;冷却尚未结束时主动等到更晚的确认时刻。
const holdMs = direction === 'enter-floor' ? this.enterHoldMs : this.exitHoldMs
const startedAt = this.now()
const delay = Math.max(holdMs, this.cooldownUntil - startedAt)
const timer = setTimeout(() => {
this.confirmCandidate(direction)
}, Math.max(0, delay))
this.candidate = { direction, timer }
this.candidate = {
direction,
timer,
distanceAtStart: this.currentDistance,
startedAt
}
}
private confirmCandidate(direction: GuideAutoSwitchDirection) {
if (this.candidate?.direction !== direction) return
const candidateDistance = this.candidate.distanceAtStart
const candidateStartedAt = this.candidate.startedAt
this.candidate = null
if (this.transitionPending || !this.isConditionMet(direction)) return
if (this.transitionPending || !this.isConditionMet(direction, candidateDistance)) return
if (this.now() < this.cooldownUntil) {
this.updateCandidate(direction, true)
return
@@ -220,20 +279,30 @@ export class GuideAutoSwitchStateMachine {
this.onSwitchRequested({
direction,
distance: this.currentDistance,
source: this.currentSource
source: this.currentSource,
candidateStartedAt,
confirmedAt: this.now()
})
}
private isConditionMet(direction: GuideAutoSwitchDirection) {
private isConditionMet(direction: GuideAutoSwitchDirection, candidateDistance: number) {
if (direction === 'enter-floor') {
const thresholdDistance = this.overviewStartDistance * (1 - this.enterRatio)
return this.view === 'overview'
&& this.overviewStartDistance > 0
&& this.currentDistance <= this.overviewStartDistance * (1 - this.enterRatio)
&& (
this.currentDistance <= thresholdDistance
|| this.currentDistance <= candidateDistance * (1 + this.reverseToleranceRatio)
)
}
const thresholdDistance = this.floorInitialDistance * (1 + this.exitRatio)
return this.view === 'floor'
&& this.floorInitialDistance > 0
&& this.currentDistance >= this.floorInitialDistance * (1 + this.exitRatio)
&& (
this.currentDistance >= thresholdDistance
|| this.currentDistance >= candidateDistance * (1 - this.reverseToleranceRatio)
)
}
private cancelCandidate(direction?: GuideAutoSwitchDirection) {

View File

@@ -0,0 +1,189 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test'
interface CameraReport {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
quaternion: { x: number; y: number; z: number; w: number }
fov: number
zoom: number
near: number
far: number
distance: number
}
interface AnchorReport {
id: string
world: { x: number; y: number; z: number }
screen: { x: number; y: number; z: number; visible: boolean }
}
interface VisualReport {
camera: CameraReport
modelRoot: {
positionError: number
rotationError: number
scaleError: number
maxError: number
} | null
anchors: AnchorReport[]
activeView: string
floorId: string
isCameraTweening: boolean
}
interface VisualStabilityApi {
getReport: () => VisualReport
getFloorIds: () => string[]
switchFloor: (floorId: string) => Promise<void>
showOverview: () => Promise<void>
}
const getApiState = async (page: Page) => {
try {
return await page.evaluate(() => {
const windowWithDiagnostics = window as Window & {
__GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi
}
const api = windowWithDiagnostics.__GUIDE_3D_VISUAL_STABILITY__
return api
? { floorIds: api.getFloorIds(), report: api.getReport() }
: null
})
} catch (error) {
if (error instanceof Error && error.message.includes('Execution context was destroyed')) {
return null
}
throw error
}
}
const getReport = async (page: Page) => {
const state = await getApiState(page)
expect(state).not.toBeNull()
return state!.report
}
const getMaximumCameraDelta = (before: CameraReport, after: CameraReport) => Math.max(
Math.hypot(
before.position.x - after.position.x,
before.position.y - after.position.y,
before.position.z - after.position.z
),
Math.hypot(
before.target.x - after.target.x,
before.target.y - after.target.y,
before.target.z - after.target.z
),
Math.hypot(before.up.x - after.up.x, before.up.y - after.up.y, before.up.z - after.up.z),
Math.hypot(
before.quaternion.x - after.quaternion.x,
before.quaternion.y - after.quaternion.y,
before.quaternion.z - after.quaternion.z,
before.quaternion.w - after.quaternion.w
),
Math.abs(before.fov - after.fov),
Math.abs(before.zoom - after.zoom),
Math.abs(before.near - after.near),
Math.abs(before.far - after.far),
Math.abs(before.distance - after.distance)
)
const getMaximumAnchorDeltaPx = (before: AnchorReport[], after: AnchorReport[]) => Math.max(
...before.map((anchor) => {
const next = after.find((candidate) => candidate.id === anchor.id)
expect(next, `missing ${anchor.id}`).toBeDefined()
return Math.hypot(anchor.screen.x - next!.screen.x, anchor.screen.y - next!.screen.y)
})
)
const assertStableTransition = (before: VisualReport, after: VisualReport) => {
expect(after.isCameraTweening).toBe(false)
expect(after.modelRoot, 'the active model must stay committed').not.toBeNull()
expect(after.modelRoot!.maxError).toBeLessThan(1e-12)
expect(getMaximumCameraDelta(before.camera, after.camera)).toBeLessThan(1e-6)
expect(after.anchors).toHaveLength(3)
expect(getMaximumAnchorDeltaPx(before.anchors, after.anchors)).toBeLessThanOrEqual(2)
}
const screenshot = async (page: Page, testInfo: TestInfo, name: string) => {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) })
}
const waitForTransition = async (
page: Page,
transition: Promise<void>,
testInfo: TestInfo,
name: string
) => {
let complete = false
void transition.then(() => { complete = true })
for (let frame = 0; !complete && frame < 180; frame += 1) {
await page.waitForTimeout(80)
const report = await getReport(page)
expect(report.modelRoot, `blank model frame ${frame}`).not.toBeNull()
if (frame === 0) {
await screenshot(page, testInfo, `${name}-during`)
}
}
await transition
expect(complete, `${name} exceeded the sampling window`).toBe(true)
}
const switchFloor = async (page: Page, testInfo: TestInfo, floorId: string, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
const transition = page.evaluate(async (id) => {
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.switchFloor(id)
}, floorId)
await waitForTransition(page, transition, testInfo, name)
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
assertStableTransition(before, after)
}
const showOverview = async (page: Page, testInfo: TestInfo, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
const transition = page.evaluate(async () => {
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.showOverview()
})
await waitForTransition(page, transition, testInfo, name)
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
assertStableTransition(before, after)
}
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
await page.goto('/')
await page.getByText('馆内', { exact: true }).click()
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, {
timeout: 180_000
}).toBe(3)
const state = await getApiState(page)
expect(state?.floorIds).toEqual(expect.arrayContaining(['L-2', 'L1', 'L1.5', 'L2', 'L3', 'L5']))
await switchFloor(page, testInfo, 'L1', 'exterior-l1')
await switchFloor(page, testInfo, 'L2', 'l1-l2')
await switchFloor(page, testInfo, 'L3', 'l2-l3')
await switchFloor(page, testInfo, 'L1', 'l3-l1')
await showOverview(page, testInfo, 'l1-exterior')
await switchFloor(page, testInfo, 'L-2', 'exterior-lminus2')
await switchFloor(page, testInfo, 'L5', 'lminus2-l5')
await switchFloor(page, testInfo, 'L-2', 'l5-lminus2')
await showOverview(page, testInfo, 'lminus2-exterior')
await switchFloor(page, testInfo, 'L1.5', 'exterior-l1point5')
await showOverview(page, testInfo, 'l1point5-exterior')
})

View File

@@ -24,16 +24,25 @@ afterEach(() => {
})
describe('建筑外观与馆内楼层自动切换状态机', () => {
it('连续放大 14.9% 不进入,达到 15% 并保持 500ms 后进入', () => {
it('连续放大 14.9% 不进入,达到 15% 后进入', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'gesture')
machine.updateDistance(85.1, { source: 'gesture' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
machine.updateDistance(85, { source: 'gesture' })
vi.advanceTimersByTime(499)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
})
it('进入条件必须连续保持 120ms', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'wheel')
machine.updateDistance(85, { source: 'wheel' })
vi.advanceTimersByTime(119)
expect(requests).toEqual([])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['enter-floor'])
@@ -46,12 +55,12 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.updateDistance(95, { source: 'wheel' })
machine.beginInput(95, 'wheel')
machine.updateDistance(90, { source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
})
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 并保持 500ms 后退出', () => {
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 后退出', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
@@ -61,37 +70,59 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
expect(requests).toEqual([])
machine.updateDistance(120)
vi.advanceTimersByTime(499)
vi.advanceTimersByTime(400)
expect(requests).toEqual(['exit-overview'])
})
it('退出条件必须连续保持 400ms', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(120)
vi.advanceTimersByTime(399)
expect(requests).toEqual([])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['exit-overview'])
})
it('条件满足后反向缩放会取消防抖任务并重置进入基准', () => {
it('0.8% 内的触控反向抖动不重置进入基准,也不取消候选状态', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'touch', { resetBaseline: true })
machine.updateDistance(85, { source: 'touch' })
vi.advanceTimersByTime(300)
machine.updateDistance(88, { source: 'touch' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(60)
machine.updateDistance(85.6, { source: 'touch' })
vi.advanceTimersByTime(60)
expect(requests).toEqual([])
machine.updateDistance(74.8, { source: 'touch' })
vi.advanceTimersByTime(500)
expect(requests).toEqual(['enter-floor'])
})
it('退出条件满足后回到 20% 阈值内会取消防抖任务', () => {
it('明确反向缩放超过 0.8% 后取消候选并重置进入基准', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'touch', { resetBaseline: true })
machine.updateDistance(85, { source: 'touch' })
vi.advanceTimersByTime(60)
machine.updateDistance(86, { source: 'touch' })
vi.advanceTimersByTime(120)
expect(requests).toEqual([])
machine.updateDistance(73, { source: 'touch' })
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
})
it('退出候选明确反向超过 0.8% 后会取消防抖任务', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(120)
vi.advanceTimersByTime(300)
machine.updateDistance(119.9)
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(200)
machine.updateDistance(118.9)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
})
@@ -101,13 +132,13 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.beginInput(100, 'wheel')
machine.updateDistance(90, { source: 'wheel' })
vi.advanceTimersByTime(1201)
vi.advanceTimersByTime(2001)
machine.updateDistance(80, { previousDistance: 90, source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual([])
machine.updateDistance(76.5, { source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
})
@@ -116,7 +147,7 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.beginInput(100, 'wheel')
machine.updateDistance(85, { source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
machine.markTransitionSucceeded()
@@ -136,11 +167,11 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.beginInput(100, source, { resetBaseline: source === 'touch' })
machine.updateDistance(85.1, { source })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
machine.updateDistance(85, { source })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
}
)