From abaeda189e5ca24e3b57a1dd418e90b2b6407c88 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Tue, 14 Jul 2026 01:16:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B7=A8=E6=A5=BC=E5=B1=82?= =?UTF-8?q?=E7=82=B9=E4=BD=8D=E8=81=9A=E7=84=A6=E8=A7=86=E8=A7=89=E8=B7=B3?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/ThreeMap.vue | 378 ++++++++----------- tests/e2e/three-map-camera-stability.spec.ts | 156 +++++++- tests/unit/FacilityDetail.spec.ts | 2 +- 3 files changed, 296 insertions(+), 240 deletions(-) diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 0502f23..fc7c46d 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -227,6 +227,7 @@ interface CameraTweenState { toTarget: THREE.Vector3 startedAt: number durationMs: number + reason?: 'poi-focus' onComplete?: () => void } @@ -344,26 +345,11 @@ interface PoiMarkerCacheEntry { pois: RenderPoi[] group: THREE.Group markerSize: number - displayOffsetY: number rawPoiCount?: number rawPositionedPoiCount?: number filteredCategoryCounts?: Record } -interface PoiYDistribution { - count: number - min: number - max: number - median: number -} - -interface ModelYBounds { - min: number - max: number - center: number - height: number -} - interface PreparedFloorScene { floor: FloorIndexItem model: THREE.Object3D @@ -481,8 +467,7 @@ let activeRoutePreviewSignature = '' const poiDataCache = new Map() const poiMarkerGroupCache = new Map() const preparedFloorModelCache = new Map() -const poiDisplayOffsetYCache = new Map() -const poiDisplayDiagnosticsKeys = new Set() +const poiCoordinateDiagnosticsKeys = new Set() let activeFocusLabelSprite: THREE.Sprite | null = null let activeFocusPulseSprite: THREE.Sprite | null = null let activeFocusBaseSprite: THREE.Sprite | null = null @@ -523,6 +508,8 @@ let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture' let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | null = null let cameraTween: CameraTweenState | null = null +let poiFocusCameraAnimationCount = 0 +let cameraSnapshotRestoreCount = 0 let adjacentPreloadSeq = 0 let defaultFloorPreloadSeq = 0 let firstModelLoadStartedAt = 0 @@ -545,11 +532,6 @@ const adjacentPreloadDelayMs = 900 const manualAutoSwitchPauseMs = 1500 const autoSwitchExitDistanceRatio = 0.2 const autoSwitchExitDistanceMarginRatio = 0.08 -const poiMarkerVisualLiftFactor = 0.92 -const poiAmbientLabelLiftFactor = 2.1 -const poiFocusLabelLiftFactor = 2.72 -const poiFocusBaseLiftFactor = 0.18 -const poiFocusPulseLiftFactor = 0.34 type ThreeMapDiagnosticEvent = | 'init-start' @@ -570,6 +552,7 @@ type ThreeMapDiagnosticEvent = | 'poi-background-start' | 'poi-background-ready' | 'poi-filter-diagnostics' + | 'poi-coordinate-diagnostics' | 'poi-marker-render-diagnostics' | 'poi-hit-diagnostics' | 'adjacent-preload-skip' @@ -1127,131 +1110,69 @@ const countPoiMarkerObjects = (group: THREE.Group) => { return counts } -const summarizePoiYDistribution = (pois: RenderPoi[]): PoiYDistribution | null => { - const values = pois - .map((poi) => poi.positionGltf?.[1]) - .filter((value): value is number => Number.isFinite(value)) - .sort((left, right) => left - right) - - if (!values.length) return null - - const middle = Math.floor(values.length / 2) - const median = values.length % 2 - ? values[middle] - : (values[middle - 1] + values[middle]) / 2 - - return { - count: values.length, - min: values[0], - max: values[values.length - 1], - median - } -} - -const getModelYBounds = (model: THREE.Object3D): ModelYBounds => { - const box = getObjectBox(model) - const height = Math.max(box.max.y - box.min.y, 0) - - return { - min: box.min.y, - max: box.max.y, - center: (box.min.y + box.max.y) / 2, - height - } -} - const roundDiagnosticNumber = (value: number) => Math.round(value * 1000) / 1000 -const getPoiDisplayOffsetCacheKey = (floorId: string) => floorId - -const getCachedPoiDisplayOffsetY = (floorId: string) => ( - poiDisplayOffsetYCache.get(getPoiDisplayOffsetCacheKey(floorId)) || 0 -) - -const getPoiDisplayPosition = (poi: RenderPoi, floorId = poi.floorId) => { +const getPoiDisplayPosition = (poi: RenderPoi) => { if (!poi.positionGltf) return null const [x, y, z] = poi.positionGltf - return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z) + return new THREE.Vector3(x, y, z) } -const getPoiLiftedPosition = ( - displayPosition: THREE.Vector3, - markerSize: number, - liftFactor: number -) => new THREE.Vector3( - displayPosition.x, - displayPosition.y + markerSize * liftFactor, - displayPosition.z -) - -const getPoiMarkerPosition = (displayPosition: THREE.Vector3, markerSize: number) => ( - getPoiLiftedPosition(displayPosition, markerSize, poiMarkerVisualLiftFactor) -) - -const getPoiAmbientLabelPosition = (displayPosition: THREE.Vector3, markerSize: number) => ( - getPoiLiftedPosition(displayPosition, markerSize, poiAmbientLabelLiftFactor) -) - -const resolvePoiDisplayOffsetY = ( +const recordPoiCoordinateDiagnostics = ( floorId: string, model: THREE.Object3D, pois: RenderPoi[] ) => { - const modelY = getModelYBounds(model) - const poiY = summarizePoiYDistribution(pois) - let offsetY = 0 - let reason = 'no-positioned-poi' + if (!import.meta.env.DEV) return - if (poiY) { - const modelTolerance = Math.max(2, modelY.height * 0.35) - const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance + const box = getObjectBox(model) + const positionedPois = pois.filter((poi): poi is RenderPoi & { positionGltf: [number, number, number] } => ( + Boolean(poi.positionGltf) + )) + const coordinateErrors = positionedPois.map((poi) => { + const [x, y, z] = poi.positionGltf + const error = Math.hypot( + Math.max(box.min.x - x, 0, x - box.max.x), + Math.max(box.min.y - y, 0, y - box.max.y), + Math.max(box.min.z - z, 0, z - box.max.z) + ) + return { poi, error } + }) + const maxCoordinateError = Math.max(0, ...coordinateErrors.map(({ error }) => error)) + const diagnosticsKey = [ + floorId, + positionedPois.length, + roundDiagnosticNumber(maxCoordinateError), + roundDiagnosticNumber(box.min.x), + roundDiagnosticNumber(box.min.y), + roundDiagnosticNumber(box.min.z), + roundDiagnosticNumber(box.max.x), + roundDiagnosticNumber(box.max.y), + roundDiagnosticNumber(box.max.z) + ].join(':') - if (overlapsModelRange) { - reason = 'poi-y-overlaps-model-y' - } else { - offsetY = modelY.center - poiY.median - reason = 'align-poi-median-to-model-center' - } - } + if (poiCoordinateDiagnosticsKeys.has(diagnosticsKey)) return - poiDisplayOffsetYCache.set(getPoiDisplayOffsetCacheKey(floorId), offsetY) - - if (import.meta.env.DEV) { - const diagnosticsKey = [ - floorId, - roundDiagnosticNumber(modelY.min), - roundDiagnosticNumber(modelY.max), - poiY ? roundDiagnosticNumber(poiY.min) : 'none', - poiY ? roundDiagnosticNumber(poiY.max) : 'none', - roundDiagnosticNumber(offsetY) - ].join(':') - - if (!poiDisplayDiagnosticsKeys.has(diagnosticsKey)) { - poiDisplayDiagnosticsKeys.add(diagnosticsKey) - console.info('[ThreeMap] floor POI display coordinate diagnostics', { - floorId, - modelY: { - min: roundDiagnosticNumber(modelY.min), - max: roundDiagnosticNumber(modelY.max), - center: roundDiagnosticNumber(modelY.center), - height: roundDiagnosticNumber(modelY.height) - }, - poiY: poiY - ? { - count: poiY.count, - min: roundDiagnosticNumber(poiY.min), - max: roundDiagnosticNumber(poiY.max), - median: roundDiagnosticNumber(poiY.median) - } - : null, - displayOffsetY: roundDiagnosticNumber(offsetY), - reason - }) - } - } - - return offsetY + poiCoordinateDiagnosticsKeys.add(diagnosticsKey) + logThreeMapDiagnostic('poi-coordinate-diagnostics', { + floorId, + coordinateSpace: 'GLB_METER', + modelBounds: { + min: serializeVector3(box.min), + max: serializeVector3(box.max) + }, + positionedPoiCount: positionedPois.length, + maxCoordinateError: roundDiagnosticNumber(maxCoordinateError), + mismatchedPois: coordinateErrors + .filter(({ error }) => error > 1e-6) + .slice(0, 12) + .map(({ poi, error }) => ({ + poiId: poi.id, + positionGltf: poi.positionGltf, + coordinateError: roundDiagnosticNumber(error) + })) + }) } const getPoiGlyph = (poi: RenderPoi) => ( @@ -1449,7 +1370,6 @@ const updateAmbientPoiLabels = () => { const densityTier = getPoiLabelDensityTier() const acceptedBounds: Array> = [] - const markerSize = getPoiMarkerSize() const candidates = getPoiSprites() .map((sprite) => { const poi = sprite.userData.poi as RenderPoi | undefined @@ -1481,44 +1401,19 @@ const updateAmbientPoiLabels = () => { updateAmbientLabelScale(labelSprite, poi) - const baseOffset = markerSize * (isHallPoi(poi) ? poiAmbientLabelLiftFactor : 1.82) - const offsetAttempts = [ - 0, - markerSize * 0.82, - -markerSize * 0.62, - markerSize * 1.48, - -markerSize * 1.08 - ] const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx - let accepted = false - - for (const offset of offsetAttempts) { - labelSprite.position.set(displayPosition.x, displayPosition.y + baseOffset + offset, displayPosition.z) - const screenPosition = getProjectedScreenPosition(labelSprite) - if (!screenPosition) continue - - const bounds = getAmbientLabelBounds(screenPosition, poi) - const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing)) - if (overlaps) continue - - labelSprite.visible = true - acceptedBounds.push(bounds) - accepted = true - break + labelSprite.position.copy(displayPosition) + const screenPosition = getProjectedScreenPosition(labelSprite) + if (!screenPosition) { + labelSprite.visible = false + return } - if (!accepted) { - if (isHallPoi(poi)) { - const fallbackOffset = offsetAttempts[offsetAttempts.length - 1] - labelSprite.position.set( - displayPosition.x, - displayPosition.y + baseOffset + fallbackOffset, - displayPosition.z - ) - labelSprite.visible = true - } else { - labelSprite.visible = false - } + const bounds = getAmbientLabelBounds(screenPosition, poi) + const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing)) + labelSprite.visible = !overlaps + if (!overlaps) { + acceptedBounds.push(bounds) } }) } @@ -1662,12 +1557,20 @@ const cancelCameraTween = (options: { manual?: boolean } = {}) => { const moveCameraTo = ( position: THREE.Vector3, target: THREE.Vector3, - options: { durationMs?: number; immediate?: boolean; onComplete?: () => void } = {} + options: { + durationMs?: number + immediate?: boolean + reason?: 'poi-focus' + onComplete?: () => void + } = {} ) => { if (!camera || !controls) return const toPosition = position.clone() const toTarget = target.clone() + if (options.reason === 'poi-focus') { + poiFocusCameraAnimationCount += 1 + } isProgrammaticCameraChange = true clearProgrammaticCameraTimer() @@ -1688,6 +1591,7 @@ const moveCameraTo = ( toTarget, startedAt: performance.now(), durationMs: options.durationMs ?? cameraTweenDurationMs, + reason: options.reason, onComplete: options.onComplete } } @@ -2358,8 +2262,7 @@ const disposePoiMarkerCache = () => { }) poiMarkerGroupCache.clear() poiDataCache.clear() - poiDisplayOffsetYCache.clear() - poiDisplayDiagnosticsKeys.clear() + poiCoordinateDiagnosticsKeys.clear() } const clearPoiGroupChildren = () => { @@ -2572,22 +2475,8 @@ const clearRoutePreview = () => { } } -const getRouteFloorOffsetY = (floorId: string) => { - if (activeView.value !== 'multi' || !activeModel) return 0 - - let offsetY = 0 - activeModel.traverse((child) => { - if (offsetY) return - if (child.userData.floorId === floorId && typeof child.userData.multiFloorOffsetY === 'number') { - offsetY = child.userData.multiFloorOffsetY - } - }) - - return offsetY -} - -const routePointToVector = (position: [number, number, number], floorId: string, lift = 0.45) => ( - new THREE.Vector3(position[0], position[1] + getRouteFloorOffsetY(floorId) + lift, position[2]) +const routePointToVector = (position: [number, number, number]) => ( + new THREE.Vector3(position[0], position[1], position[2]) ) const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => { @@ -2677,7 +2566,7 @@ const renderRouteEndpoint = ( if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return const marker = createRouteMarkerSprite(label, color, markerSize) - marker.position.copy(routePointToVector(endpoint.position, endpoint.floorId, markerSize * 0.45)) + marker.position.copy(routePointToVector(endpoint.position)) routeGroup.add(marker) } @@ -2688,7 +2577,7 @@ const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number .filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value) .forEach((point) => { const marker = createRouteMarkerSprite('换', '#8a5cf6', markerSize * 0.72) - marker.position.copy(routePointToVector(point.position, point.floorId, markerSize * 0.25)) + marker.position.copy(routePointToVector(point.position)) routeGroup!.add(marker) }) } @@ -2746,7 +2635,7 @@ const renderRoutePreview = () => { if (segment.points.length < 2) return targetRouteGroup.add(createRouteLine( - segment.points.map((point) => routePointToVector(point.position, point.floorId)), + segment.points.map((point) => routePointToVector(point.position)), activeView.value === 'floor' ? 1 : 0.88 )) }) @@ -3477,6 +3366,7 @@ const captureCameraSnapshot = (): CameraSnapshot => { const restoreCameraSnapshot = (snapshot: CameraSnapshot) => { if (!camera || !controls) return + cameraSnapshotRestoreCount += 1 cancelCameraTween() clearProgrammaticCameraTimer() isProgrammaticCameraChange = false @@ -3609,13 +3499,30 @@ const getProjectedBuildingAnchors = () => { }) } +const getVisualFocusReport = () => { + if (!selectedPOI.value?.positionGltf) return null + + const marker = findPoiSprite(selectedPOI.value.id) + return { + poiId: selectedPOI.value.id, + floorId: selectedPOI.value.floorId, + positionGltf: [...selectedPOI.value.positionGltf] as [number, number, number], + markerPosition: marker + ? { x: marker.position.x, y: marker.position.y, z: marker.position.z } + : null + } +} + const getVisualStabilityReport = () => ({ camera: serializeCameraSnapshot(captureCameraSnapshot()), modelRoot: getModelRootTransformAudit(activeModel), anchors: getProjectedBuildingAnchors(), activeView: activeView.value, floorId: currentFloor.value, - isCameraTweening: Boolean(cameraTween) + isCameraTweening: Boolean(cameraTween), + poiFocusCameraAnimationCount, + cameraSnapshotRestoreCount, + focus: getVisualFocusReport() }) const installVisualStabilityDiagnostics = () => { @@ -3633,7 +3540,25 @@ const installVisualStabilityDiagnostics = () => { })), switchFloor: handleFloorChange, showOverview, - resetCamera + resetCamera, + getFloorPois: async (floorId: string) => { + const floor = floorIndex.value.find((item) => item.floorId === floorId) + if (!floor) return [] + const pois = poiDataCache.get(floorId) || await props.modelSource.loadFloorPois(floorId) + return pois + .filter((poi) => Boolean(poi.positionGltf)) + .map((poi) => ({ + poiId: poi.id, + floorId: poi.floorId, + name: poi.name, + positionGltf: poi.positionGltf! + })) + }, + focusTargetPoi: async (request: TargetPoiFocusRequest) => { + queueTargetFocus(request) + return targetFocusQueue + }, + clearTargetFocus } } @@ -4629,7 +4554,7 @@ const showFocusPoiLabel = (poi: RenderPoi) => { const markerSize = getPoiMarkerSize() activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize) activeFocusLabelSprite.frustumCulled = false - activeFocusLabelSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusLabelLiftFactor)) + activeFocusLabelSprite.position.copy(displayPosition) updateFocusLabelScale() poiGroup.add(activeFocusLabelSprite) } @@ -4643,7 +4568,7 @@ const showFocusPoiBase = (poi: RenderPoi) => { const markerSize = getPoiMarkerSize() activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize) activeFocusBaseSprite.frustumCulled = false - activeFocusBaseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusBaseLiftFactor)) + activeFocusBaseSprite.position.copy(displayPosition) poiGroup.add(activeFocusBaseSprite) } @@ -4656,7 +4581,7 @@ const showFocusPoiPulse = (poi: RenderPoi) => { const markerSize = getPoiMarkerSize() activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize) activeFocusPulseSprite.frustumCulled = false - activeFocusPulseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusPulseLiftFactor)) + activeFocusPulseSprite.position.copy(displayPosition) poiGroup.add(activeFocusPulseSprite) } @@ -4800,13 +4725,13 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => { if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null const poi = createPoiFromFocusRequest(request) - const displayPosition = poi ? getPoiDisplayPosition(poi, request.floorId) : null + const displayPosition = poi ? getPoiDisplayPosition(poi) : null if (!poi || !displayPosition) return null const markerSize = getPoiMarkerSize() const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize) - sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize)) - hitTarget.position.copy(sprite.position) + sprite.position.copy(displayPosition) + hitTarget.position.copy(displayPosition) poiGroup.add(sprite) poiGroup.add(hitTarget) @@ -4826,14 +4751,14 @@ const createPoiMarkerGroup = ( group.userData.displayMode = displayMode pois.forEach((poi) => { - const displayPosition = getPoiDisplayPosition(poi, floorId) + const displayPosition = getPoiDisplayPosition(poi) if (!displayPosition) return const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize) - sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize)) - hitTarget.position.copy(sprite.position) + sprite.position.copy(displayPosition) + hitTarget.position.copy(displayPosition) if (labelSprite) { - labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize)) + labelSprite.position.copy(displayPosition) labelSprite.visible = false } sprite.visible = isPoiIncludedByVisibleFilter(poi) @@ -4877,8 +4802,7 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => { poiGroupCurrentFloor: poiGroup.userData.currentFloor, selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value), markerSize: roundDiagnosticNumber(entry.markerSize), - displayOffsetY: roundDiagnosticNumber(entry.displayOffsetY), - visualLiftY: roundDiagnosticNumber(entry.markerSize * poiMarkerVisualLiftFactor) + coordinateSpace: 'GLB_METER' }) } @@ -4945,9 +4869,9 @@ const prepareFloorPOIs = async ( renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length, filteredCategoryCounts: countPoisByCategory(filteredPois) }) - const displayOffsetY = markerModel - ? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois) - : getCachedPoiDisplayOffsetY(floor.floorId) + if (markerModel) { + recordPoiCoordinateDiagnostics(floor.floorId, markerModel, validPois) + } const markerSize = getPoiMarkerSizeForModel(markerModel) const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize) const entry: PoiMarkerCacheEntry = { @@ -4956,7 +4880,6 @@ const prepareFloorPOIs = async ( pois: validPois, group: markerGroup, markerSize, - displayOffsetY, rawPoiCount: floorPois.length, rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length, filteredCategoryCounts: countPoisByCategory(filteredPois) @@ -5232,30 +5155,25 @@ const isSceneReadyForTargetFocus = () => ( ) const focusCameraOnPoi = (poi: RenderPoi) => { - const displayPosition = getPoiDisplayPosition(poi) - if (!camera || !controls || !displayPosition) return + const target = getPoiDisplayPosition(poi) + if (!camera || !controls || !target) return - const target = displayPosition.clone() - const modelSize = activeModel - ? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()) - : new THREE.Vector3(80, 80, 80) - const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1) + const currentOffset = camera.position.clone().sub(controls.target) + const currentDistance = currentOffset.length() + const direction = currentDistance > 1e-6 + ? currentOffset.multiplyScalar(1 / currentDistance) + : new THREE.Vector3(0.72, 0.58, 1).normalize() const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1) - const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4) - const distance = Math.min( - Math.max(maxDim * focusDistanceFactor, 28), - Math.max(maxDim * focusMaxDistanceFactor, 80) + const minimumDistance = Math.max(controls.minDistance, 1) + const maximumDistance = Math.max(minimumDistance, controls.maxDistance) + const distance = THREE.MathUtils.clamp( + currentDistance * focusDistanceFactor, + minimumDistance, + maximumDistance ) - const direction = new THREE.Vector3(0.72, 0.58, 1).normalize() const nextPosition = target.clone().add(direction.multiplyScalar(distance)) - camera.near = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1200) - camera.far = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20) - camera.updateProjectionMatrix() - - controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.025) - controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 6) - moveCameraTo(nextPosition, target) + moveCameraTo(nextPosition, target, { reason: 'poi-focus' }) } const focusTargetPoi = async (request: TargetPoiFocusRequest) => { @@ -5387,6 +5305,8 @@ const init3DScene = async () => { firstModelLoadStartedAt = getNow() firstModelVisibleReported = false initialModelSettled = false + poiFocusCameraAnimationCount = 0 + cameraSnapshotRestoreCount = 0 isLoading.value = true loadError.value = false selectedPOI.value = null @@ -5570,6 +5490,8 @@ const disposeScene = () => { clearModelAdjustReportTimer() hasPendingManualModelAdjustment = false cameraTween = null + poiFocusCameraAnimationCount = 0 + cameraSnapshotRestoreCount = 0 isProgrammaticCameraChange = false activeAutoSwitchInputSource = 'gesture' resetInteractionGateState() diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts index 759b966..70daa52 100644 --- a/tests/e2e/three-map-camera-stability.spec.ts +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -30,6 +30,14 @@ interface VisualReport { activeView: string floorId: string isCameraTweening: boolean + poiFocusCameraAnimationCount: number + cameraSnapshotRestoreCount: number + focus: { + poiId: string + floorId: string + positionGltf: [number, number, number] + markerPosition: { x: number; y: number; z: number } | null + } | null } interface FloorReport { @@ -38,11 +46,27 @@ interface FloorReport { modelMatchKeys: string[] } +interface PoiFocusCandidate { + poiId: string + floorId: string + name: string + positionGltf: [number, number, number] +} + interface VisualStabilityApi { getReport: () => VisualReport getFloors: () => FloorReport[] switchFloor: (floorId: string) => Promise showOverview: () => Promise + getFloorPois: (floorId: string) => Promise + focusTargetPoi: (request: { + requestId: number | string + poiId: string + floorId: string + name?: string + positionGltf: [number, number, number] + }) => Promise + clearTargetFocus: () => void } const getApiState = async (page: Page) => { @@ -219,6 +243,43 @@ const resolveRequiredFloorId = (floors: FloorReport[], requested: string) => { return floor!.floorId } +const getFloorPois = async (page: Page, floorId: string) => { + const pois = await 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') + return api.getFloorPois(id) + }, floorId) + expect(pois.length, `floor ${floorId} must contain a positioned POI`).toBeGreaterThan(0) + return pois +} + +const getVectorDelta = (left: { x: number; y: number; z: number }, right: [number, number, number]) => ( + Math.hypot(left.x - right[0], left.y - right[1], left.z - right[2]) +) + +const waitForPoiFocusToSettle = async (page: Page, testInfo: TestInfo, name: string) => { + let sawTween = false + let stableFrames = 0 + + for (let frame = 0; frame < 180; frame += 1) { + const report = await getReport(page) + expect(report.modelRoot, `blank POI focus frame ${frame}`).not.toBeNull() + expect(report.modelRoot!.maxError).toBeLessThan(1e-12) + sawTween ||= report.isCameraTweening + stableFrames = report.isCameraTweening ? 0 : stableFrames + 1 + + if (frame === 0) { + await screenshot(page, testInfo, `${name}-during`) + } + if (sawTween && stableFrames >= 3) return report + + await page.waitForTimeout(40) + } + + throw new Error(`${name} did not settle after one POI focus animation`) +} + test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => { await page.goto('/') await Promise.all([ @@ -235,14 +296,14 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project const lMinus2 = resolveRequiredFloorId(state!.floors, 'L-2') const l1 = resolveRequiredFloorId(state!.floors, 'L1') const l1Point5 = resolveRequiredFloorId(state!.floors, 'L1.5') - const l2 = resolveRequiredFloorId(state!.floors, 'L2') - const l3 = resolveRequiredFloorId(state!.floors, 'L3') + const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1') + const l4 = resolveRequiredFloorId(state!.floors, 'L4') const l5 = resolveRequiredFloorId(state!.floors, '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 switchFloor(page, testInfo, lMinus1, 'l1-lminus1') + await switchFloor(page, testInfo, l4, 'lminus1-l4') + await switchFloor(page, testInfo, l1, 'l4-l1') await showOverview(page, testInfo, 'l1-exterior') await switchFloor(page, testInfo, lMinus2, 'exterior-lminus2') @@ -254,14 +315,87 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project await showOverview(page, testInfo, 'l1point5-exterior') await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit') - await switchFloor(page, testInfo, l2, 'l1-l2-auto-exit') - await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-button-exit', 'button') + await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-auto-exit') + await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-button-exit', 'button') await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit') - await switchFloor(page, testInfo, l2, 'l1-l2-wheel-exit') - await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-wheel-exit', 'wheel') + await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-wheel-exit') + await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-wheel-exit', 'wheel') await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit') - await switchFloor(page, testInfo, l2, 'l1-l2-touch-exit') - await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-touch-exit', 'touch') + await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-touch-exit') + await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-touch-exit', 'touch') +}) + +test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and does not rebound', async ({ page }, testInfo) => { + await page.goto('/') + await Promise.all([ + page.waitForURL(/tab=guide/, { timeout: 30_000 }), + page.getByText('馆内', { exact: true }).click() + ]) + await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true) + + const state = await getApiState(page) + expect(state).not.toBeNull() + const l1 = resolveRequiredFloorId(state!.floors, 'L1') + const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1') + const [targetPoi] = await getFloorPois(page, lMinus1) + + await switchFloor(page, testInfo, l1, 'search-focus-exterior-l1') + const beforeFocus = await getReport(page) + await screenshot(page, testInfo, 'search-focus-before') + + const focusTransition = page.evaluate(async (request) => { + 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.focusTargetPoi(request) + }, { + requestId: 'e2e-cross-floor-search-focus', + poiId: targetPoi.poiId, + floorId: targetPoi.floorId, + name: targetPoi.name, + positionGltf: targetPoi.positionGltf + }) + + let focusRequestCompleted = false + void focusTransition.then(() => { focusRequestCompleted = true }) + for (let frame = 0; !focusRequestCompleted && frame < 180; frame += 1) { + const report = await getReport(page) + expect(report.modelRoot, `blank cross-floor POI load frame ${frame}`).not.toBeNull() + expect(report.modelRoot!.maxError).toBeLessThan(1e-12) + await page.waitForTimeout(40) + } + expect(focusRequestCompleted, 'cross-floor POI focus exceeded the sampling window').toBe(true) + await focusTransition + + const afterFocus = await waitForPoiFocusToSettle(page, testInfo, 'search-focus') + await screenshot(page, testInfo, 'search-focus-after') + + expect(afterFocus.activeView).toBe('floor') + expect(afterFocus.floorId).toBe(lMinus1) + expect(afterFocus.focus).not.toBeNull() + expect(afterFocus.focus!.poiId).toBe(targetPoi.poiId) + expect(afterFocus.focus!.positionGltf).toEqual(targetPoi.positionGltf) + expect(getVectorDelta(afterFocus.camera.target, targetPoi.positionGltf)).toBeLessThan(1e-6) + expect(afterFocus.focus!.markerPosition).not.toBeNull() + expect(getVectorDelta(afterFocus.focus!.markerPosition!, targetPoi.positionGltf)).toBeLessThan(1e-6) + expect(afterFocus.cameraSnapshotRestoreCount - beforeFocus.cameraSnapshotRestoreCount).toBe(1) + expect(afterFocus.poiFocusCameraAnimationCount - beforeFocus.poiFocusCameraAnimationCount).toBe(1) + + await page.waitForTimeout(240) + const afterIdle = await getReport(page) + expect(afterIdle.isCameraTweening).toBe(false) + expect(afterIdle.poiFocusCameraAnimationCount).toBe(afterFocus.poiFocusCameraAnimationCount) + expect(getMaximumCameraDelta(afterFocus.camera, afterIdle.camera)).toBeLessThan(1e-6) + + await page.evaluate(() => { + const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) + .__GUIDE_3D_VISUAL_STABILITY__ + if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable') + api.clearTargetFocus() + }) + + await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1') + await showOverview(page, testInfo, 'search-focus-clear-l1-exterior') }) diff --git a/tests/unit/FacilityDetail.spec.ts b/tests/unit/FacilityDetail.spec.ts index 7c3759d..2dad6d3 100644 --- a/tests/unit/FacilityDetail.spec.ts +++ b/tests/unit/FacilityDetail.spec.ts @@ -156,7 +156,7 @@ describe('点位详情实际渲染与返回闭环', () => { const sheet = wrapper.get('[data-testid="facility-detail-sheet"]') const mapShell = wrapper.getComponent(GuideMapShellStub) expect(sheet.text()).toContain('二层卫生间') - expect(sheet.text()).toContain('类别卫生间') + expect(sheet.text()).toContain('类别洗手间') expect(sheet.text()).toContain('楼层2F') expect(sheet.text()).not.toContain('可无障碍到达') expect(sheet.text()).not.toContain('展示点位')