修复跨楼层点位聚焦视觉跳动
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-14 01:16:31 +08:00
parent 231ecb465e
commit abaeda189e
3 changed files with 296 additions and 240 deletions

View File

@@ -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<string, number>
}
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<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
const poiDisplayOffsetYCache = new Map<string, number>()
const poiDisplayDiagnosticsKeys = new Set<string>()
const poiCoordinateDiagnosticsKeys = new Set<string>()
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<typeof setTimeout> | 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<ReturnType<typeof getAmbientLabelBounds>> = []
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()