@@ -227,6 +227,7 @@ interface CameraTweenState {
|
|||||||
toTarget: THREE.Vector3
|
toTarget: THREE.Vector3
|
||||||
startedAt: number
|
startedAt: number
|
||||||
durationMs: number
|
durationMs: number
|
||||||
|
reason?: 'poi-focus'
|
||||||
onComplete?: () => void
|
onComplete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,26 +345,11 @@ interface PoiMarkerCacheEntry {
|
|||||||
pois: RenderPoi[]
|
pois: RenderPoi[]
|
||||||
group: THREE.Group
|
group: THREE.Group
|
||||||
markerSize: number
|
markerSize: number
|
||||||
displayOffsetY: number
|
|
||||||
rawPoiCount?: number
|
rawPoiCount?: number
|
||||||
rawPositionedPoiCount?: number
|
rawPositionedPoiCount?: number
|
||||||
filteredCategoryCounts?: Record<string, number>
|
filteredCategoryCounts?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PoiYDistribution {
|
|
||||||
count: number
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
median: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelYBounds {
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
center: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreparedFloorScene {
|
interface PreparedFloorScene {
|
||||||
floor: FloorIndexItem
|
floor: FloorIndexItem
|
||||||
model: THREE.Object3D
|
model: THREE.Object3D
|
||||||
@@ -481,8 +467,7 @@ 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 preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
|
||||||
const poiDisplayOffsetYCache = new Map<string, number>()
|
const poiCoordinateDiagnosticsKeys = new Set<string>()
|
||||||
const poiDisplayDiagnosticsKeys = new Set<string>()
|
|
||||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||||
let activeFocusPulseSprite: THREE.Sprite | null = null
|
let activeFocusPulseSprite: THREE.Sprite | null = null
|
||||||
let activeFocusBaseSprite: THREE.Sprite | null = null
|
let activeFocusBaseSprite: THREE.Sprite | null = null
|
||||||
@@ -523,6 +508,8 @@ let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
|
|||||||
let isProgrammaticCameraChange = false
|
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 poiFocusCameraAnimationCount = 0
|
||||||
|
let cameraSnapshotRestoreCount = 0
|
||||||
let adjacentPreloadSeq = 0
|
let adjacentPreloadSeq = 0
|
||||||
let defaultFloorPreloadSeq = 0
|
let defaultFloorPreloadSeq = 0
|
||||||
let firstModelLoadStartedAt = 0
|
let firstModelLoadStartedAt = 0
|
||||||
@@ -545,11 +532,6 @@ const adjacentPreloadDelayMs = 900
|
|||||||
const manualAutoSwitchPauseMs = 1500
|
const manualAutoSwitchPauseMs = 1500
|
||||||
const autoSwitchExitDistanceRatio = 0.2
|
const autoSwitchExitDistanceRatio = 0.2
|
||||||
const autoSwitchExitDistanceMarginRatio = 0.08
|
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 =
|
type ThreeMapDiagnosticEvent =
|
||||||
| 'init-start'
|
| 'init-start'
|
||||||
@@ -570,6 +552,7 @@ type ThreeMapDiagnosticEvent =
|
|||||||
| 'poi-background-start'
|
| 'poi-background-start'
|
||||||
| 'poi-background-ready'
|
| 'poi-background-ready'
|
||||||
| 'poi-filter-diagnostics'
|
| 'poi-filter-diagnostics'
|
||||||
|
| 'poi-coordinate-diagnostics'
|
||||||
| 'poi-marker-render-diagnostics'
|
| 'poi-marker-render-diagnostics'
|
||||||
| 'poi-hit-diagnostics'
|
| 'poi-hit-diagnostics'
|
||||||
| 'adjacent-preload-skip'
|
| 'adjacent-preload-skip'
|
||||||
@@ -1127,131 +1110,69 @@ const countPoiMarkerObjects = (group: THREE.Group) => {
|
|||||||
return counts
|
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 roundDiagnosticNumber = (value: number) => Math.round(value * 1000) / 1000
|
||||||
|
|
||||||
const getPoiDisplayOffsetCacheKey = (floorId: string) => floorId
|
const getPoiDisplayPosition = (poi: RenderPoi) => {
|
||||||
|
|
||||||
const getCachedPoiDisplayOffsetY = (floorId: string) => (
|
|
||||||
poiDisplayOffsetYCache.get(getPoiDisplayOffsetCacheKey(floorId)) || 0
|
|
||||||
)
|
|
||||||
|
|
||||||
const getPoiDisplayPosition = (poi: RenderPoi, floorId = poi.floorId) => {
|
|
||||||
if (!poi.positionGltf) return null
|
if (!poi.positionGltf) return null
|
||||||
|
|
||||||
const [x, y, z] = poi.positionGltf
|
const [x, y, z] = poi.positionGltf
|
||||||
return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z)
|
return new THREE.Vector3(x, y, z)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPoiLiftedPosition = (
|
const recordPoiCoordinateDiagnostics = (
|
||||||
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 = (
|
|
||||||
floorId: string,
|
floorId: string,
|
||||||
model: THREE.Object3D,
|
model: THREE.Object3D,
|
||||||
pois: RenderPoi[]
|
pois: RenderPoi[]
|
||||||
) => {
|
) => {
|
||||||
const modelY = getModelYBounds(model)
|
if (!import.meta.env.DEV) return
|
||||||
const poiY = summarizePoiYDistribution(pois)
|
|
||||||
let offsetY = 0
|
|
||||||
let reason = 'no-positioned-poi'
|
|
||||||
|
|
||||||
if (poiY) {
|
const box = getObjectBox(model)
|
||||||
const modelTolerance = Math.max(2, modelY.height * 0.35)
|
const positionedPois = pois.filter((poi): poi is RenderPoi & { positionGltf: [number, number, number] } => (
|
||||||
const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance
|
Boolean(poi.positionGltf)
|
||||||
|
))
|
||||||
if (overlapsModelRange) {
|
const coordinateErrors = positionedPois.map((poi) => {
|
||||||
reason = 'poi-y-overlaps-model-y'
|
const [x, y, z] = poi.positionGltf
|
||||||
} else {
|
const error = Math.hypot(
|
||||||
offsetY = modelY.center - poiY.median
|
Math.max(box.min.x - x, 0, x - box.max.x),
|
||||||
reason = 'align-poi-median-to-model-center'
|
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 }
|
||||||
poiDisplayOffsetYCache.set(getPoiDisplayOffsetCacheKey(floorId), offsetY)
|
})
|
||||||
|
const maxCoordinateError = Math.max(0, ...coordinateErrors.map(({ error }) => error))
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const diagnosticsKey = [
|
const diagnosticsKey = [
|
||||||
floorId,
|
floorId,
|
||||||
roundDiagnosticNumber(modelY.min),
|
positionedPois.length,
|
||||||
roundDiagnosticNumber(modelY.max),
|
roundDiagnosticNumber(maxCoordinateError),
|
||||||
poiY ? roundDiagnosticNumber(poiY.min) : 'none',
|
roundDiagnosticNumber(box.min.x),
|
||||||
poiY ? roundDiagnosticNumber(poiY.max) : 'none',
|
roundDiagnosticNumber(box.min.y),
|
||||||
roundDiagnosticNumber(offsetY)
|
roundDiagnosticNumber(box.min.z),
|
||||||
|
roundDiagnosticNumber(box.max.x),
|
||||||
|
roundDiagnosticNumber(box.max.y),
|
||||||
|
roundDiagnosticNumber(box.max.z)
|
||||||
].join(':')
|
].join(':')
|
||||||
|
|
||||||
if (!poiDisplayDiagnosticsKeys.has(diagnosticsKey)) {
|
if (poiCoordinateDiagnosticsKeys.has(diagnosticsKey)) return
|
||||||
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) => (
|
const getPoiGlyph = (poi: RenderPoi) => (
|
||||||
@@ -1449,7 +1370,6 @@ const updateAmbientPoiLabels = () => {
|
|||||||
|
|
||||||
const densityTier = getPoiLabelDensityTier()
|
const densityTier = getPoiLabelDensityTier()
|
||||||
const acceptedBounds: Array<ReturnType<typeof getAmbientLabelBounds>> = []
|
const acceptedBounds: Array<ReturnType<typeof getAmbientLabelBounds>> = []
|
||||||
const markerSize = getPoiMarkerSize()
|
|
||||||
const candidates = getPoiSprites()
|
const candidates = getPoiSprites()
|
||||||
.map((sprite) => {
|
.map((sprite) => {
|
||||||
const poi = sprite.userData.poi as RenderPoi | undefined
|
const poi = sprite.userData.poi as RenderPoi | undefined
|
||||||
@@ -1481,44 +1401,19 @@ const updateAmbientPoiLabels = () => {
|
|||||||
|
|
||||||
updateAmbientLabelScale(labelSprite, poi)
|
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
|
const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx
|
||||||
let accepted = false
|
labelSprite.position.copy(displayPosition)
|
||||||
|
|
||||||
for (const offset of offsetAttempts) {
|
|
||||||
labelSprite.position.set(displayPosition.x, displayPosition.y + baseOffset + offset, displayPosition.z)
|
|
||||||
const screenPosition = getProjectedScreenPosition(labelSprite)
|
const screenPosition = getProjectedScreenPosition(labelSprite)
|
||||||
if (!screenPosition) continue
|
if (!screenPosition) {
|
||||||
|
labelSprite.visible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const bounds = getAmbientLabelBounds(screenPosition, poi)
|
const bounds = getAmbientLabelBounds(screenPosition, poi)
|
||||||
const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing))
|
const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing))
|
||||||
if (overlaps) continue
|
labelSprite.visible = !overlaps
|
||||||
|
if (!overlaps) {
|
||||||
labelSprite.visible = true
|
|
||||||
acceptedBounds.push(bounds)
|
acceptedBounds.push(bounds)
|
||||||
accepted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1662,12 +1557,20 @@ const cancelCameraTween = (options: { manual?: boolean } = {}) => {
|
|||||||
const moveCameraTo = (
|
const moveCameraTo = (
|
||||||
position: THREE.Vector3,
|
position: THREE.Vector3,
|
||||||
target: 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
|
if (!camera || !controls) return
|
||||||
|
|
||||||
const toPosition = position.clone()
|
const toPosition = position.clone()
|
||||||
const toTarget = target.clone()
|
const toTarget = target.clone()
|
||||||
|
if (options.reason === 'poi-focus') {
|
||||||
|
poiFocusCameraAnimationCount += 1
|
||||||
|
}
|
||||||
isProgrammaticCameraChange = true
|
isProgrammaticCameraChange = true
|
||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
|
|
||||||
@@ -1688,6 +1591,7 @@ const moveCameraTo = (
|
|||||||
toTarget,
|
toTarget,
|
||||||
startedAt: performance.now(),
|
startedAt: performance.now(),
|
||||||
durationMs: options.durationMs ?? cameraTweenDurationMs,
|
durationMs: options.durationMs ?? cameraTweenDurationMs,
|
||||||
|
reason: options.reason,
|
||||||
onComplete: options.onComplete
|
onComplete: options.onComplete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2358,8 +2262,7 @@ const disposePoiMarkerCache = () => {
|
|||||||
})
|
})
|
||||||
poiMarkerGroupCache.clear()
|
poiMarkerGroupCache.clear()
|
||||||
poiDataCache.clear()
|
poiDataCache.clear()
|
||||||
poiDisplayOffsetYCache.clear()
|
poiCoordinateDiagnosticsKeys.clear()
|
||||||
poiDisplayDiagnosticsKeys.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearPoiGroupChildren = () => {
|
const clearPoiGroupChildren = () => {
|
||||||
@@ -2572,22 +2475,8 @@ const clearRoutePreview = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRouteFloorOffsetY = (floorId: string) => {
|
const routePointToVector = (position: [number, number, number]) => (
|
||||||
if (activeView.value !== 'multi' || !activeModel) return 0
|
new THREE.Vector3(position[0], position[1], position[2])
|
||||||
|
|
||||||
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 getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
|
const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
|
||||||
@@ -2677,7 +2566,7 @@ const renderRouteEndpoint = (
|
|||||||
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
|
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
|
||||||
|
|
||||||
const marker = createRouteMarkerSprite(label, color, markerSize)
|
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)
|
routeGroup.add(marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2688,7 +2577,7 @@ const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number
|
|||||||
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
|
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
|
||||||
.forEach((point) => {
|
.forEach((point) => {
|
||||||
const marker = createRouteMarkerSprite('换', '#8a5cf6', markerSize * 0.72)
|
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)
|
routeGroup!.add(marker)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2746,7 +2635,7 @@ const renderRoutePreview = () => {
|
|||||||
if (segment.points.length < 2) return
|
if (segment.points.length < 2) return
|
||||||
|
|
||||||
targetRouteGroup.add(createRouteLine(
|
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
|
activeView.value === 'floor' ? 1 : 0.88
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -3477,6 +3366,7 @@ const captureCameraSnapshot = (): CameraSnapshot => {
|
|||||||
const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
|
const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
|
||||||
if (!camera || !controls) return
|
if (!camera || !controls) return
|
||||||
|
|
||||||
|
cameraSnapshotRestoreCount += 1
|
||||||
cancelCameraTween()
|
cancelCameraTween()
|
||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
isProgrammaticCameraChange = false
|
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 = () => ({
|
const getVisualStabilityReport = () => ({
|
||||||
camera: serializeCameraSnapshot(captureCameraSnapshot()),
|
camera: serializeCameraSnapshot(captureCameraSnapshot()),
|
||||||
modelRoot: getModelRootTransformAudit(activeModel),
|
modelRoot: getModelRootTransformAudit(activeModel),
|
||||||
anchors: getProjectedBuildingAnchors(),
|
anchors: getProjectedBuildingAnchors(),
|
||||||
activeView: activeView.value,
|
activeView: activeView.value,
|
||||||
floorId: currentFloor.value,
|
floorId: currentFloor.value,
|
||||||
isCameraTweening: Boolean(cameraTween)
|
isCameraTweening: Boolean(cameraTween),
|
||||||
|
poiFocusCameraAnimationCount,
|
||||||
|
cameraSnapshotRestoreCount,
|
||||||
|
focus: getVisualFocusReport()
|
||||||
})
|
})
|
||||||
|
|
||||||
const installVisualStabilityDiagnostics = () => {
|
const installVisualStabilityDiagnostics = () => {
|
||||||
@@ -3633,7 +3540,25 @@ const installVisualStabilityDiagnostics = () => {
|
|||||||
})),
|
})),
|
||||||
switchFloor: handleFloorChange,
|
switchFloor: handleFloorChange,
|
||||||
showOverview,
|
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()
|
const markerSize = getPoiMarkerSize()
|
||||||
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
||||||
activeFocusLabelSprite.frustumCulled = false
|
activeFocusLabelSprite.frustumCulled = false
|
||||||
activeFocusLabelSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusLabelLiftFactor))
|
activeFocusLabelSprite.position.copy(displayPosition)
|
||||||
updateFocusLabelScale()
|
updateFocusLabelScale()
|
||||||
poiGroup.add(activeFocusLabelSprite)
|
poiGroup.add(activeFocusLabelSprite)
|
||||||
}
|
}
|
||||||
@@ -4643,7 +4568,7 @@ const showFocusPoiBase = (poi: RenderPoi) => {
|
|||||||
const markerSize = getPoiMarkerSize()
|
const markerSize = getPoiMarkerSize()
|
||||||
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
|
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
|
||||||
activeFocusBaseSprite.frustumCulled = false
|
activeFocusBaseSprite.frustumCulled = false
|
||||||
activeFocusBaseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusBaseLiftFactor))
|
activeFocusBaseSprite.position.copy(displayPosition)
|
||||||
poiGroup.add(activeFocusBaseSprite)
|
poiGroup.add(activeFocusBaseSprite)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4656,7 +4581,7 @@ const showFocusPoiPulse = (poi: RenderPoi) => {
|
|||||||
const markerSize = getPoiMarkerSize()
|
const markerSize = getPoiMarkerSize()
|
||||||
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
|
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
|
||||||
activeFocusPulseSprite.frustumCulled = false
|
activeFocusPulseSprite.frustumCulled = false
|
||||||
activeFocusPulseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusPulseLiftFactor))
|
activeFocusPulseSprite.position.copy(displayPosition)
|
||||||
poiGroup.add(activeFocusPulseSprite)
|
poiGroup.add(activeFocusPulseSprite)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4800,13 +4725,13 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
|
|||||||
if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null
|
if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null
|
||||||
|
|
||||||
const poi = createPoiFromFocusRequest(request)
|
const poi = createPoiFromFocusRequest(request)
|
||||||
const displayPosition = poi ? getPoiDisplayPosition(poi, request.floorId) : null
|
const displayPosition = poi ? getPoiDisplayPosition(poi) : null
|
||||||
if (!poi || !displayPosition) return null
|
if (!poi || !displayPosition) return null
|
||||||
|
|
||||||
const markerSize = getPoiMarkerSize()
|
const markerSize = getPoiMarkerSize()
|
||||||
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
||||||
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
|
sprite.position.copy(displayPosition)
|
||||||
hitTarget.position.copy(sprite.position)
|
hitTarget.position.copy(displayPosition)
|
||||||
poiGroup.add(sprite)
|
poiGroup.add(sprite)
|
||||||
poiGroup.add(hitTarget)
|
poiGroup.add(hitTarget)
|
||||||
|
|
||||||
@@ -4826,14 +4751,14 @@ const createPoiMarkerGroup = (
|
|||||||
group.userData.displayMode = displayMode
|
group.userData.displayMode = displayMode
|
||||||
|
|
||||||
pois.forEach((poi) => {
|
pois.forEach((poi) => {
|
||||||
const displayPosition = getPoiDisplayPosition(poi, floorId)
|
const displayPosition = getPoiDisplayPosition(poi)
|
||||||
if (!displayPosition) return
|
if (!displayPosition) return
|
||||||
|
|
||||||
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
|
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
|
||||||
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
|
sprite.position.copy(displayPosition)
|
||||||
hitTarget.position.copy(sprite.position)
|
hitTarget.position.copy(displayPosition)
|
||||||
if (labelSprite) {
|
if (labelSprite) {
|
||||||
labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
|
labelSprite.position.copy(displayPosition)
|
||||||
labelSprite.visible = false
|
labelSprite.visible = false
|
||||||
}
|
}
|
||||||
sprite.visible = isPoiIncludedByVisibleFilter(poi)
|
sprite.visible = isPoiIncludedByVisibleFilter(poi)
|
||||||
@@ -4877,8 +4802,7 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
|||||||
poiGroupCurrentFloor: poiGroup.userData.currentFloor,
|
poiGroupCurrentFloor: poiGroup.userData.currentFloor,
|
||||||
selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value),
|
selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value),
|
||||||
markerSize: roundDiagnosticNumber(entry.markerSize),
|
markerSize: roundDiagnosticNumber(entry.markerSize),
|
||||||
displayOffsetY: roundDiagnosticNumber(entry.displayOffsetY),
|
coordinateSpace: 'GLB_METER'
|
||||||
visualLiftY: roundDiagnosticNumber(entry.markerSize * poiMarkerVisualLiftFactor)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4945,9 +4869,9 @@ const prepareFloorPOIs = async (
|
|||||||
renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
||||||
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
||||||
})
|
})
|
||||||
const displayOffsetY = markerModel
|
if (markerModel) {
|
||||||
? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois)
|
recordPoiCoordinateDiagnostics(floor.floorId, markerModel, validPois)
|
||||||
: getCachedPoiDisplayOffsetY(floor.floorId)
|
}
|
||||||
const markerSize = getPoiMarkerSizeForModel(markerModel)
|
const markerSize = getPoiMarkerSizeForModel(markerModel)
|
||||||
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
|
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
|
||||||
const entry: PoiMarkerCacheEntry = {
|
const entry: PoiMarkerCacheEntry = {
|
||||||
@@ -4956,7 +4880,6 @@ const prepareFloorPOIs = async (
|
|||||||
pois: validPois,
|
pois: validPois,
|
||||||
group: markerGroup,
|
group: markerGroup,
|
||||||
markerSize,
|
markerSize,
|
||||||
displayOffsetY,
|
|
||||||
rawPoiCount: floorPois.length,
|
rawPoiCount: floorPois.length,
|
||||||
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||||
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
||||||
@@ -5232,30 +5155,25 @@ const isSceneReadyForTargetFocus = () => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const focusCameraOnPoi = (poi: RenderPoi) => {
|
const focusCameraOnPoi = (poi: RenderPoi) => {
|
||||||
const displayPosition = getPoiDisplayPosition(poi)
|
const target = getPoiDisplayPosition(poi)
|
||||||
if (!camera || !controls || !displayPosition) return
|
if (!camera || !controls || !target) return
|
||||||
|
|
||||||
const target = displayPosition.clone()
|
const currentOffset = camera.position.clone().sub(controls.target)
|
||||||
const modelSize = activeModel
|
const currentDistance = currentOffset.length()
|
||||||
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
|
const direction = currentDistance > 1e-6
|
||||||
: new THREE.Vector3(80, 80, 80)
|
? currentOffset.multiplyScalar(1 / currentDistance)
|
||||||
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
|
: new THREE.Vector3(0.72, 0.58, 1).normalize()
|
||||||
const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
|
const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
|
||||||
const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4)
|
const minimumDistance = Math.max(controls.minDistance, 1)
|
||||||
const distance = Math.min(
|
const maximumDistance = Math.max(minimumDistance, controls.maxDistance)
|
||||||
Math.max(maxDim * focusDistanceFactor, 28),
|
const distance = THREE.MathUtils.clamp(
|
||||||
Math.max(maxDim * focusMaxDistanceFactor, 80)
|
currentDistance * focusDistanceFactor,
|
||||||
|
minimumDistance,
|
||||||
|
maximumDistance
|
||||||
)
|
)
|
||||||
const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
|
|
||||||
const nextPosition = target.clone().add(direction.multiplyScalar(distance))
|
const nextPosition = target.clone().add(direction.multiplyScalar(distance))
|
||||||
|
|
||||||
camera.near = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1200)
|
moveCameraTo(nextPosition, target, { reason: 'poi-focus' })
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||||
@@ -5387,6 +5305,8 @@ const init3DScene = async () => {
|
|||||||
firstModelLoadStartedAt = getNow()
|
firstModelLoadStartedAt = getNow()
|
||||||
firstModelVisibleReported = false
|
firstModelVisibleReported = false
|
||||||
initialModelSettled = false
|
initialModelSettled = false
|
||||||
|
poiFocusCameraAnimationCount = 0
|
||||||
|
cameraSnapshotRestoreCount = 0
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
selectedPOI.value = null
|
selectedPOI.value = null
|
||||||
@@ -5570,6 +5490,8 @@ const disposeScene = () => {
|
|||||||
clearModelAdjustReportTimer()
|
clearModelAdjustReportTimer()
|
||||||
hasPendingManualModelAdjustment = false
|
hasPendingManualModelAdjustment = false
|
||||||
cameraTween = null
|
cameraTween = null
|
||||||
|
poiFocusCameraAnimationCount = 0
|
||||||
|
cameraSnapshotRestoreCount = 0
|
||||||
isProgrammaticCameraChange = false
|
isProgrammaticCameraChange = false
|
||||||
activeAutoSwitchInputSource = 'gesture'
|
activeAutoSwitchInputSource = 'gesture'
|
||||||
resetInteractionGateState()
|
resetInteractionGateState()
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ interface VisualReport {
|
|||||||
activeView: string
|
activeView: string
|
||||||
floorId: string
|
floorId: string
|
||||||
isCameraTweening: boolean
|
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 {
|
interface FloorReport {
|
||||||
@@ -38,11 +46,27 @@ interface FloorReport {
|
|||||||
modelMatchKeys: string[]
|
modelMatchKeys: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PoiFocusCandidate {
|
||||||
|
poiId: string
|
||||||
|
floorId: string
|
||||||
|
name: string
|
||||||
|
positionGltf: [number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
interface VisualStabilityApi {
|
interface VisualStabilityApi {
|
||||||
getReport: () => VisualReport
|
getReport: () => VisualReport
|
||||||
getFloors: () => FloorReport[]
|
getFloors: () => FloorReport[]
|
||||||
switchFloor: (floorId: string) => Promise<void>
|
switchFloor: (floorId: string) => Promise<void>
|
||||||
showOverview: () => Promise<void>
|
showOverview: () => Promise<void>
|
||||||
|
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
|
||||||
|
focusTargetPoi: (request: {
|
||||||
|
requestId: number | string
|
||||||
|
poiId: string
|
||||||
|
floorId: string
|
||||||
|
name?: string
|
||||||
|
positionGltf: [number, number, number]
|
||||||
|
}) => Promise<unknown>
|
||||||
|
clearTargetFocus: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getApiState = async (page: Page) => {
|
const getApiState = async (page: Page) => {
|
||||||
@@ -219,6 +243,43 @@ const resolveRequiredFloorId = (floors: FloorReport[], requested: string) => {
|
|||||||
return floor!.floorId
|
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) => {
|
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await Promise.all([
|
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 lMinus2 = resolveRequiredFloorId(state!.floors, 'L-2')
|
||||||
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
|
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
|
||||||
const l1Point5 = resolveRequiredFloorId(state!.floors, 'L1.5')
|
const l1Point5 = resolveRequiredFloorId(state!.floors, 'L1.5')
|
||||||
const l2 = resolveRequiredFloorId(state!.floors, 'L2')
|
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
|
||||||
const l3 = resolveRequiredFloorId(state!.floors, 'L3')
|
const l4 = resolveRequiredFloorId(state!.floors, 'L4')
|
||||||
const l5 = resolveRequiredFloorId(state!.floors, 'L5')
|
const l5 = resolveRequiredFloorId(state!.floors, 'L5')
|
||||||
|
|
||||||
await switchFloor(page, testInfo, l1, 'exterior-l1')
|
await switchFloor(page, testInfo, l1, 'exterior-l1')
|
||||||
await switchFloor(page, testInfo, l2, 'l1-l2')
|
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1')
|
||||||
await switchFloor(page, testInfo, l3, 'l2-l3')
|
await switchFloor(page, testInfo, l4, 'lminus1-l4')
|
||||||
await switchFloor(page, testInfo, l1, 'l3-l1')
|
await switchFloor(page, testInfo, l1, 'l4-l1')
|
||||||
await showOverview(page, testInfo, 'l1-exterior')
|
await showOverview(page, testInfo, 'l1-exterior')
|
||||||
|
|
||||||
await switchFloor(page, testInfo, lMinus2, 'exterior-lminus2')
|
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 showOverview(page, testInfo, 'l1point5-exterior')
|
||||||
|
|
||||||
await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit')
|
await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit')
|
||||||
await switchFloor(page, testInfo, l2, 'l1-l2-auto-exit')
|
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-auto-exit')
|
||||||
await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-button-exit', 'button')
|
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-button-exit', 'button')
|
||||||
|
|
||||||
await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit')
|
await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit')
|
||||||
await switchFloor(page, testInfo, l2, 'l1-l2-wheel-exit')
|
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-wheel-exit')
|
||||||
await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-wheel-exit', 'wheel')
|
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-wheel-exit', 'wheel')
|
||||||
|
|
||||||
await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit')
|
await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit')
|
||||||
await switchFloor(page, testInfo, l2, 'l1-l2-touch-exit')
|
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-touch-exit')
|
||||||
await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-touch-exit', 'touch')
|
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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
|
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
|
||||||
const mapShell = wrapper.getComponent(GuideMapShellStub)
|
const mapShell = wrapper.getComponent(GuideMapShellStub)
|
||||||
expect(sheet.text()).toContain('二层卫生间')
|
expect(sheet.text()).toContain('二层卫生间')
|
||||||
expect(sheet.text()).toContain('类别卫生间')
|
expect(sheet.text()).toContain('类别洗手间')
|
||||||
expect(sheet.text()).toContain('楼层2F')
|
expect(sheet.text()).toContain('楼层2F')
|
||||||
expect(sheet.text()).not.toContain('可无障碍到达')
|
expect(sheet.text()).not.toContain('可无障碍到达')
|
||||||
expect(sheet.text()).not.toContain('展示点位')
|
expect(sheet.text()).not.toContain('展示点位')
|
||||||
|
|||||||
Reference in New Issue
Block a user