diff --git a/package.json b/package.json index 67cbd58..2b08831 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d091aa9 --- /dev/null +++ b/playwright.config.ts @@ -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 + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30a4357..30c6412 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 214318b..a159adb 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -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() const poiMarkerGroupCache = new Map() +const preparedFloorModelCache = new Map() const poiDisplayOffsetYCache = new Map() const poiDisplayDiagnosticsKeys = new Set() let activeFocusLabelSprite: THREE.Sprite | null = null @@ -471,12 +524,15 @@ let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | 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 + } + 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 + } + 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 diff --git a/src/components/map/guideAutoSwitchStateMachine.ts b/src/components/map/guideAutoSwitchStateMachine.ts index 28217e2..1f7e4ab 100644 --- a/src/components/map/guideAutoSwitchStateMachine.ts +++ b/src/components/map/guideAutoSwitchStateMachine.ts @@ -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 + 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) { diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts new file mode 100644 index 0000000..4556f01 --- /dev/null +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -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 + showOverview: () => Promise +} + +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, + 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') +}) diff --git a/tests/unit/GuideAutoSwitchStateMachine.spec.ts b/tests/unit/GuideAutoSwitchStateMachine.spec.ts index ef5b81b..1fbf198 100644 --- a/tests/unit/GuideAutoSwitchStateMachine.spec.ts +++ b/tests/unit/GuideAutoSwitchStateMachine.spec.ts @@ -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']) } )