This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"build:mp-weixin": "uni build -p mp-weixin --mode production",
|
"build:mp-weixin": "uni build -p mp-weixin --mode production",
|
||||||
"verify:arrival-build": "node scripts/verify-arrival-navigation-build.cjs",
|
"verify:arrival-build": "node scripts/verify-arrival-navigation-build.cjs",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
|
"test:e2e:camera-stability": "playwright test tests/e2e/three-map-camera-stability.spec.ts",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"lint": "eslint \"src/**/*.{ts,vue}\""
|
"lint": "eslint \"src/**/*.{ts,vue}\""
|
||||||
},
|
},
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260519001",
|
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260519001",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260519001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260519001",
|
||||||
"@types/three": "0.163.0",
|
"@types/three": "0.163.0",
|
||||||
|
"@playwright/test": "^1.54.1",
|
||||||
"@typescript-eslint/eslint-plugin": "7.18.0",
|
"@typescript-eslint/eslint-plugin": "7.18.0",
|
||||||
"@typescript-eslint/parser": "7.18.0",
|
"@typescript-eslint/parser": "7.18.0",
|
||||||
"@vitejs/plugin-vue": "5.2.4",
|
"@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':
|
'@dcloudio/vite-plugin-uni':
|
||||||
specifier: 3.0.0-alpha-5010120260519001
|
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))
|
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':
|
'@types/three':
|
||||||
specifier: 0.163.0
|
specifier: 0.163.0
|
||||||
version: 0.163.0
|
version: 0.163.0
|
||||||
@@ -1372,6 +1375,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||||
engines: {node: '>= 10.0.0'}
|
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':
|
'@rollup/pluginutils@5.1.0':
|
||||||
resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
|
resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -2549,6 +2557,11 @@ packages:
|
|||||||
fs.realpath@1.0.0:
|
fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -3438,6 +3451,16 @@ packages:
|
|||||||
pkg-types@2.3.1:
|
pkg-types@2.3.1:
|
||||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
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:
|
pngjs@3.4.0:
|
||||||
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
@@ -6379,6 +6402,10 @@ snapshots:
|
|||||||
'@parcel/watcher-win32-x64': 2.5.6
|
'@parcel/watcher-win32-x64': 2.5.6
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@playwright/test@1.61.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.61.1
|
||||||
|
|
||||||
'@rollup/pluginutils@5.1.0(rollup@4.60.4)':
|
'@rollup/pluginutils@5.1.0(rollup@4.60.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -7675,6 +7702,9 @@ snapshots:
|
|||||||
|
|
||||||
fs.realpath@1.0.0: {}
|
fs.realpath@1.0.0: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -8737,6 +8767,14 @@ snapshots:
|
|||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
pathe: 2.0.3
|
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: {}
|
pngjs@3.4.0: {}
|
||||||
|
|
||||||
postcss-import@14.1.0(postcss@8.5.15):
|
postcss-import@14.1.0(postcss@8.5.15):
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ interface CameraFitOptions {
|
|||||||
targetOffsetRatio?: THREE.Vector3
|
targetOffsetRatio?: THREE.Vector3
|
||||||
screenOffsetRatio?: THREE.Vector2
|
screenOffsetRatio?: THREE.Vector2
|
||||||
up?: THREE.Vector3
|
up?: THREE.Vector3
|
||||||
|
durationMs?: number
|
||||||
onComplete?: () => void
|
onComplete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +208,12 @@ interface LoadFloorOptions {
|
|||||||
suppressProgress?: boolean
|
suppressProgress?: boolean
|
||||||
detachPoiBeforeLoad?: boolean
|
detachPoiBeforeLoad?: boolean
|
||||||
allowSameFloorReload?: boolean
|
allowSameFloorReload?: boolean
|
||||||
|
cameraSnapshot?: CameraSnapshot
|
||||||
|
onCameraStable?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadOverviewOptions {
|
||||||
|
cameraSnapshot?: CameraSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadModelOptions {
|
interface LoadModelOptions {
|
||||||
@@ -223,6 +230,45 @@ interface CameraTweenState {
|
|||||||
onComplete?: () => void
|
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
|
type FloorIndexItem = GuideModelFloorAsset
|
||||||
|
|
||||||
interface FloorOption {
|
interface FloorOption {
|
||||||
@@ -327,6 +373,12 @@ interface PreparedFloorScene {
|
|||||||
protectedResources?: ReusableModelResources
|
protectedResources?: ReusableModelResources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PreparedFloorModelCacheEntry {
|
||||||
|
floorId: string
|
||||||
|
modelUrl: string
|
||||||
|
model: THREE.Object3D
|
||||||
|
}
|
||||||
|
|
||||||
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -428,6 +480,7 @@ let routeGroup: THREE.Group | null = null
|
|||||||
let activeRoutePreviewSignature = ''
|
let activeRoutePreviewSignature = ''
|
||||||
const poiDataCache = new Map<string, RenderPoi[]>()
|
const poiDataCache = new Map<string, RenderPoi[]>()
|
||||||
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
|
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
|
||||||
|
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
|
||||||
const poiDisplayOffsetYCache = new Map<string, number>()
|
const poiDisplayOffsetYCache = new Map<string, number>()
|
||||||
const poiDisplayDiagnosticsKeys = new Set<string>()
|
const poiDisplayDiagnosticsKeys = new Set<string>()
|
||||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||||
@@ -471,12 +524,15 @@ let isProgrammaticCameraChange = false
|
|||||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let cameraTween: CameraTweenState | null = null
|
let cameraTween: CameraTweenState | null = null
|
||||||
let adjacentPreloadSeq = 0
|
let adjacentPreloadSeq = 0
|
||||||
|
let defaultFloorPreloadSeq = 0
|
||||||
let firstModelLoadStartedAt = 0
|
let firstModelLoadStartedAt = 0
|
||||||
let firstModelVisibleReported = false
|
let firstModelVisibleReported = false
|
||||||
let initialModelSettled = false
|
let initialModelSettled = false
|
||||||
|
|
||||||
const cameraTweenEnabled = true
|
const cameraTweenEnabled = true
|
||||||
const cameraTweenDurationMs = 480
|
const cameraTweenDurationMs = 480
|
||||||
|
const buttonZoomTweenDurationMs = 240
|
||||||
|
const programmaticCameraTailMs = 40
|
||||||
const poiTapFeedbackDurationMs = 180
|
const poiTapFeedbackDurationMs = 180
|
||||||
const poiHitTargetScaleMultiplier = 4.8
|
const poiHitTargetScaleMultiplier = 4.8
|
||||||
const poiHitTargetCoreScaleMultiplier = 5.4
|
const poiHitTargetCoreScaleMultiplier = 5.4
|
||||||
@@ -497,8 +553,17 @@ type ThreeMapDiagnosticEvent =
|
|||||||
| 'init-start'
|
| 'init-start'
|
||||||
| 'package-ready'
|
| 'package-ready'
|
||||||
| 'model-load-start'
|
| 'model-load-start'
|
||||||
|
| 'model-request-start'
|
||||||
|
| 'model-request-complete'
|
||||||
|
| 'model-parse-complete'
|
||||||
| 'model-load-complete'
|
| 'model-load-complete'
|
||||||
| 'model-load-failed'
|
| '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'
|
| 'first-model-visible'
|
||||||
| 'poi-background-start'
|
| 'poi-background-start'
|
||||||
| 'poi-background-ready'
|
| 'poi-background-ready'
|
||||||
@@ -506,6 +571,15 @@ type ThreeMapDiagnosticEvent =
|
|||||||
| 'poi-marker-render-diagnostics'
|
| 'poi-marker-render-diagnostics'
|
||||||
| 'poi-hit-diagnostics'
|
| 'poi-hit-diagnostics'
|
||||||
| 'adjacent-preload-skip'
|
| 'adjacent-preload-skip'
|
||||||
|
| 'default-floor-preload-start'
|
||||||
|
| 'default-floor-preload-complete'
|
||||||
|
|
||||||
|
interface BuildingAnchor {
|
||||||
|
id: string
|
||||||
|
position: THREE.Vector3
|
||||||
|
}
|
||||||
|
|
||||||
|
let referenceBuildingAnchors: BuildingAnchor[] = []
|
||||||
|
|
||||||
const getNow = () => (
|
const getNow = () => (
|
||||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
@@ -1552,7 +1626,7 @@ const endProgrammaticCameraChangeSoon = (onComplete?: () => void) => {
|
|||||||
isProgrammaticCameraChange = false
|
isProgrammaticCameraChange = false
|
||||||
programmaticCameraTimer = null
|
programmaticCameraTimer = null
|
||||||
onComplete?.()
|
onComplete?.()
|
||||||
}, 120)
|
}, programmaticCameraTailMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
const easeInOutCubic = (t: number) => (
|
const easeInOutCubic = (t: number) => (
|
||||||
@@ -1562,10 +1636,11 @@ const easeInOutCubic = (t: number) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const cancelCameraTween = (options: { manual?: boolean } = {}) => {
|
const cancelCameraTween = (options: { manual?: boolean } = {}) => {
|
||||||
if (!cameraTween) return
|
if (cameraTween) {
|
||||||
|
cameraTween = null
|
||||||
cameraTween = null
|
}
|
||||||
if (options.manual) {
|
if (options.manual) {
|
||||||
|
// 手动手势必须立即接管相机,避免已结束动画遗留的保护状态继续拦截自动切换。
|
||||||
isProgrammaticCameraChange = false
|
isProgrammaticCameraChange = false
|
||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
const distance = controls?.getDistance()
|
const distance = controls?.getDistance()
|
||||||
@@ -1914,6 +1989,8 @@ const initThree = async () => {
|
|||||||
controls.minPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.minPolarAngle
|
controls.minPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.minPolarAngle
|
||||||
controls.maxPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.maxPolarAngle
|
controls.maxPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.maxPolarAngle
|
||||||
syncControlInteractionOptions()
|
syncControlInteractionOptions()
|
||||||
|
// 首次进入外观或任意单层前,先建立唯一的外观参考投影。
|
||||||
|
applyReferenceOverviewCameraState()
|
||||||
|
|
||||||
loader = new GLTFLoader()
|
loader = new GLTFLoader()
|
||||||
dracoLoader = new DRACOLoader()
|
dracoLoader = new DRACOLoader()
|
||||||
@@ -2471,6 +2548,13 @@ const disposeCachedOverviewModel = () => {
|
|||||||
cachedSharedModelUrl = ''
|
cachedSharedModelUrl = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disposePreparedFloorModelCache = () => {
|
||||||
|
preparedFloorModelCache.forEach((entry) => {
|
||||||
|
disposeObject(entry.model)
|
||||||
|
})
|
||||||
|
preparedFloorModelCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
const clearRoutePreview = () => {
|
const clearRoutePreview = () => {
|
||||||
activeRoutePreviewSignature = ''
|
activeRoutePreviewSignature = ''
|
||||||
if (!routeGroup) return
|
if (!routeGroup) return
|
||||||
@@ -2732,22 +2816,53 @@ const loadModelOnce = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = getNow()
|
const startedAt = getNow()
|
||||||
|
let requestCompletedAt: number | null = null
|
||||||
logThreeMapDiagnostic('model-load-start', {
|
logThreeMapDiagnostic('model-load-start', {
|
||||||
label,
|
label,
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
|
logThreeMapDiagnostic('model-request-start', {
|
||||||
|
label,
|
||||||
|
url
|
||||||
|
})
|
||||||
|
|
||||||
loader.load(
|
loader.load(
|
||||||
url,
|
url,
|
||||||
(gltf) => {
|
(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', {
|
logThreeMapDiagnostic('model-load-complete', {
|
||||||
label,
|
label,
|
||||||
url,
|
url,
|
||||||
elapsedMs: Math.round(getNow() - startedAt)
|
elapsedMs: Math.round(completedAt - startedAt)
|
||||||
})
|
})
|
||||||
resolve(gltf)
|
resolve(gltf)
|
||||||
},
|
},
|
||||||
(event) => {
|
(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 (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return
|
||||||
|
|
||||||
if (event.total > 0) {
|
if (event.total > 0) {
|
||||||
@@ -2904,42 +3019,138 @@ const getOverviewModelUrls = (packageData: GuideModelRenderPackage) => (
|
|||||||
packageData.overviewModelUrls?.length ? packageData.overviewModelUrls : [packageData.overviewModelUrl]
|
packageData.overviewModelUrls?.length ? packageData.overviewModelUrls : [packageData.overviewModelUrl]
|
||||||
)
|
)
|
||||||
|
|
||||||
const preloadModelUrls = async (urls: string[], label: string, preloadSeq: number) => {
|
type ModelPreloadScope = 'adjacent' | 'default-entry'
|
||||||
const candidates = urls.map((url) => url.trim()).filter(Boolean)
|
|
||||||
if (!candidates.length || preloadSeq !== adjacentPreloadSeq || isDisposed) return
|
|
||||||
|
|
||||||
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 isCached = guideModelLoadManager.has(url)
|
||||||
const isPending = guideModelLoadManager.isInFlight(url)
|
const isPending = guideModelLoadManager.isInFlight(url)
|
||||||
if (isCached || isPending) {
|
return isCached || isPending
|
||||||
logThreeMapDiagnostic('adjacent-preload-skip', {
|
|
||||||
label,
|
|
||||||
url,
|
|
||||||
reason: isCached ? 'cache-hit' : 'in-flight-hit',
|
|
||||||
diagnostics: guideModelLoadManager.getDiagnostics()
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
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 {
|
try {
|
||||||
await guideModelLoadManager.preload({
|
await guideModelLoadManager.preload({
|
||||||
urls: availableCandidates,
|
urls: candidates,
|
||||||
label,
|
label,
|
||||||
loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
|
loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
|
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
|
||||||
urls: availableCandidates,
|
urls: candidates,
|
||||||
label,
|
label,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
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 getAdjacentFloors = (floorId: string) => {
|
||||||
const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
|
const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
|
||||||
const currentIndex = orderedFloors.findIndex((floor) => floor.floorId === floorId)
|
const currentIndex = orderedFloors.findIndex((floor) => floor.floorId === floorId)
|
||||||
@@ -3080,7 +3291,7 @@ const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
|
|||||||
|| autoSwitchTemporarilyDisabled
|
|| autoSwitchTemporarilyDisabled
|
||||||
|| isLoading.value
|
|| isLoading.value
|
||||||
|| isAutoSwitchLocked
|
|| isAutoSwitchLocked
|
||||||
|| isProgrammaticCameraChange
|
|| (isProgrammaticCameraChange && activeAutoSwitchInputSource !== 'button')
|
||||||
|| !controls
|
|| !controls
|
||||||
|| !activeModel
|
|| !activeModel
|
||||||
|| activeView.value === 'multi'
|
|| activeView.value === 'multi'
|
||||||
@@ -3102,6 +3313,14 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isAutoSwitchLocked = true
|
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') {
|
if (request.direction === 'enter-floor') {
|
||||||
const targetFloorId = getAutoEntryTargetFloorId()
|
const targetFloorId = getAutoEntryTargetFloorId()
|
||||||
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
|
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
|
||||||
@@ -3116,7 +3335,16 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
|
|||||||
await loadFloor(targetFloorId, {
|
await loadFloor(targetFloorId, {
|
||||||
preserveCurrentSceneUntilReady: hasSceneToKeep,
|
preserveCurrentSceneUntilReady: hasSceneToKeep,
|
||||||
suppressProgress: 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) }
|
{ showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) }
|
||||||
@@ -3136,6 +3364,10 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
|
const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
|
||||||
|
enterHoldMs: 120,
|
||||||
|
exitHoldMs: 400,
|
||||||
|
intentTimeoutMs: 2000,
|
||||||
|
reverseToleranceRatio: 0.008,
|
||||||
cooldownMs: props.autoSwitchCooldown,
|
cooldownMs: props.autoSwitchCooldown,
|
||||||
canSwitch: canRunAutoSwitch,
|
canSwitch: canRunAutoSwitch,
|
||||||
onSwitchRequested: requestAutoSwitch
|
onSwitchRequested: requestAutoSwitch
|
||||||
@@ -3195,6 +3427,220 @@ const disableAutoSwitchTemporarily = (durationMs: number) => {
|
|||||||
}, durationMs)
|
}, 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 = (
|
const setCameraView = (
|
||||||
center: THREE.Vector3,
|
center: THREE.Vector3,
|
||||||
maxDim: number,
|
maxDim: number,
|
||||||
@@ -3237,10 +3683,20 @@ const setCameraView = (
|
|||||||
}
|
}
|
||||||
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08)
|
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)
|
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
|
if (!camera || !controls) return
|
||||||
|
|
||||||
const useOverviewBuildingFit = activeView.value === 'overview' && preset !== 'top'
|
const useOverviewBuildingFit = activeView.value === 'overview' && preset !== 'top'
|
||||||
@@ -3263,18 +3719,21 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
|
|||||||
? {
|
? {
|
||||||
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
|
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
|
||||||
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
|
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
|
||||||
up: new THREE.Vector3(0, 1, 0)
|
up: camera.up.clone()
|
||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
overviewFitOptions.durationMs = transitionOptions.durationMs
|
||||||
overviewFitOptions.onComplete = () => {
|
overviewFitOptions.onComplete = () => {
|
||||||
if (activeView.value !== 'floor' || !controls) return
|
if (activeView.value === 'floor' && controls) {
|
||||||
|
const distance = controls.getDistance()
|
||||||
const distance = controls.getDistance()
|
if (Number.isFinite(distance)) {
|
||||||
if (Number.isFinite(distance)) {
|
// 楼层切换或相机复位完成后,以最终稳定距离作为退出基准。
|
||||||
// 楼层切换或相机复位完成后,以最终拟合距离作为退出基准。
|
autoSwitchStateMachine.setFloorInitialDistance(distance)
|
||||||
autoSwitchStateMachine.setFloorInitialDistance(distance)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transitionOptions.onComplete?.()
|
||||||
}
|
}
|
||||||
setCameraView(center, maxDim, direction, overviewFitOptions)
|
setCameraView(center, maxDim, direction, overviewFitOptions)
|
||||||
}
|
}
|
||||||
@@ -3286,20 +3745,12 @@ const applyIndoorInitialModelTransform = (model: THREE.Object3D) => {
|
|||||||
model.updateMatrixWorld(true)
|
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 = () => {
|
const resetCamera = () => {
|
||||||
if (activeModel) {
|
applyReferenceOverviewCameraState()
|
||||||
fitCameraToObject(activeModel)
|
if (activeView.value === 'floor' && controls) {
|
||||||
|
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
|
||||||
}
|
}
|
||||||
|
refreshPoiVisibilityByDistance()
|
||||||
}
|
}
|
||||||
|
|
||||||
const setCameraPreset = (preset: CameraPreset) => {
|
const setCameraPreset = (preset: CameraPreset) => {
|
||||||
@@ -3324,11 +3775,17 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
|
|||||||
if (shouldTrackUserZoom) {
|
if (shouldTrackUserZoom) {
|
||||||
activeAutoSwitchInputSource = 'button'
|
activeAutoSwitchInputSource = 'button'
|
||||||
autoSwitchStateMachine.beginInput(currentDistance, 'button')
|
autoSwitchStateMachine.beginInput(currentDistance, 'button')
|
||||||
|
// 防抖与按钮动画同时开始,达到阈值时直接衔接楼层过渡,避免连续播放两段完整变焦。
|
||||||
|
autoSwitchStateMachine.updateDistance(nextDistance, {
|
||||||
|
previousDistance: currentDistance,
|
||||||
|
source: 'button'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
offset.setLength(nextDistance)
|
offset.setLength(nextDistance)
|
||||||
const nextPosition = controls.target.clone().add(offset)
|
const nextPosition = controls.target.clone().add(offset)
|
||||||
moveCameraTo(nextPosition, controls.target, {
|
moveCameraTo(nextPosition, controls.target, {
|
||||||
|
durationMs: shouldTrackUserZoom ? buttonZoomTweenDurationMs : undefined,
|
||||||
onComplete: () => handleControlChange()
|
onComplete: () => handleControlChange()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3378,10 +3835,13 @@ const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadOverview = async () => {
|
const loadOverview = async (options: LoadOverviewOptions = {}) => {
|
||||||
const packageData = renderPackage.value
|
const packageData = renderPackage.value
|
||||||
if (!packageData || !scene) return
|
if (!packageData || !scene) return
|
||||||
|
|
||||||
|
const cameraSnapshot = cloneCameraSnapshot(options.cameraSnapshot || captureCameraSnapshot())
|
||||||
|
const previousView = activeView.value
|
||||||
|
const previousFloorId = currentFloor.value
|
||||||
const loadToken = startModelLoad()
|
const loadToken = startModelLoad()
|
||||||
activeView.value = 'overview'
|
activeView.value = 'overview'
|
||||||
autoSwitchStateMachine.setView('overview')
|
autoSwitchStateMachine.setView('overview')
|
||||||
@@ -3398,7 +3858,9 @@ const loadOverview = async () => {
|
|||||||
applyIndoorInitialModelTransform(activeModel)
|
applyIndoorInitialModelTransform(activeModel)
|
||||||
applyModelVisibilityForView(activeModel, 'overview')
|
applyModelVisibilityForView(activeModel, 'overview')
|
||||||
targetScene.add(activeModel)
|
targetScene.add(activeModel)
|
||||||
applyOverviewInitialCamera()
|
restoreCameraSnapshot(cameraSnapshot)
|
||||||
|
updateReferenceBuildingAnchors(activeModel)
|
||||||
|
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
|
||||||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||||||
resetAutoSwitchDistanceTracking()
|
resetAutoSwitchDistanceTracking()
|
||||||
refreshPoiVisibilityByDistance()
|
refreshPoiVisibilityByDistance()
|
||||||
@@ -3407,6 +3869,7 @@ const loadOverview = async () => {
|
|||||||
source: 'cached-overview',
|
source: 'cached-overview',
|
||||||
modelUrl: cachedSharedModelUrl
|
modelUrl: cachedSharedModelUrl
|
||||||
})
|
})
|
||||||
|
scheduleDefaultFloorPreload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3429,7 +3892,9 @@ const loadOverview = async () => {
|
|||||||
cachedOverviewModel = activeModel
|
cachedOverviewModel = activeModel
|
||||||
cachedSharedModelUrl = packageData.overviewModelUrl
|
cachedSharedModelUrl = packageData.overviewModelUrl
|
||||||
targetScene.add(activeModel)
|
targetScene.add(activeModel)
|
||||||
applyOverviewInitialCamera()
|
restoreCameraSnapshot(cameraSnapshot)
|
||||||
|
updateReferenceBuildingAnchors(activeModel)
|
||||||
|
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
|
||||||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||||||
resetAutoSwitchDistanceTracking()
|
resetAutoSwitchDistanceTracking()
|
||||||
refreshPoiVisibilityByDistance()
|
refreshPoiVisibilityByDistance()
|
||||||
@@ -3438,6 +3903,7 @@ const loadOverview = async () => {
|
|||||||
source: 'network',
|
source: 'network',
|
||||||
modelUrl: packageData.overviewModelUrl
|
modelUrl: packageData.overviewModelUrl
|
||||||
})
|
})
|
||||||
|
scheduleDefaultFloorPreload()
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepareFloorScene = async (
|
const prepareFloorScene = async (
|
||||||
@@ -3450,12 +3916,25 @@ const prepareFloorScene = async (
|
|||||||
let ownsModel = true
|
let ownsModel = true
|
||||||
let cacheAsSharedModel = false
|
let cacheAsSharedModel = false
|
||||||
let protectedResources: ReusableModelResources | undefined
|
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)
|
const cachedExactSharedModel = canAttachCachedSharedModel(floor.modelUrl)
|
||||||
? cachedOverviewModel
|
? cachedOverviewModel
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (cachedExactSharedModel) {
|
if (preparedModel) {
|
||||||
|
preparedFloorModelCache.delete(requestedFloorId)
|
||||||
|
model = preparedModel.model
|
||||||
|
} else if (cachedExactSharedModel) {
|
||||||
model = cachedExactSharedModel
|
model = cachedExactSharedModel
|
||||||
ownsModel = false
|
ownsModel = false
|
||||||
protectedResources = collectReusableModelResources(cachedExactSharedModel)
|
protectedResources = collectReusableModelResources(cachedExactSharedModel)
|
||||||
@@ -3483,6 +3962,7 @@ const prepareFloorScene = async (
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scenePrepareStartedAt = getNow()
|
||||||
model = gltf.scene
|
model = gltf.scene
|
||||||
model.name = `GuideFloorModel_${requestedFloorId}`
|
model.name = `GuideFloorModel_${requestedFloorId}`
|
||||||
model.userData.floorId = requestedFloorId
|
model.userData.floorId = requestedFloorId
|
||||||
@@ -3509,6 +3989,16 @@ const prepareFloorScene = async (
|
|||||||
protectedResources
|
protectedResources
|
||||||
}
|
}
|
||||||
assertPreparedFloorScene(prepared, loadToken, requestedFloorId)
|
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
|
return prepared
|
||||||
}
|
}
|
||||||
@@ -3516,8 +4006,10 @@ const prepareFloorScene = async (
|
|||||||
const commitPreparedFloorScene = (
|
const commitPreparedFloorScene = (
|
||||||
prepared: PreparedFloorScene,
|
prepared: PreparedFloorScene,
|
||||||
loadToken: number,
|
loadToken: number,
|
||||||
expectedFloorId: string
|
expectedFloorId: string,
|
||||||
|
options: LoadFloorOptions = {}
|
||||||
) => {
|
) => {
|
||||||
|
const commitStartedAt = getNow()
|
||||||
assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined)
|
assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined)
|
||||||
assertPreparedFloorScene(prepared, loadToken, expectedFloorId)
|
assertPreparedFloorScene(prepared, loadToken, expectedFloorId)
|
||||||
|
|
||||||
@@ -3557,7 +4049,16 @@ const commitPreparedFloorScene = (
|
|||||||
selectedPOI.value = null
|
selectedPOI.value = null
|
||||||
updatePoiMarkerFocus()
|
updatePoiMarkerFocus()
|
||||||
hasLoadedFloorViewOnce = true
|
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()
|
refreshPoiVisibilityByDistance()
|
||||||
renderRoutePreview()
|
renderRoutePreview()
|
||||||
markFirstModelVisible('floor', {
|
markFirstModelVisible('floor', {
|
||||||
@@ -3567,6 +4068,13 @@ const commitPreparedFloorScene = (
|
|||||||
})
|
})
|
||||||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||||||
scheduleAdjacentFloorPreload(expectedFloorId)
|
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 = {}) => {
|
const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
|
||||||
@@ -3589,6 +4097,7 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cameraSnapshot = cloneCameraSnapshot(options.cameraSnapshot || captureCameraSnapshot())
|
||||||
const loadToken = startFloorContextTransaction(requestedFloorId)
|
const loadToken = startFloorContextTransaction(requestedFloorId)
|
||||||
if (options.detachPoiBeforeLoad) {
|
if (options.detachPoiBeforeLoad) {
|
||||||
detachActivePoiLayer()
|
detachActivePoiLayer()
|
||||||
@@ -3597,7 +4106,10 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
|
|||||||
try {
|
try {
|
||||||
const prepared = await prepareFloorScene(floor, loadToken, options)
|
const prepared = await prepareFloorScene(floor, loadToken, options)
|
||||||
assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
|
assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
|
||||||
commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
|
commitPreparedFloorScene(prepared, loadToken, requestedFloorId, {
|
||||||
|
...options,
|
||||||
|
cameraSnapshot
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isStaleModelLoadError(error)) {
|
if (!isStaleModelLoadError(error)) {
|
||||||
@@ -4861,6 +5373,7 @@ const init3DScene = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await initThree()
|
await initThree()
|
||||||
|
installVisualStabilityDiagnostics()
|
||||||
await loadModelPackage()
|
await loadModelPackage()
|
||||||
|
|
||||||
setProgress(100, '馆内三维模型加载完成')
|
setProgress(100, '馆内三维模型加载完成')
|
||||||
@@ -4989,6 +5502,8 @@ const retryLoad = () => {
|
|||||||
const disposeScene = () => {
|
const disposeScene = () => {
|
||||||
isDisposed = true
|
isDisposed = true
|
||||||
adjacentPreloadSeq += 1
|
adjacentPreloadSeq += 1
|
||||||
|
defaultFloorPreloadSeq += 1
|
||||||
|
referenceBuildingAnchors = []
|
||||||
invalidateModelLoads()
|
invalidateModelLoads()
|
||||||
|
|
||||||
if (animationId) {
|
if (animationId) {
|
||||||
@@ -5025,6 +5540,7 @@ const disposeScene = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
autoSwitchStateMachine.dispose()
|
autoSwitchStateMachine.dispose()
|
||||||
|
disposeVisualStabilityDiagnostics()
|
||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
clearModelAdjustReportTimer()
|
clearModelAdjustReportTimer()
|
||||||
hasPendingManualModelAdjustment = false
|
hasPendingManualModelAdjustment = false
|
||||||
@@ -5035,6 +5551,7 @@ const disposeScene = () => {
|
|||||||
|
|
||||||
clearSceneData()
|
clearSceneData()
|
||||||
disposePoiMarkerCache()
|
disposePoiMarkerCache()
|
||||||
|
disposePreparedFloorModelCache()
|
||||||
disposeCachedOverviewModel()
|
disposeCachedOverviewModel()
|
||||||
scene?.clear()
|
scene?.clear()
|
||||||
scene = null
|
scene = null
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface GuideAutoSwitchRequest {
|
|||||||
direction: GuideAutoSwitchDirection
|
direction: GuideAutoSwitchDirection
|
||||||
distance: number
|
distance: number
|
||||||
source: GuideAutoSwitchInputSource
|
source: GuideAutoSwitchInputSource
|
||||||
|
candidateStartedAt: number
|
||||||
|
confirmedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GuideAutoSwitchStateMachineOptions {
|
interface GuideAutoSwitchStateMachineOptions {
|
||||||
@@ -14,9 +16,11 @@ interface GuideAutoSwitchStateMachineOptions {
|
|||||||
now?: () => number
|
now?: () => number
|
||||||
enterRatio?: number
|
enterRatio?: number
|
||||||
exitRatio?: number
|
exitRatio?: number
|
||||||
holdMs?: number
|
enterHoldMs?: number
|
||||||
|
exitHoldMs?: number
|
||||||
cooldownMs?: number
|
cooldownMs?: number
|
||||||
intentTimeoutMs?: number
|
intentTimeoutMs?: number
|
||||||
|
reverseToleranceRatio?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DistanceUpdateOptions {
|
interface DistanceUpdateOptions {
|
||||||
@@ -31,6 +35,8 @@ interface BeginInputOptions {
|
|||||||
interface PendingCandidate {
|
interface PendingCandidate {
|
||||||
direction: GuideAutoSwitchDirection
|
direction: GuideAutoSwitchDirection
|
||||||
timer: ReturnType<typeof setTimeout>
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
distanceAtStart: number
|
||||||
|
startedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidDistance = (distance: number) => Number.isFinite(distance) && distance > 0
|
const isValidDistance = (distance: number) => Number.isFinite(distance) && distance > 0
|
||||||
@@ -41,9 +47,11 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
private readonly now: () => number
|
private readonly now: () => number
|
||||||
private readonly enterRatio: number
|
private readonly enterRatio: number
|
||||||
private readonly exitRatio: number
|
private readonly exitRatio: number
|
||||||
private readonly holdMs: number
|
private readonly enterHoldMs: number
|
||||||
|
private readonly exitHoldMs: number
|
||||||
private readonly cooldownMs: number
|
private readonly cooldownMs: number
|
||||||
private readonly intentTimeoutMs: number
|
private readonly intentTimeoutMs: number
|
||||||
|
private readonly reverseToleranceRatio: number
|
||||||
|
|
||||||
// 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。
|
// 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。
|
||||||
private view: GuideAutoSwitchView = 'overview'
|
private view: GuideAutoSwitchView = 'overview'
|
||||||
@@ -51,6 +59,7 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
private overviewLastDistance = 0
|
private overviewLastDistance = 0
|
||||||
private overviewLastInputAt: number | null = null
|
private overviewLastInputAt: number | null = null
|
||||||
private floorInitialDistance = 0
|
private floorInitialDistance = 0
|
||||||
|
private floorLastDistance = 0
|
||||||
private currentDistance = 0
|
private currentDistance = 0
|
||||||
private currentSource: GuideAutoSwitchInputSource = 'gesture'
|
private currentSource: GuideAutoSwitchInputSource = 'gesture'
|
||||||
private cooldownUntil = 0
|
private cooldownUntil = 0
|
||||||
@@ -63,9 +72,11 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
this.now = options.now || Date.now
|
this.now = options.now || Date.now
|
||||||
this.enterRatio = options.enterRatio ?? 0.15
|
this.enterRatio = options.enterRatio ?? 0.15
|
||||||
this.exitRatio = options.exitRatio ?? 0.2
|
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.cooldownMs = options.cooldownMs ?? 1500
|
||||||
this.intentTimeoutMs = options.intentTimeoutMs ?? 1200
|
this.intentTimeoutMs = options.intentTimeoutMs ?? 2000
|
||||||
|
this.reverseToleranceRatio = options.reverseToleranceRatio ?? 0.008
|
||||||
}
|
}
|
||||||
|
|
||||||
setView(view: GuideAutoSwitchView) {
|
setView(view: GuideAutoSwitchView) {
|
||||||
@@ -76,6 +87,7 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
this.resetOverviewTracking()
|
this.resetOverviewTracking()
|
||||||
if (view !== 'floor') {
|
if (view !== 'floor') {
|
||||||
this.floorInitialDistance = 0
|
this.floorInitialDistance = 0
|
||||||
|
this.floorLastDistance = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +100,9 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
|
|
||||||
const now = this.now()
|
const now = this.now()
|
||||||
const intentExpired = this.hasOverviewIntentExpired(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 (
|
if (
|
||||||
options.resetBaseline
|
options.resetBaseline
|
||||||
|| !this.overviewStartDistance
|
|| !this.overviewStartDistance
|
||||||
@@ -99,7 +113,9 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
this.overviewStartDistance = distance
|
this.overviewStartDistance = distance
|
||||||
}
|
}
|
||||||
|
|
||||||
this.overviewLastDistance = distance
|
if (!isReverseJitter) {
|
||||||
|
this.overviewLastDistance = distance
|
||||||
|
}
|
||||||
this.overviewLastInputAt = now
|
this.overviewLastInputAt = now
|
||||||
this.currentDistance = distance
|
this.currentDistance = distance
|
||||||
this.currentSource = source
|
this.currentSource = source
|
||||||
@@ -113,6 +129,7 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
|
|
||||||
const now = this.now()
|
const now = this.now()
|
||||||
const source = options.source || this.currentSource
|
const source = options.source || this.currentSource
|
||||||
|
const previousCurrentDistance = this.currentDistance
|
||||||
this.currentDistance = distance
|
this.currentDistance = distance
|
||||||
this.currentSource = source
|
this.currentSource = source
|
||||||
|
|
||||||
@@ -121,20 +138,33 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
return
|
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
|
const shouldExit = this.floorInitialDistance > 0
|
||||||
&& distance >= this.floorInitialDistance * (1 + this.exitRatio)
|
&& distance >= this.floorInitialDistance * (1 + this.exitRatio)
|
||||||
this.updateCandidate('exit-overview', shouldExit)
|
this.updateCandidate(
|
||||||
|
'exit-overview',
|
||||||
|
shouldExit,
|
||||||
|
isReverseJitter && distance < previousCurrentDistance
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setFloorInitialDistance(distance: number) {
|
setFloorInitialDistance(distance: number) {
|
||||||
this.cancelCandidate('exit-overview')
|
this.cancelCandidate('exit-overview')
|
||||||
this.floorInitialDistance = isValidDistance(distance) ? distance : 0
|
this.floorInitialDistance = isValidDistance(distance) ? distance : 0
|
||||||
|
this.floorLastDistance = this.floorInitialDistance
|
||||||
this.currentDistance = this.floorInitialDistance
|
this.currentDistance = this.floorInitialDistance
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFloorInitialDistance() {
|
clearFloorInitialDistance() {
|
||||||
this.cancelCandidate('exit-overview')
|
this.cancelCandidate('exit-overview')
|
||||||
this.floorInitialDistance = 0
|
this.floorInitialDistance = 0
|
||||||
|
this.floorLastDistance = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
markTransitionSucceeded() {
|
markTransitionSucceeded() {
|
||||||
@@ -155,6 +185,7 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
this.transitionPending = false
|
this.transitionPending = false
|
||||||
this.cooldownUntil = 0
|
this.cooldownUntil = 0
|
||||||
this.floorInitialDistance = 0
|
this.floorInitialDistance = 0
|
||||||
|
this.floorLastDistance = 0
|
||||||
this.currentDistance = 0
|
this.currentDistance = 0
|
||||||
this.resetOverviewTracking()
|
this.resetOverviewTracking()
|
||||||
}
|
}
|
||||||
@@ -168,18 +199,32 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
? previousDistance
|
? previousDistance
|
||||||
: distance
|
: distance
|
||||||
const intentExpired = this.hasOverviewIntentExpired(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 (!this.overviewStartDistance || intentExpired || reversed) {
|
if (!this.overviewStartDistance || intentExpired || reversed) {
|
||||||
this.cancelCandidate('enter-floor')
|
this.cancelCandidate('enter-floor')
|
||||||
this.overviewStartDistance = reversed ? distance : fallbackStartDistance
|
this.overviewStartDistance = reversed ? distance : fallbackStartDistance
|
||||||
}
|
}
|
||||||
|
|
||||||
this.overviewLastDistance = distance
|
if (!isReverseJitter) {
|
||||||
|
this.overviewLastDistance = distance
|
||||||
|
}
|
||||||
this.overviewLastInputAt = now
|
this.overviewLastInputAt = now
|
||||||
|
|
||||||
const shouldEnter = distance <= this.overviewStartDistance * (1 - this.enterRatio)
|
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) {
|
private hasOverviewIntentExpired(now: number) {
|
||||||
@@ -187,8 +232,13 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
&& now - this.overviewLastInputAt > this.intentTimeoutMs
|
&& now - this.overviewLastInputAt > this.intentTimeoutMs
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCandidate(direction: GuideAutoSwitchDirection, conditionMet: boolean) {
|
private updateCandidate(
|
||||||
|
direction: GuideAutoSwitchDirection,
|
||||||
|
conditionMet: boolean,
|
||||||
|
keepOnReverseJitter = false
|
||||||
|
) {
|
||||||
if (!conditionMet) {
|
if (!conditionMet) {
|
||||||
|
if (keepOnReverseJitter && this.candidate?.direction === direction) return
|
||||||
this.cancelCandidate(direction)
|
this.cancelCandidate(direction)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,19 +247,28 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
if (!this.canSwitch(direction)) return
|
if (!this.canSwitch(direction)) return
|
||||||
|
|
||||||
this.cancelCandidate()
|
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(() => {
|
const timer = setTimeout(() => {
|
||||||
this.confirmCandidate(direction)
|
this.confirmCandidate(direction)
|
||||||
}, Math.max(0, delay))
|
}, Math.max(0, delay))
|
||||||
this.candidate = { direction, timer }
|
this.candidate = {
|
||||||
|
direction,
|
||||||
|
timer,
|
||||||
|
distanceAtStart: this.currentDistance,
|
||||||
|
startedAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private confirmCandidate(direction: GuideAutoSwitchDirection) {
|
private confirmCandidate(direction: GuideAutoSwitchDirection) {
|
||||||
if (this.candidate?.direction !== direction) return
|
if (this.candidate?.direction !== direction) return
|
||||||
|
const candidateDistance = this.candidate.distanceAtStart
|
||||||
|
const candidateStartedAt = this.candidate.startedAt
|
||||||
this.candidate = null
|
this.candidate = null
|
||||||
|
|
||||||
if (this.transitionPending || !this.isConditionMet(direction)) return
|
if (this.transitionPending || !this.isConditionMet(direction, candidateDistance)) return
|
||||||
if (this.now() < this.cooldownUntil) {
|
if (this.now() < this.cooldownUntil) {
|
||||||
this.updateCandidate(direction, true)
|
this.updateCandidate(direction, true)
|
||||||
return
|
return
|
||||||
@@ -220,20 +279,30 @@ export class GuideAutoSwitchStateMachine {
|
|||||||
this.onSwitchRequested({
|
this.onSwitchRequested({
|
||||||
direction,
|
direction,
|
||||||
distance: this.currentDistance,
|
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') {
|
if (direction === 'enter-floor') {
|
||||||
|
const thresholdDistance = this.overviewStartDistance * (1 - this.enterRatio)
|
||||||
return this.view === 'overview'
|
return this.view === 'overview'
|
||||||
&& this.overviewStartDistance > 0
|
&& 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'
|
return this.view === 'floor'
|
||||||
&& this.floorInitialDistance > 0
|
&& this.floorInitialDistance > 0
|
||||||
&& this.currentDistance >= this.floorInitialDistance * (1 + this.exitRatio)
|
&& (
|
||||||
|
this.currentDistance >= thresholdDistance
|
||||||
|
|| this.currentDistance >= candidateDistance * (1 - this.reverseToleranceRatio)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelCandidate(direction?: GuideAutoSwitchDirection) {
|
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('建筑外观与馆内楼层自动切换状态机', () => {
|
describe('建筑外观与馆内楼层自动切换状态机', () => {
|
||||||
it('连续放大 14.9% 不进入,达到 15% 并保持 500ms 后进入', () => {
|
it('连续放大 14.9% 不进入,达到 15% 后进入', () => {
|
||||||
const { machine, requests } = createMachine()
|
const { machine, requests } = createMachine()
|
||||||
|
|
||||||
machine.beginInput(100, 'gesture')
|
machine.beginInput(100, 'gesture')
|
||||||
machine.updateDistance(85.1, { source: 'gesture' })
|
machine.updateDistance(85.1, { source: 'gesture' })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(400)
|
||||||
expect(requests).toEqual([])
|
expect(requests).toEqual([])
|
||||||
|
|
||||||
machine.updateDistance(85, { source: 'gesture' })
|
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([])
|
expect(requests).toEqual([])
|
||||||
vi.advanceTimersByTime(1)
|
vi.advanceTimersByTime(1)
|
||||||
expect(requests).toEqual(['enter-floor'])
|
expect(requests).toEqual(['enter-floor'])
|
||||||
@@ -46,12 +55,12 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
|
|||||||
machine.updateDistance(95, { source: 'wheel' })
|
machine.updateDistance(95, { source: 'wheel' })
|
||||||
machine.beginInput(95, 'wheel')
|
machine.beginInput(95, 'wheel')
|
||||||
machine.updateDistance(90, { source: 'wheel' })
|
machine.updateDistance(90, { source: 'wheel' })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(400)
|
||||||
|
|
||||||
expect(requests).toEqual([])
|
expect(requests).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 并保持 500ms 后退出', () => {
|
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 后退出', () => {
|
||||||
const { machine, requests } = createMachine()
|
const { machine, requests } = createMachine()
|
||||||
|
|
||||||
machine.setView('floor')
|
machine.setView('floor')
|
||||||
@@ -61,37 +70,59 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
|
|||||||
expect(requests).toEqual([])
|
expect(requests).toEqual([])
|
||||||
|
|
||||||
machine.updateDistance(120)
|
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([])
|
expect(requests).toEqual([])
|
||||||
vi.advanceTimersByTime(1)
|
vi.advanceTimersByTime(1)
|
||||||
expect(requests).toEqual(['exit-overview'])
|
expect(requests).toEqual(['exit-overview'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('条件满足后反向缩放会取消防抖任务并重置进入基准', () => {
|
it('0.8% 内的触控反向抖动不重置进入基准,也不取消候选状态', () => {
|
||||||
const { machine, requests } = createMachine()
|
const { machine, requests } = createMachine()
|
||||||
|
|
||||||
machine.beginInput(100, 'touch', { resetBaseline: true })
|
machine.beginInput(100, 'touch', { resetBaseline: true })
|
||||||
machine.updateDistance(85, { source: 'touch' })
|
machine.updateDistance(85, { source: 'touch' })
|
||||||
vi.advanceTimersByTime(300)
|
vi.advanceTimersByTime(60)
|
||||||
machine.updateDistance(88, { source: 'touch' })
|
machine.updateDistance(85.6, { source: 'touch' })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(60)
|
||||||
|
|
||||||
expect(requests).toEqual([])
|
|
||||||
|
|
||||||
machine.updateDistance(74.8, { source: 'touch' })
|
|
||||||
vi.advanceTimersByTime(500)
|
|
||||||
expect(requests).toEqual(['enter-floor'])
|
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()
|
const { machine, requests } = createMachine()
|
||||||
|
|
||||||
machine.setView('floor')
|
machine.setView('floor')
|
||||||
machine.setFloorInitialDistance(100)
|
machine.setFloorInitialDistance(100)
|
||||||
machine.updateDistance(120)
|
machine.updateDistance(120)
|
||||||
vi.advanceTimersByTime(300)
|
vi.advanceTimersByTime(200)
|
||||||
machine.updateDistance(119.9)
|
machine.updateDistance(118.9)
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(400)
|
||||||
|
|
||||||
expect(requests).toEqual([])
|
expect(requests).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -101,13 +132,13 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
|
|||||||
|
|
||||||
machine.beginInput(100, 'wheel')
|
machine.beginInput(100, 'wheel')
|
||||||
machine.updateDistance(90, { source: 'wheel' })
|
machine.updateDistance(90, { source: 'wheel' })
|
||||||
vi.advanceTimersByTime(1201)
|
vi.advanceTimersByTime(2001)
|
||||||
machine.updateDistance(80, { previousDistance: 90, source: 'wheel' })
|
machine.updateDistance(80, { previousDistance: 90, source: 'wheel' })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(120)
|
||||||
expect(requests).toEqual([])
|
expect(requests).toEqual([])
|
||||||
|
|
||||||
machine.updateDistance(76.5, { source: 'wheel' })
|
machine.updateDistance(76.5, { source: 'wheel' })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(120)
|
||||||
expect(requests).toEqual(['enter-floor'])
|
expect(requests).toEqual(['enter-floor'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,7 +147,7 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
|
|||||||
|
|
||||||
machine.beginInput(100, 'wheel')
|
machine.beginInput(100, 'wheel')
|
||||||
machine.updateDistance(85, { source: 'wheel' })
|
machine.updateDistance(85, { source: 'wheel' })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(120)
|
||||||
expect(requests).toEqual(['enter-floor'])
|
expect(requests).toEqual(['enter-floor'])
|
||||||
|
|
||||||
machine.markTransitionSucceeded()
|
machine.markTransitionSucceeded()
|
||||||
@@ -136,11 +167,11 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
|
|||||||
|
|
||||||
machine.beginInput(100, source, { resetBaseline: source === 'touch' })
|
machine.beginInput(100, source, { resetBaseline: source === 'touch' })
|
||||||
machine.updateDistance(85.1, { source })
|
machine.updateDistance(85.1, { source })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(400)
|
||||||
expect(requests).toEqual([])
|
expect(requests).toEqual([])
|
||||||
|
|
||||||
machine.updateDistance(85, { source })
|
machine.updateDistance(85, { source })
|
||||||
vi.advanceTimersByTime(500)
|
vi.advanceTimersByTime(120)
|
||||||
expect(requests).toEqual(['enter-floor'])
|
expect(requests).toEqual(['enter-floor'])
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user