This commit is contained in:
@@ -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
22
playwright.config.ts
Normal 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
38
pnpm-lock.yaml
generated
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
189
tests/e2e/three-map-camera-stability.spec.ts
Normal file
189
tests/e2e/three-map-camera-stability.spec.ts
Normal 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')
|
||||
})
|
||||
@@ -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'])
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user