Files
frontend-miniapp/src/components/map/ThreeMap.vue
lyf eaec80bdc3
Some checks failed
CI / verify (push) Has been cancelled
合并展示馆内空间点位和业务点位
2026-07-07 04:06:38 +08:00

5198 lines
151 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="three-map-container">
<view ref="containerRef" class="three-canvas-wrapper"></view>
<view v-if="isLoading && !loadError" class="loading-overlay">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">{{ loadingMessage }}</text>
<view class="loading-progress">
<view class="loading-bar" :style="{ width: `${loadingProgress}%` }"></view>
</view>
</view>
</view>
<view v-else-if="loadError" class="loading-overlay error-overlay">
<view class="loading-content error-content">
<text class="error-title">3D 模型加载失败</text>
<text class="error-text">{{ loadingMessage }}</text>
<view class="retry-btn" @tap="retryLoad">
<text class="retry-text">重新加载</text>
</view>
</view>
</view>
<view v-if="showControls && floors.length" class="map-toolbar">
<view class="overview-btn" :class="{ active: activeView === 'multi' }" @tap="showMultiFloor">
<text class="overview-text">多层</text>
</view>
</view>
<FloorSwitcher
v-if="showControls && floors.length"
:floors="floors"
:current-floor="currentFloor"
@floor-change="handleFloorChange"
/>
<view v-if="showControls && selectedPOI" class="poi-detail-popup">
<view class="poi-content">
<text class="poi-name">{{ selectedPOI.name }}</text>
<text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import FloorSwitcher from './FloorSwitcher.vue'
import type {
GuideModelFloorAsset,
GuideModelRenderPackage,
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import type {
GuideRouteEndpoint,
GuideRouteFloorSegment,
GuideRouteResult
} from '@/domain/museum'
import {
compareFloorsTopToBottom
} from '@/domain/guideFloor'
import {
guideModelLoadManager,
type GuideModelResourceSet
} from '@/services/model/GuideModelLoadManager'
type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
type TouchGestureMode = 'orbit' | 'pan'
type ZoomCameraSource = 'button' | 'gesture'
interface InitialModelProgressEvent {
progress: number
message: string
view: ViewMode
floorId?: string
elapsedMs?: number
}
const CANVAS_FONT_FAMILY = '"鸿蒙黑体", "HarmonyOS Sans SC", "HarmonyOS Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
const FLOOR_CAMERA_DISTANCE_FACTOR = 0.78
const SGS_VISUAL_RENDER_CONFIG = {
sceneBackground: 0xeceff1,
camera: {
fov: 42,
near: 0.1,
far: 20000,
initialPosition: new THREE.Vector3(20, 20, 20)
},
renderer: {
dprCap: 1.5,
powerPreference: 'high-performance' as WebGLPowerPreference,
antialias: true,
alpha: false,
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 1.0,
shadows: false
},
lights: {
hemisphere: {
skyColor: 0xffffff,
groundColor: 0xb0bec5,
intensity: 1.7
},
key: {
color: 0xffffff,
intensity: 2.2,
position: new THREE.Vector3(80, 120, 80)
},
fill: {
color: 0xffffff,
intensity: 0.55,
position: new THREE.Vector3(-60, 70, -50)
}
},
controls: {
minDistance: 10,
maxDistance: 600,
minPolarAngle: 0,
maxPolarAngle: Math.PI / 2 - 0.05,
dampingFactor: 0.06
},
framing: {
overviewScreenOffsetRatio: new THREE.Vector2(-0.045, -0.095),
floorScreenOffsetRatio: new THREE.Vector2(0, -0.055)
}
} as const
interface CameraFitOptions {
distanceFactor?: number
targetOffsetRatio?: THREE.Vector3
screenOffsetRatio?: THREE.Vector2
up?: THREE.Vector3
}
interface LoadFloorOptions {
preserveCurrentSceneUntilReady?: boolean
suppressProgress?: boolean
detachPoiBeforeLoad?: boolean
allowSameFloorReload?: boolean
}
interface LoadModelOptions {
suppressProgress?: boolean
}
interface CameraTweenState {
fromPosition: THREE.Vector3
toPosition: THREE.Vector3
fromTarget: THREE.Vector3
toTarget: THREE.Vector3
startedAt: number
durationMs: number
onComplete?: () => void
}
type FloorIndexItem = GuideModelFloorAsset
interface FloorOption {
id: string
label: string
}
type RenderPoi = GuideRenderPoi
type PoiDisplayMode = 'core' | 'balanced' | 'detail'
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
interface PoiSpriteUserData {
poi?: RenderPoi
baseScale?: number
hitTargetBaseScale?: number
visibleMarker?: THREE.Sprite
hitTarget?: THREE.Sprite
labelSprite?: THREE.Sprite
feedbackUntil?: number
labelBaseScaleX?: number
labelBaseScaleY?: number
isPoiLabel?: boolean
isPoiPulse?: boolean
isPoiBase?: boolean
isPoiHitTarget?: boolean
isCorePoi?: boolean
}
interface FocusHallMaterialState {
material: THREE.Material
color?: THREE.Color
emissive?: THREE.Color
emissiveIntensity?: number
opacity: number
transparent: boolean
}
interface TargetPoiFocusRequest {
requestId: number | string
poiId: string
name?: string
floorId: string
floorLabel?: string
primaryCategoryZh?: string
positionGltf?: [number, number, number]
sourceObjectName?: string
}
interface TargetPoiFocusResult {
requestId: number | string
poiId: string
floorId: string
status: 'focused' | 'missing' | 'error'
message?: string
}
interface MultiFloorModelItem {
floor: FloorIndexItem
label: string
model: THREE.Object3D
size: THREE.Vector3
}
interface ReusableModelResources {
geometries: Set<THREE.BufferGeometry>
materials: Set<THREE.Material>
textures: Set<THREE.Texture>
}
interface PoiMarkerCacheEntry {
floorId: string
displayMode: PoiDisplayMode
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
poiEntry: PoiMarkerCacheEntry | null
ownsModel: boolean
cacheAsSharedModel: boolean
protectedResources?: ReusableModelResources
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
const props = withDefaults(defineProps<{
assetBaseUrl?: string
modelSource: GuideModelSource
initialFloorId?: string
initialView?: ViewMode
showControls?: boolean
showPoi?: boolean
targetFocus?: TargetPoiFocusRequest | null
touchGestureMode?: TouchGestureMode
targetFocusDistanceFactor?: number
routePreview?: GuideRouteResult | null
showRoute?: boolean
autoSwitch?: boolean
disableAutoExit?: boolean
autoSwitchThresholdLow?: number
autoSwitchThresholdHigh?: number
autoSwitchCooldown?: number
}>(), {
assetBaseUrl: '',
initialFloorId: 'L1',
initialView: 'overview',
showControls: true,
showPoi: false,
targetFocus: null,
touchGestureMode: 'pan',
targetFocusDistanceFactor: 0.36,
routePreview: null,
showRoute: false,
autoSwitch: true,
disableAutoExit: false,
autoSwitchThresholdLow: 1.45,
autoSwitchThresholdHigh: 3.8,
autoSwitchCooldown: 2000
})
const emit = defineEmits<{
floorChange: [floorId: string]
poiClick: [poi: RenderPoi]
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }]
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
initialModelProgress: [event: InitialModelProgressEvent]
initialModelReady: [event: { view: ViewMode; floorId?: string; elapsedMs?: number }]
initialModelFailed: [event: { view: ViewMode; floorId?: string; message: string; elapsedMs?: number }]
}>()
const containerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const loadingProgress = ref(0)
const loadingMessage = ref('正在读取馆内导览资源...')
const activeView = ref<ViewMode>(props.initialView)
const currentFloor = ref(props.initialFloorId)
const selectedPOI = ref<RenderPoi | null>(null)
const activeFocusPoiId = ref('')
const floorIndex = ref<FloorIndexItem[]>([])
const renderPackage = ref<GuideModelRenderPackage | null>(null)
const floors = computed<FloorOption[]>(() => (
[...floorIndex.value]
.sort(compareFloorsTopToBottom)
.map((floor) => ({
id: floor.floorId,
label: formatFloorLabel(floor.floorId)
}))
))
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let renderer: THREE.WebGLRenderer | null = null
let controls: OrbitControls | null = null
let loader: GLTFLoader | null = null
let dracoLoader: DRACOLoader | null = null
let activeModel: THREE.Object3D | null = null
let poiGroup: THREE.Group | null = null
let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const poiDisplayOffsetYCache = new Map<string, number>()
const poiDisplayDiagnosticsKeys = new Set<string>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
let activeFocusHallGlowMesh: THREE.Mesh | null = null
let activeFocusHallMaterialStates: FocusHallMaterialState[] = []
let animationId = 0
let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = Promise.resolve()
let modelLoadVersion = 0
let pendingRequestedFloorId = ''
let floorSwitchLoadToken = 0
let floorSwitchRequestedFloorId = ''
let isFloorSwitching = false
let hasLoadedFloorViewOnce = false
let pointerDownState: { x: number; y: number } | null = null
let cachedOverviewModel: THREE.Object3D | null = null
let cachedSharedModelUrl = ''
let autoSwitchOverviewMaxDim = 0
// 自动切换状态:建筑外观积极进入,馆内锁定后再二段退出。
let isAutoSwitchLocked = false
let lastAutoSwitchTime = 0
let lastControlDistance = 0
let activeControlGestureStartedAt = 0
let indoorEntryGestureLocked = false
let indoorContextLockedUntil = 0
let floorExitCandidateStartedAt = 0
let floorExitCandidateBaseDistance = 0
let floorExitZoomOutAccumulatedRatio = 0
let autoSwitchTemporarilyDisabled = false
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
let overviewEntryIntentStartedAt = 0
let overviewEntryStartDistance = 0
let overviewEntryZoomInRounds = 0
let overviewWheelZoomInRounds = 0
let overviewWheelZoomIntentUntil = 0
let overviewWheelAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
let cameraTween: CameraTweenState | null = null
let adjacentPreloadSeq = 0
let firstModelLoadStartedAt = 0
let firstModelVisibleReported = false
let initialModelSettled = false
const cameraTweenEnabled = true
const cameraTweenDurationMs = 480
const poiTapFeedbackDurationMs = 180
const poiHitTargetScaleMultiplier = 4.8
const poiHitTargetCoreScaleMultiplier = 5.4
const poiScreenHitRadiusPx = 64
const poiScreenHitRadiusCorePx = 76
const poiAmbientLabelHallSpacingPx = 92
const poiAmbientLabelServiceSpacingPx = 72
const indoorContextLockMs = 5000
const indoorGestureExitGraceMs = 900
const overviewEntryIntentWindowMs = 1200
const overviewEntryMinZoomInRatio = 0.04
const overviewEntryAccumulatedRatio = 0.2
const overviewEntryMinRounds = 2
const overviewEntryMaxRounds = 4
const overviewWheelEntryDelayMs = 180
const floorExitCandidateThresholdMultiplier = 1.12
const floorExitConfirmThresholdMultiplier = 1.34
const floorExitZoomOutIntentMinDeltaRatio = 0.015
const floorExitAccumulatedZoomOutRatio = 0.26
const floorExitCandidateHoldMs = 900
const modelLoadRetryDelaysMs = [300, 900]
const adjacentPreloadDelayMs = 900
const poiMarkerVisualLiftFactor = 0.92
const poiAmbientLabelLiftFactor = 2.1
const poiFocusLabelLiftFactor = 2.72
const poiFocusBaseLiftFactor = 0.18
const poiFocusPulseLiftFactor = 0.34
type ThreeMapDiagnosticEvent =
| 'init-start'
| 'package-ready'
| 'model-load-start'
| 'model-load-complete'
| 'model-load-failed'
| 'first-model-visible'
| 'poi-background-start'
| 'poi-background-ready'
| 'poi-filter-diagnostics'
| 'poi-marker-render-diagnostics'
| 'poi-hit-diagnostics'
| 'adjacent-preload-skip'
const getNow = () => (
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now()
)
const isThreeMapDiagnosticsEnabled = () => {
if (!import.meta.env.DEV) return false
if (typeof window === 'undefined') return true
const diagnosticsWindow = window as unknown as {
__GUIDE_3D_MODEL_DIAGNOSTICS__?: boolean
}
return diagnosticsWindow.__GUIDE_3D_MODEL_DIAGNOSTICS__ !== false
}
const recordThreeMapDiagnostic = (
event: ThreeMapDiagnosticEvent,
payload: Record<string, unknown>
) => {
if (typeof window === 'undefined') return
const diagnosticsWindow = window as unknown as {
__GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{
source: string
event: string
payload: Record<string, unknown>
}>
}
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= []
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({
source: 'ThreeMap',
event,
payload
})
}
const stringifyDiagnosticPayload = (payload: Record<string, unknown>) => {
try {
return JSON.stringify(payload)
} catch {
return '[unserializable]'
}
}
const logThreeMapDiagnostic = (
event: ThreeMapDiagnosticEvent,
payload: Record<string, unknown> = {}
) => {
if (!isThreeMapDiagnosticsEnabled()) return
recordThreeMapDiagnostic(event, payload)
console.debug(`[ThreeMap] ${event} ${stringifyDiagnosticPayload(payload)}`)
}
const syncControlInteractionOptions = () => {
if (!controls) return
controls.enableRotate = false
controls.enablePan = true
controls.screenSpacePanning = true
controls.panSpeed = 0.85
controls.mouseButtons.LEFT = THREE.MOUSE.PAN
controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
controls.mouseButtons.RIGHT = THREE.MOUSE.PAN
controls.touches.ONE = THREE.TOUCH.PAN
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN
}
const corePoiCategories = new Set([
'poi',
'touring_poi',
'exhibition_hall',
'exhibition_hall_entrance',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service'
])
const poiCategoryPriority: Record<string, number> = {
target_preview: 100,
exhibition_hall: 96,
exhibition_hall_entrance: 92,
touring_poi: 90,
poi: 86,
accessibility_special_service: 88,
basic_service_facility: 82,
business_poi: 80,
transport_circulation: 76,
operation_experience: 46
}
const isHallPoi = (poi: RenderPoi) => (
poi.kind === 'hall'
|| poi.primaryCategory === 'exhibition_hall'
|| poi.primaryCategory === 'touring_poi'
)
const isServiceFacilityPoi = (poi: RenderPoi) => (
poi.primaryCategory === 'basic_service_facility'
|| poi.primaryCategory === 'accessibility_special_service'
|| poi.primaryCategory === 'business_poi'
)
const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'
const poiGlyphMap: Record<string, string> = {
poi: '点',
accessible_restroom: '无',
accessibility: '无',
restroom: '卫',
water: '水',
rest_area: '休',
elevator: '梯',
stair: '梯',
experience: '展',
theater: '演',
restaurant: '餐',
cafe: '咖',
shop: '购',
bookstore: '书',
cultural_shop: '文',
business: '商',
exhibition_hall: '展',
hall_entrance: '门',
entrance_exit: '门',
target_preview: '位'
}
const getContainerElement = () => {
const container = containerRef.value
if (!container) return null
if (typeof HTMLElement === 'undefined') return null
if (container instanceof HTMLElement) {
return container
}
const element = '$el' in container ? container.$el : null
return element instanceof HTMLElement ? element : null
}
const formatFloorLabel = (floorId: string) => (
floorIndex.value.find((floor) => floor.floorId === floorId)?.label || floorId
)
const floorExteriorNameKeywords = [
'外墙',
'玻璃幕墙',
'楼顶',
'屋顶',
'馆外'
]
const overviewBuildingSubjectNameKeywords = [
'外墙',
'幕墙',
'玻璃幕墙',
'楼顶',
'屋顶',
'外墙装饰'
]
const isRenderableObject = (object: THREE.Object3D) => (
object instanceof THREE.Mesh
|| object instanceof THREE.Line
|| object instanceof THREE.Points
)
const getModelNodeNames = (object: THREE.Object3D) => {
const names: string[] = []
let current: THREE.Object3D | null = object
while (current) {
if (current.name) {
names.push(current.name)
}
current = current.parent
}
return names
}
const normalizeModelMatchKey = (value: string) => (
value
.trim()
.toLowerCase()
.replace(/[\s_\-./\\]/g, '')
)
const getPoiModelMatchKeys = (poi: RenderPoi) => (
[
poi.sourceObjectName,
`${poi.floorId}_${poi.name}`,
poi.name
]
.filter((value): value is string => Boolean(value))
.map(normalizeModelMatchKey)
.filter(Boolean)
)
const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
const keys = getPoiModelMatchKeys(poi)
if (!keys.length) return false
return getModelNodeNames(object)
.map(normalizeModelMatchKey)
.some((name) => keys.some((key) => name === key || name.includes(key)))
}
const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
if (!activeModel) return null
let matchedRoot: THREE.Object3D | null = null
activeModel.traverse((child) => {
if (matchedRoot || child === activeModel) return
const childName = child.name ? normalizeModelMatchKey(child.name) : ''
const keys = getPoiModelMatchKeys(poi)
if (childName && keys.some((key) => childName === key || childName.includes(key))) {
matchedRoot = child
}
})
if (matchedRoot) return matchedRoot
activeModel.traverse((child) => {
if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return
if (isPoiModelNodeMatch(child, poi)) {
matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
}
})
return matchedRoot
}
const isExteriorModelNode = (object: THREE.Object3D) => (
getModelNodeNames(object).some((name) => (
floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
))
)
const isOverviewBuildingSubjectNode = (object: THREE.Object3D) => (
getModelNodeNames(object).some((name) => (
overviewBuildingSubjectNameKeywords.some((keyword) => name.includes(keyword))
))
)
const getOverviewBuildingSubjectBox = (object: THREE.Object3D) => {
const box = new THREE.Box3()
object.updateWorldMatrix(true, true)
object.traverse((child) => {
if (!child.visible || !isRenderableObject(child) || !isOverviewBuildingSubjectNode(child)) return
const childBox = new THREE.Box3().setFromObject(child)
if (!childBox.isEmpty()) {
box.union(childBox)
}
})
return box.isEmpty() ? getObjectBox(object) : box
}
const getKnownFloorMatchEntriesBySpecificity = () => (
floorIndex.value
.flatMap((floor) => [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
.filter((value): value is string => Boolean(value))
.map((key) => ({ key, floorId: floor.floorId })))
.sort((a, b) => b.key.length - a.key.length)
)
const getModelNodeFloorIdByName = (name: string) => {
if (!name) return ''
const normalizedName = normalizeModelMatchKey(name)
return getKnownFloorMatchEntriesBySpecificity().find(({ key }) => (
name === key
|| name.startsWith(`${key}_`)
|| name.startsWith(`${key}中心`)
|| normalizedName === normalizeModelMatchKey(key)
|| normalizedName.startsWith(normalizeModelMatchKey(key))
))?.floorId || ''
}
const getModelNodeFloorId = (object: THREE.Object3D) => (
getModelNodeNames(object)
.map(getModelNodeFloorIdByName)
.find(Boolean) || ''
)
const setModelNodeVisibility = (model: THREE.Object3D, isVisible: (object: THREE.Object3D) => boolean) => {
model.traverse((child) => {
child.visible = child === model || !isRenderableObject(child) || isVisible(child)
})
}
const showAllModelNodes = (model: THREE.Object3D) => {
model.traverse((child) => {
child.visible = true
})
}
const showOnlyFloorModelNodes = (model: THREE.Object3D, floorId: string) => {
let currentFloorRenderableCount = 0
model.traverse((child) => {
if (!isRenderableObject(child)) return
const nodeFloorId = getModelNodeFloorId(child)
if (nodeFloorId === floorId && !isExteriorModelNode(child)) {
currentFloorRenderableCount += 1
}
})
if (!currentFloorRenderableCount) {
setModelNodeVisibility(model, (child) => !isExteriorModelNode(child))
return
}
setModelNodeVisibility(model, (child) => (
getModelNodeFloorId(child) === floorId
&& !isExteriorModelNode(child)
))
}
const applyModelVisibilityForView = (
model: THREE.Object3D,
view: ViewMode,
floorId?: string
) => {
if (view === 'floor' && floorId) {
showOnlyFloorModelNodes(model, floorId)
return
}
showAllModelNodes(model)
}
const getObjectBox = (object: THREE.Object3D) => {
const box = new THREE.Box3()
object.updateWorldMatrix(true, true)
object.traverse((child) => {
if (!child.visible || !isRenderableObject(child)) return
const childBox = new THREE.Box3().setFromObject(child)
if (!childBox.isEmpty()) {
box.union(childBox)
}
})
if (box.isEmpty()) {
return new THREE.Box3().setFromObject(object)
}
return box
}
const getPoiDisplayMode = (): PoiDisplayMode => {
if (activeView.value === 'overview') return 'core'
if (activeView.value === 'multi') return 'balanced'
return 'detail'
}
const getPoiMarkerCacheKey = (floorId: string, displayMode = getPoiDisplayMode()) => `${floorId}:${displayMode}`
const countPoisByCategory = (pois: RenderPoi[]) => pois.reduce<Record<string, number>>((counts, poi) => {
const key = poi.primaryCategory || '<missing>'
counts[key] = (counts[key] || 0) + 1
return counts
}, {})
const summarizeSelectedPoiForDiagnostics = (poi: RenderPoi | null) => (
poi
? {
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: poi.primaryCategory
}
: null
)
const countPoiMarkerObjects = (group: THREE.Group) => {
const counts = {
markerCount: 0,
hitTargetCount: 0,
labelCount: 0
}
group.traverse((child) => {
if (!(child instanceof THREE.Sprite)) return
const userData = child.userData as PoiSpriteUserData
if (userData.isPoiHitTarget) {
counts.hitTargetCount += 1
return
}
if (userData.isPoiLabel) {
counts.labelCount += 1
return
}
if (userData.poi) {
counts.markerCount += 1
}
})
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) => {
if (!poi.positionGltf) return null
const [x, y, z] = poi.positionGltf
return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), 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 = (
floorId: string,
model: THREE.Object3D,
pois: RenderPoi[]
) => {
const modelY = getModelYBounds(model)
const poiY = summarizePoiYDistribution(pois)
let offsetY = 0
let reason = 'no-positioned-poi'
if (poiY) {
const modelTolerance = Math.max(2, modelY.height * 0.35)
const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance
if (overlapsModelRange) {
reason = 'poi-y-overlaps-model-y'
} else {
offsetY = modelY.center - poiY.median
reason = 'align-poi-median-to-model-center'
}
}
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
}
const getPoiGlyph = (poi: RenderPoi) => (
poiGlyphMap[poi.iconType] || poi.name.charAt(0) || '点'
)
const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
const mode = getPoiDisplayMode()
if (mode === 'core') {
return corePoiCategories.has(poi.primaryCategory)
}
if (mode === 'balanced') {
return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi'
}
return true
}
const getPoiSpriteUserData = (sprite: THREE.Sprite) => sprite.userData as PoiSpriteUserData
const isPoiAuxiliarySprite = (sprite: THREE.Sprite) => (
Boolean(
sprite.userData.isPoiLabel
|| sprite.userData.isPoiPulse
|| sprite.userData.isPoiBase
|| sprite.userData.isPoiHitTarget
)
)
const isPoiHitTargetSprite = (child: THREE.Object3D): child is THREE.Sprite => (
child instanceof THREE.Sprite
&& Boolean(child.userData.isPoiHitTarget)
&& Boolean(child.userData.poi)
)
const getActiveModelSpan = () => {
if (!activeModel) return 1
const size = getObjectSize(activeModel)
return Math.max(size.x, size.y, size.z, 1)
}
const getPoiVisibilityTier = (): PoiVisibilityTier => {
if (!controls || !activeModel) return 'full'
const ratio = controls.getDistance() / getActiveModelSpan()
if (ratio >= 0.32) return 'tight'
if (ratio >= 0.2) return 'balanced'
return 'full'
}
const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') {
return true
}
if (activeView.value === 'floor' && isHallPoi(poi)) {
return true
}
if (tier === 'tight') {
return corePoiCategories.has(poi.primaryCategory) && !isTransportPoi(poi)
}
if (tier === 'balanced') {
return !isTransportPoi(poi) && (
corePoiCategories.has(poi.primaryCategory)
|| poi.primaryCategory === 'touring_poi'
)
}
return true
}
const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
if (tier === 'tight') return 5
if (tier === 'balanced') return 9
return Number.POSITIVE_INFINITY
}
const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
if (tier === 'tight') return 116
if (tier === 'balanced') return 78
return 0
}
const getPoiPriority = (poi: RenderPoi) => {
const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20
const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0
const transportPenalty = isTransportPoi(poi) ? 18 : 0
return selectedBoost + hallBoost + categoryPriority - transportPenalty
}
const getPoiLabelDensityTier = (): PoiVisibilityTier => {
if (!controls || !activeModel) return 'full'
const ratio = controls.getDistance() / getActiveModelSpan()
if (ratio >= 0.42) return 'tight'
if (ratio >= 0.26) return 'balanced'
return 'full'
}
const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
isHallPoi(poi)
|| isServiceFacilityPoi(poi)
)
const shouldShowAmbientPoiLabel = (
poi: RenderPoi,
markerVisible: boolean,
densityTier: PoiVisibilityTier
) => {
if (!markerVisible || activeView.value !== 'floor') return false
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false
if (isHallPoi(poi)) return true
if (isTransportPoi(poi)) return false
if (isServiceFacilityPoi(poi)) return densityTier === 'full'
return false
}
const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
if (isHallPoi(poi)) return getPoiPriority(poi) + 120
if (isServiceFacilityPoi(poi)) return getPoiPriority(poi) + 20
return getPoiPriority(poi)
}
const getAmbientLabelScaleBoost = (poi: RenderPoi) => {
if (!controls || !activeModel) return 1
const distanceRatio = controls.getDistance() / getActiveModelSpan()
const baseBoost = Math.min(1.55, Math.max(0.92, distanceRatio / 0.28))
return isHallPoi(poi) ? Math.max(1, baseBoost) : baseBoost
}
const updateAmbientLabelScale = (labelSprite: THREE.Sprite, poi: RenderPoi) => {
const userData = getPoiSpriteUserData(labelSprite)
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
? userData.labelBaseScaleX
: labelSprite.scale.x
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
? userData.labelBaseScaleY
: labelSprite.scale.y
const boost = getAmbientLabelScaleBoost(poi)
labelSprite.scale.set(baseScaleX * boost, baseScaleY * boost, 1)
}
const getProjectedScreenPosition = (object: THREE.Object3D) => {
if (!camera || !renderer) return null
const projected = object.position.clone().project(camera)
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
return null
}
return new THREE.Vector2(
(projected.x + 1) * renderer.domElement.clientWidth * 0.5,
(1 - projected.y) * renderer.domElement.clientHeight * 0.5
)
}
const getAmbientLabelBounds = (center: THREE.Vector2, poi: RenderPoi) => {
const width = isHallPoi(poi) ? 138 : 112
const height = isHallPoi(poi) ? 44 : 34
return {
left: center.x - width * 0.5,
right: center.x + width * 0.5,
top: center.y - height * 0.5,
bottom: center.y + height * 0.5
}
}
const labelBoundsOverlap = (
left: ReturnType<typeof getAmbientLabelBounds>,
right: ReturnType<typeof getAmbientLabelBounds>,
spacing: number
) => (
left.left - spacing < right.right
&& left.right + spacing > right.left
&& left.top - spacing < right.bottom
&& left.bottom + spacing > right.top
)
const updateAmbientPoiLabels = () => {
if (!poiGroup || !camera || !renderer) return
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
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
return poi && labelSprite
? {
marker: sprite,
labelSprite,
poi,
priority: getPoiAmbientLabelPriority(poi)
}
: null
})
.filter((candidate): candidate is {
marker: THREE.Sprite
labelSprite: THREE.Sprite
poi: RenderPoi
priority: number
} => Boolean(candidate))
.sort((left, right) => right.priority - left.priority)
candidates.forEach(({ marker, labelSprite, poi }) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
labelSprite.visible = false
return
}
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
}
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 updatePoiVisibilityByDistance = () => {
if (!poiGroup || !controls || !activeModel || !camera || !renderer) return
const tier = getPoiVisibilityTier()
const limit = getPoiVisibilityLimit(tier)
const spacing = getPoiScreenSpacing(tier)
const acceptedPositions: THREE.Vector2[] = []
let visibleCount = 0
const candidates = getPoiSprites()
.map((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
const screenPosition = sprite.position.clone().project(camera!)
return {
sprite,
poi,
screenPosition: new THREE.Vector2(
(screenPosition.x + 1) * renderer!.domElement.clientWidth * 0.5,
(1 - screenPosition.y) * renderer!.domElement.clientHeight * 0.5
)
}
})
.sort((a, b) => (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0))
candidates.forEach(({ sprite, poi, screenPosition }) => {
if (!poi) {
sprite.visible = false
return
}
const isSelected = poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview'
const isPinnedHall = activeView.value === 'floor' && isHallPoi(poi)
const categoryVisible = shouldShowPoiAtDistance(poi, tier)
const isTooClose = spacing > 0 && acceptedPositions.some((position) => position.distanceTo(screenPosition) < spacing)
const exceedsLimit = Number.isFinite(limit) && visibleCount >= limit
if (!categoryVisible || (!isSelected && !isPinnedHall && (isTooClose || exceedsLimit))) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
if (hitTarget) {
hitTarget.visible = false
}
if (labelSprite) {
labelSprite.visible = false
}
return
}
sprite.visible = true
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
if (hitTarget) {
hitTarget.visible = true
}
acceptedPositions.push(screenPosition)
visibleCount += 1
})
updateAmbientPoiLabels()
}
const refreshPoiVisibilityByDistance = () => {
updatePoiVisibilityByDistance()
}
const handleControlChange = () => {
updatePoiVisibilityByDistance()
checkAutoSwitch()
}
const clearProgrammaticCameraTimer = () => {
if (programmaticCameraTimer) {
clearTimeout(programmaticCameraTimer)
programmaticCameraTimer = null
}
}
const endProgrammaticCameraChangeSoon = (onComplete?: () => void) => {
clearProgrammaticCameraTimer()
programmaticCameraTimer = window.setTimeout(() => {
isProgrammaticCameraChange = false
programmaticCameraTimer = null
onComplete?.()
}, 120)
}
const easeInOutCubic = (t: number) => (
t < 0.5
? 4 * t * t * t
: 1 - ((-2 * t + 2) ** 3) / 2
)
const cancelCameraTween = (options: { manual?: boolean } = {}) => {
if (!cameraTween) return
cameraTween = null
if (options.manual) {
isProgrammaticCameraChange = false
clearProgrammaticCameraTimer()
const distance = controls?.getDistance()
if (typeof distance === 'number' && Number.isFinite(distance)) {
lastControlDistance = distance
}
}
}
const moveCameraTo = (
position: THREE.Vector3,
target: THREE.Vector3,
options: { durationMs?: number; immediate?: boolean; onComplete?: () => void } = {}
) => {
if (!camera || !controls) return
const toPosition = position.clone()
const toTarget = target.clone()
isProgrammaticCameraChange = true
clearProgrammaticCameraTimer()
const nextDistance = toPosition.distanceTo(toTarget)
if (Number.isFinite(nextDistance)) {
lastControlDistance = nextDistance
}
if (!cameraTweenEnabled || options.immediate) {
cameraTween = null
camera.position.copy(toPosition)
controls.target.copy(toTarget)
controls.update()
updatePoiVisibilityByDistance()
endProgrammaticCameraChangeSoon(options.onComplete)
return
}
cameraTween = {
fromPosition: camera.position.clone(),
toPosition,
fromTarget: controls.target.clone(),
toTarget,
startedAt: performance.now(),
durationMs: options.durationMs ?? cameraTweenDurationMs,
onComplete: options.onComplete
}
}
const updateCameraTween = (now: number) => {
if (!cameraTween || !camera || !controls) return
const elapsed = now - cameraTween.startedAt
const progress = Math.min(1, Math.max(0, elapsed / cameraTween.durationMs))
const eased = easeInOutCubic(progress)
camera.position.lerpVectors(cameraTween.fromPosition, cameraTween.toPosition, eased)
controls.target.lerpVectors(cameraTween.fromTarget, cameraTween.toTarget, eased)
controls.update()
updatePoiVisibilityByDistance()
if (progress >= 1) {
camera.position.copy(cameraTween.toPosition)
controls.target.copy(cameraTween.toTarget)
controls.update()
const { onComplete } = cameraTween
cameraTween = null
endProgrammaticCameraChangeSoon(onComplete)
}
}
const updatePoiTapFeedback = (now: number) => {
getPoiSprites().forEach((sprite) => {
const userData = getPoiSpriteUserData(sprite)
const feedbackUntil = userData.feedbackUntil || 0
const poi = userData.poi
const focused = Boolean(poi && poi.id === activeFocusPoiId.value)
if (feedbackUntil <= now) {
if (feedbackUntil) {
userData.feedbackUntil = 0
setPoiSpriteFocusStyle(sprite, focused)
}
return
}
const remaining = Math.max(0, feedbackUntil - now)
const progress = remaining / poiTapFeedbackDurationMs
const baseScale = typeof userData.baseScale === 'number'
? userData.baseScale
: sprite.scale.x
const scaleBoost = userData.isCorePoi ? 1.18 : 1
const selectedBoost = focused ? 1.62 : 1
const tapBoost = 1 + Math.sin(progress * Math.PI) * 0.22
const scale = baseScale * scaleBoost * selectedBoost * tapBoost
sprite.scale.set(scale, scale, scale)
sprite.material.opacity = Math.min(1, focused ? 1 : 0.92 + progress * 0.08)
})
}
const handleControlStart = () => {
activeControlGestureStartedAt = Date.now()
cancelCameraTween({ manual: true })
}
const handleControlEnd = () => {
if (indoorEntryGestureLocked) {
indoorContextLockedUntil = Math.max(indoorContextLockedUntil, Date.now() + indoorGestureExitGraceMs)
indoorEntryGestureLocked = false
}
activeControlGestureStartedAt = 0
updateLastControlDistance()
}
const setProgress = (progress: number, message: string) => {
const normalizedProgress = Math.min(100, Math.max(0, progress))
loadingProgress.value = normalizedProgress
loadingMessage.value = message
if (firstModelLoadStartedAt && !initialModelSettled) {
emit('initialModelProgress', {
progress: normalizedProgress,
message,
view: activeView.value,
floorId: currentFloor.value,
elapsedMs: Math.round(getNow() - firstModelLoadStartedAt)
})
}
}
const markFirstModelVisible = (
view: ViewMode,
payload: Record<string, unknown> = {}
) => {
if (firstModelVisibleReported) return
firstModelVisibleReported = true
window.requestAnimationFrame(() => {
logThreeMapDiagnostic('first-model-visible', {
view,
elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined,
diagnostics: guideModelLoadManager.getDiagnostics(),
...payload
})
})
}
const staleModelLoadMessage = 'STALE_MODEL_LOAD'
const startModelLoad = () => {
modelLoadVersion += 1
return modelLoadVersion
}
const invalidateModelLoads = () => {
modelLoadVersion += 1
pendingRequestedFloorId = ''
floorSwitchLoadToken = 0
floorSwitchRequestedFloorId = ''
isFloorSwitching = false
}
const isCurrentModelLoad = (loadToken: number) => (
loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
)
const createStaleModelLoadError = () => new Error(staleModelLoadMessage)
const isStaleModelLoadError = (error: unknown) => (
error instanceof Error && error.message === staleModelLoadMessage
)
const startFloorContextTransaction = (requestedFloorId: string) => {
const loadToken = startModelLoad()
pendingRequestedFloorId = requestedFloorId
floorSwitchLoadToken = loadToken
floorSwitchRequestedFloorId = requestedFloorId
isFloorSwitching = true
return loadToken
}
const isCurrentFloorContextTransaction = (loadToken: number, requestedFloorId: string) => (
isFloorSwitching
&& floorSwitchLoadToken === loadToken
&& floorSwitchRequestedFloorId === requestedFloorId
&& pendingRequestedFloorId === requestedFloorId
&& isCurrentModelLoad(loadToken)
)
const assertCurrentFloorContextTransaction = (
loadToken: number,
requestedFloorId: string,
staleObject?: THREE.Object3D
) => {
if (isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return
if (staleObject) {
disposeObject(staleObject)
}
throw createStaleModelLoadError()
}
const completeFloorContextTransaction = (loadToken: number, requestedFloorId: string) => {
if (!isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return
isFloorSwitching = false
floorSwitchLoadToken = 0
floorSwitchRequestedFloorId = ''
pendingRequestedFloorId = ''
}
const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => {
if (isCurrentModelLoad(loadToken)) return
if (staleObject) {
disposeObject(staleObject)
}
throw createStaleModelLoadError()
}
const assertActiveFloorModelState = (loadToken: number, requestedFloorId: string) => {
assertCurrentModelLoad(loadToken)
const activeModelFloorId = typeof activeModel?.userData.floorId === 'string'
? activeModel.userData.floorId
: ''
if (
activeView.value !== 'floor'
|| currentFloor.value !== requestedFloorId
|| !activeModel
|| activeModelFloorId !== requestedFloorId
) {
throw new Error(`楼层模型状态不一致:请求 ${requestedFloorId},当前 ${currentFloor.value || '未知'},模型 ${activeModelFloorId || '未知'}`)
}
}
const assertPreparedFloorScene = (
prepared: PreparedFloorScene,
loadToken: number,
expectedFloorId: string
) => {
assertCurrentModelLoad(loadToken, prepared.ownsModel ? prepared.model : undefined)
if (pendingRequestedFloorId !== expectedFloorId) {
if (prepared.ownsModel) {
disposeObject(prepared.model, prepared.protectedResources)
}
throw createStaleModelLoadError()
}
const modelFloorId = typeof prepared.model.userData.floorId === 'string'
? prepared.model.userData.floorId
: ''
if (modelFloorId !== expectedFloorId) {
if (prepared.ownsModel) {
disposeObject(prepared.model, prepared.protectedResources)
}
throw new Error(`楼层模型校验失败:请求 ${expectedFloorId},模型 ${modelFloorId || '未知'}`)
}
const poiFloorId = typeof prepared.poiEntry?.group.userData.floorId === 'string'
? prepared.poiEntry.group.userData.floorId
: ''
if (prepared.poiEntry && (prepared.poiEntry.floorId !== expectedFloorId || poiFloorId !== expectedFloorId)) {
if (prepared.ownsModel) {
disposeObject(prepared.model, prepared.protectedResources)
}
throw new Error(`楼层点位校验失败:请求 ${expectedFloorId},点位 ${poiFloorId || prepared.poiEntry.floorId || '未知'}`)
}
}
const assertCommittedFloorScene = (loadToken: number, expectedFloorId: string) => {
assertActiveFloorModelState(loadToken, expectedFloorId)
const poiFloorId = typeof poiGroup?.userData.floorId === 'string'
? poiGroup.userData.floorId
: ''
const poiCurrentFloor = typeof poiGroup?.userData.currentFloor === 'string'
? poiGroup.userData.currentFloor
: ''
if (
shouldRenderPoiMarkers.value
&& poiGroup
&& (poiFloorId !== expectedFloorId || poiCurrentFloor !== expectedFloorId)
) {
throw new Error(`楼层点位状态不一致:请求 ${expectedFloorId},点位 ${poiFloorId || '未知'},当前 ${poiCurrentFloor || '未知'}`)
}
}
const waitForContainer = async () => {
await nextTick()
for (let index = 0; index < 20; index += 1) {
const container = getContainerElement()
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
return container
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return getContainerElement()
}
const initThree = async () => {
const container = await waitForContainer()
if (!container) {
throw new Error('3D 容器未就绪')
}
scene = new THREE.Scene()
scene.background = new THREE.Color(SGS_VISUAL_RENDER_CONFIG.sceneBackground)
const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight)
camera = new THREE.PerspectiveCamera(
SGS_VISUAL_RENDER_CONFIG.camera.fov,
width / height,
SGS_VISUAL_RENDER_CONFIG.camera.near,
SGS_VISUAL_RENDER_CONFIG.camera.far
)
camera.position.copy(SGS_VISUAL_RENDER_CONFIG.camera.initialPosition)
renderer = new THREE.WebGLRenderer({
antialias: SGS_VISUAL_RENDER_CONFIG.renderer.antialias,
alpha: SGS_VISUAL_RENDER_CONFIG.renderer.alpha,
powerPreference: SGS_VISUAL_RENDER_CONFIG.renderer.powerPreference
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, SGS_VISUAL_RENDER_CONFIG.renderer.dprCap))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = SGS_VISUAL_RENDER_CONFIG.renderer.toneMapping
renderer.toneMappingExposure = SGS_VISUAL_RENDER_CONFIG.renderer.toneMappingExposure
renderer.shadowMap.enabled = SGS_VISUAL_RENDER_CONFIG.renderer.shadows
container.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = SGS_VISUAL_RENDER_CONFIG.controls.dampingFactor
controls.enableZoom = true
controls.minDistance = SGS_VISUAL_RENDER_CONFIG.controls.minDistance
controls.maxDistance = SGS_VISUAL_RENDER_CONFIG.controls.maxDistance
controls.minPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.minPolarAngle
controls.maxPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.maxPolarAngle
syncControlInteractionOptions()
loader = new GLTFLoader()
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/static/three/draco/')
loader.setDRACOLoader(dracoLoader)
poiGroup = new THREE.Group()
poiGroup.name = 'GuideModelPOI'
scene.add(poiGroup)
routeGroup = new THREE.Group()
routeGroup.name = 'GuideModelRoute'
scene.add(routeGroup)
const hemisphereLight = new THREE.HemisphereLight(
SGS_VISUAL_RENDER_CONFIG.lights.hemisphere.skyColor,
SGS_VISUAL_RENDER_CONFIG.lights.hemisphere.groundColor,
SGS_VISUAL_RENDER_CONFIG.lights.hemisphere.intensity
)
scene.add(hemisphereLight)
const keyLight = new THREE.DirectionalLight(
SGS_VISUAL_RENDER_CONFIG.lights.key.color,
SGS_VISUAL_RENDER_CONFIG.lights.key.intensity
)
keyLight.position.copy(SGS_VISUAL_RENDER_CONFIG.lights.key.position)
scene.add(keyLight)
const fillLight = new THREE.DirectionalLight(
SGS_VISUAL_RENDER_CONFIG.lights.fill.color,
SGS_VISUAL_RENDER_CONFIG.lights.fill.intensity
)
fillLight.position.copy(SGS_VISUAL_RENDER_CONFIG.lights.fill.position)
scene.add(fillLight)
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(container)
container.addEventListener('pointerdown', handlePointerDown)
container.addEventListener('pointerup', handlePointerUp)
renderer.domElement.addEventListener('wheel', handleWheelIntent, { passive: true, capture: true })
// 监听控制器变化,用于自动切换和点位可见层级更新
if (controls) {
controls.addEventListener('start', handleControlStart)
controls.addEventListener('end', handleControlEnd)
controls.addEventListener('change', handleControlChange)
}
startRenderLoop()
}
const startRenderLoop = () => {
const render = () => {
if (isDisposed || !renderer || !scene || !camera) return
if (cameraTween) {
updateCameraTween(performance.now())
} else {
controls?.update()
}
updatePoiTapFeedback(performance.now())
updateFocusLabelScale()
renderer.render(scene, camera)
animationId = window.requestAnimationFrame(render)
}
render()
}
const handleResize = () => {
const container = getContainerElement()
if (!container || !camera || !renderer) return
const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight)
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
const materialTextureKeys = [
'map',
'alphaMap',
'aoMap',
'bumpMap',
'normalMap',
'displacementMap',
'roughnessMap',
'metalnessMap',
'emissiveMap',
'specularMap',
'envMap',
'lightMap',
'gradientMap',
'matcap',
'clearcoatMap',
'clearcoatNormalMap',
'clearcoatRoughnessMap',
'sheenColorMap',
'sheenRoughnessMap',
'transmissionMap',
'thicknessMap',
'iridescenceMap',
'iridescenceThicknessMap'
] as const
const collectMaterialTextures = (material: THREE.Material | null | undefined) => {
const textures = new Set<THREE.Texture>()
if (!material) return textures
const materialRecord = material as unknown as Record<string, unknown>
materialTextureKeys.forEach((key) => {
const texture = materialRecord[key]
if (texture instanceof THREE.Texture) {
textures.add(texture)
}
})
return textures
}
const disposeMaterialTextures = (
material: THREE.Material,
protectedResources: ReusableModelResources,
disposedTextures: Set<THREE.Texture>
) => {
collectMaterialTextures(material).forEach((texture) => {
if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) {
texture.dispose()
disposedTextures.add(texture)
}
})
}
const collectReusableModelResources = (object: THREE.Object3D | null): ReusableModelResources => {
const geometries = new Set<THREE.BufferGeometry>()
const materials = new Set<THREE.Material>()
const textures = new Set<THREE.Texture>()
object?.traverse((child) => {
if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
if (child.geometry) {
geometries.add(child.geometry)
}
const childMaterials = Array.isArray(child.material) ? child.material : [child.material]
childMaterials.forEach((material) => {
if (!material) return
materials.add(material)
collectMaterialTextures(material).forEach((texture) => textures.add(texture))
})
}
if (child instanceof THREE.Sprite && child.material) {
materials.add(child.material)
collectMaterialTextures(child.material).forEach((texture) => textures.add(texture))
}
})
return { geometries, materials, textures }
}
const mergeReusableResources = (...resources: ReusableModelResources[]) => {
const merged: ReusableModelResources = {
geometries: new Set(),
materials: new Set(),
textures: new Set()
}
resources.forEach((resource) => {
resource.geometries.forEach((geometry) => merged.geometries.add(geometry))
resource.materials.forEach((material) => merged.materials.add(material))
resource.textures.forEach((texture) => merged.textures.add(texture))
})
return merged
}
const toReusableModelResources = (resources: GuideModelResourceSet): ReusableModelResources => ({
geometries: resources.geometries,
materials: resources.materials,
textures: resources.textures
})
const collectCachedModelResources = () => {
return toReusableModelResources(guideModelLoadManager.collectProtectedResources())
}
const disposeObject = (
object: THREE.Object3D,
protectedResources = collectReusableModelResources(null),
protectCachedModelResources = true
) => {
const effectiveProtectedResources = protectCachedModelResources
? mergeReusableResources(protectedResources, collectCachedModelResources())
: protectedResources
const disposedGeometries = new Set<THREE.BufferGeometry>()
const disposedMaterials = new Set<THREE.Material>()
const disposedTextures = new Set<THREE.Texture>()
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (
child.geometry
&& !effectiveProtectedResources.geometries.has(child.geometry)
&& !disposedGeometries.has(child.geometry)
) {
child.geometry.dispose()
disposedGeometries.add(child.geometry)
}
const materials = Array.isArray(child.material) ? child.material : [child.material]
materials.forEach((material) => {
if (material && !effectiveProtectedResources.materials.has(material) && !disposedMaterials.has(material)) {
disposeMaterialTextures(material, effectiveProtectedResources, disposedTextures)
material.dispose()
disposedMaterials.add(material)
}
})
}
if (child instanceof THREE.Sprite) {
if (!effectiveProtectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
disposeMaterialTextures(child.material, effectiveProtectedResources, disposedTextures)
child.material.dispose()
disposedMaterials.add(child.material)
}
}
if (child instanceof THREE.Line) {
if (
child.geometry
&& !effectiveProtectedResources.geometries.has(child.geometry)
&& !disposedGeometries.has(child.geometry)
) {
child.geometry.dispose()
disposedGeometries.add(child.geometry)
}
const materials = Array.isArray(child.material) ? child.material : [child.material]
materials.forEach((material) => {
if (material && !effectiveProtectedResources.materials.has(material) && !disposedMaterials.has(material)) {
disposeMaterialTextures(material, effectiveProtectedResources, disposedTextures)
material.dispose()
disposedMaterials.add(material)
}
})
}
})
}
const isPoiMarkerSprite = (child: THREE.Object3D): child is THREE.Sprite => (
child instanceof THREE.Sprite
&& Boolean(child.userData.poi)
&& !isPoiAuxiliarySprite(child)
)
const getPoiSprites = () => {
const sprites: THREE.Sprite[] = []
poiGroup?.traverse((child) => {
if (isPoiMarkerSprite(child)) {
sprites.push(child)
}
})
return sprites
}
const getPoiHitTargets = () => {
const sprites: THREE.Sprite[] = []
poiGroup?.traverse((child) => {
if (isPoiHitTargetSprite(child)) {
sprites.push(child)
}
})
return sprites
}
const getPoiScreenPoint = (sprite: THREE.Sprite, rect: DOMRect) => {
if (!camera) return null
const projected = sprite.position.clone().project(camera)
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
return null
}
return new THREE.Vector2(
rect.left + ((projected.x + 1) * rect.width * 0.5),
rect.top + ((1 - projected.y) * rect.height * 0.5)
)
}
const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) => {
const pointer = new THREE.Vector2(event.clientX, event.clientY)
return getPoiSprites()
.filter((sprite) => sprite.visible && sprite.userData.poi)
.map((sprite) => {
const screenPoint = getPoiScreenPoint(sprite, rect)
const poi = sprite.userData.poi as RenderPoi | undefined
if (!screenPoint || !poi) return null
const isCorePoi = Boolean(getPoiSpriteUserData(sprite).isCorePoi)
const hitRadius = isCorePoi ? poiScreenHitRadiusCorePx : poiScreenHitRadiusPx
const distance = screenPoint.distanceTo(pointer)
return {
sprite,
distance,
hitRadius,
priority: getPoiPriority(poi)
}
})
.filter((candidate): candidate is {
sprite: THREE.Sprite
distance: number
hitRadius: number
priority: number
} => Boolean(candidate && candidate.distance <= candidate.hitRadius))
.sort((a, b) => (a.distance - b.distance) || (b.priority - a.priority))[0]?.sprite || null
}
const detachPoiMarkerGroups = () => {
poiMarkerGroupCache.forEach((entry) => {
entry.group.parent?.remove(entry.group)
})
if (poiGroup) {
poiGroup.userData.floorId = ''
poiGroup.userData.currentFloor = ''
}
}
const detachActivePoiLayer = () => {
disposeFocusLabel()
disposeFocusPulse()
disposeFocusBase()
clearFocusHallHighlight()
clearRoutePreview()
detachPoiMarkerGroups()
}
const disposePoiMarkerCache = () => {
detachPoiMarkerGroups()
poiMarkerGroupCache.forEach((entry) => {
disposeObject(entry.group)
})
poiMarkerGroupCache.clear()
poiDataCache.clear()
poiDisplayOffsetYCache.clear()
poiDisplayDiagnosticsKeys.clear()
}
const clearPoiGroupChildren = () => {
if (!poiGroup) return
while (poiGroup.children.length) {
const child = poiGroup.children[0]
poiGroup.remove(child)
if (child instanceof THREE.Group) {
const cachedEntry = [...poiMarkerGroupCache.values()].find((entry) => entry.group === child)
if (cachedEntry) continue
}
disposeObject(child)
}
}
const getMaterialColor = (material: THREE.Material) => {
const candidate = material as THREE.Material & { color?: unknown }
return candidate.color instanceof THREE.Color ? candidate.color : undefined
}
const getMaterialEmissive = (material: THREE.Material) => {
const candidate = material as THREE.Material & { emissive?: unknown }
return candidate.emissive instanceof THREE.Color ? candidate.emissive : undefined
}
const getMaterialEmissiveIntensity = (material: THREE.Material) => {
const candidate = material as THREE.Material & { emissiveIntensity?: unknown }
return typeof candidate.emissiveIntensity === 'number'
? candidate.emissiveIntensity
: undefined
}
const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) => {
const candidate = material as THREE.Material & { emissiveIntensity?: number }
if (typeof candidate.emissiveIntensity === 'number') {
candidate.emissiveIntensity = value
}
}
const clearFocusHallHighlight = () => {
activeFocusHallMaterialStates.forEach((state) => {
const color = getMaterialColor(state.material)
const emissive = getMaterialEmissive(state.material)
if (color && state.color) {
color.copy(state.color)
}
if (emissive && state.emissive) {
emissive.copy(state.emissive)
}
if (typeof state.emissiveIntensity === 'number') {
setMaterialEmissiveIntensity(state.material, state.emissiveIntensity)
}
state.material.opacity = state.opacity
state.material.transparent = state.transparent
state.material.needsUpdate = true
})
activeFocusHallMaterialStates = []
if (activeFocusHallGlowMesh) {
activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh)
const material = activeFocusHallGlowMesh.material
if (!Array.isArray(material)) {
const glowMaterial = material as THREE.MeshBasicMaterial
glowMaterial.map?.dispose()
}
disposeObject(activeFocusHallGlowMesh)
activeFocusHallGlowMesh = null
}
}
const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set<THREE.Material>) => {
if (seenMaterials.has(material)) return
seenMaterials.add(material)
const color = getMaterialColor(material)
const emissive = getMaterialEmissive(material)
const emissiveIntensity = getMaterialEmissiveIntensity(material)
activeFocusHallMaterialStates.push({
material,
color: color?.clone(),
emissive: emissive?.clone(),
emissiveIntensity,
opacity: material.opacity,
transparent: material.transparent
})
const glowColor = new THREE.Color('#e0df00')
if (color) {
color.lerp(glowColor, 0.34)
}
if (emissive) {
emissive.copy(glowColor)
setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76))
}
material.opacity = Math.max(material.opacity, 0.96)
material.transparent = true
material.needsUpdate = true
}
const createFocusHallGlowMesh = (poi: RenderPoi) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition) return null
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const context = canvas.getContext('2d')
if (context) {
const gradient = context.createRadialGradient(128, 128, 16, 128, 128, 118)
gradient.addColorStop(0, 'rgba(224, 223, 0, 0.52)')
gradient.addColorStop(0.45, 'rgba(224, 223, 0, 0.34)')
gradient.addColorStop(0.78, 'rgba(224, 223, 0, 0.16)')
gradient.addColorStop(1, 'rgba(224, 223, 0, 0)')
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = gradient
context.fillRect(0, 0, canvas.width, canvas.height)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.78,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide
})
const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14)
const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72)
const mesh = new THREE.Mesh(geometry, material)
mesh.name = 'GuideFocusHallGlow'
mesh.position.set(displayPosition.x, displayPosition.y + 0.08, displayPosition.z)
mesh.rotation.x = -Math.PI / 2
mesh.renderOrder = 7
return mesh
}
const showFocusHallHighlight = (poi: RenderPoi) => {
clearFocusHallHighlight()
if (!activeModel) return
activeFocusHallGlowMesh = createFocusHallGlowMesh(poi)
if (activeFocusHallGlowMesh) {
poiGroup?.add(activeFocusHallGlowMesh)
}
const modelRoot = findPoiModelRoot(poi)
if (!modelRoot) return
const seenMaterials = new Set<THREE.Material>()
let matchedMeshCount = 0
modelRoot.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.visible) return
matchedMeshCount += 1
const materials = Array.isArray(child.material) ? child.material : [child.material]
materials.forEach((material) => {
if (material) {
applyFocusHallMaterial(material, seenMaterials)
}
})
})
if (!matchedMeshCount) {
clearFocusHallHighlight()
}
}
const disposeCachedOverviewModel = () => {
autoSwitchOverviewMaxDim = 0
if (!cachedOverviewModel) return
disposeObject(cachedOverviewModel)
cachedOverviewModel = null
cachedSharedModelUrl = ''
}
const clearRoutePreview = () => {
activeRoutePreviewSignature = ''
if (!routeGroup) return
while (routeGroup.children.length) {
const child = routeGroup.children[0]
routeGroup.remove(child)
disposeObject(child)
}
}
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 getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
if (activeView.value === 'floor') {
return route.floorSegments
.filter((segment) => segment.floorId === currentFloor.value)
}
return route.floorSegments
}
const createRouteLine = (points: THREE.Vector3[], opacity: number) => {
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const material = new THREE.LineBasicMaterial({
color: 0x1f8f5f,
transparent: true,
opacity,
depthTest: false,
depthWrite: false
})
const line = new THREE.Line(geometry, material)
line.renderOrder = 18
return line
}
const createRouteMarkerSprite = (label: string, color: string, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const context = canvas.getContext('2d')
if (context) {
context.clearRect(0, 0, canvas.width, canvas.height)
context.shadowColor = 'rgba(31, 35, 41, 0.24)'
context.shadowBlur = 16
context.shadowOffsetY = 8
context.beginPath()
context.arc(64, 56, 32, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.shadowColor = 'transparent'
context.lineWidth = 6
context.strokeStyle = '#ffffff'
context.stroke()
context.beginPath()
context.moveTo(64, 116)
context.lineTo(43, 78)
context.lineTo(85, 78)
context.closePath()
context.fillStyle = color
context.fill()
context.lineWidth = 6
context.strokeStyle = '#ffffff'
context.stroke()
context.font = `700 30px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
context.fillText(label, 64, 56, 52)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
}))
sprite.scale.set(markerSize, markerSize, markerSize)
sprite.renderOrder = 28
return sprite
}
const shouldShowRouteEndpoint = (endpoint: GuideRouteEndpoint) => (
activeView.value !== 'floor' || endpoint.floorId === currentFloor.value
)
const renderRouteEndpoint = (
endpoint: GuideRouteEndpoint,
label: string,
color: string,
markerSize: number
) => {
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
const marker = createRouteMarkerSprite(label, color, markerSize)
marker.position.copy(routePointToVector(endpoint.position, endpoint.floorId, markerSize * 0.45))
routeGroup.add(marker)
}
const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number) => {
if (!routeGroup) return
route.connectorPoints
.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))
routeGroup!.add(marker)
})
}
const routePositionSignature = (position: [number, number, number]) => (
position.map((value) => Number(value.toFixed(3))).join(',')
)
const getRoutePreviewSignature = (
route: GuideRouteResult,
visibleSegments: GuideRouteFloorSegment[],
markerSize: number
) => JSON.stringify({
routeId: route.id,
view: activeView.value,
floor: activeView.value === 'floor' ? currentFloor.value : '',
markerSize: Number(markerSize.toFixed(3)),
start: {
poiId: route.start.poiId,
floorId: route.start.floorId,
position: routePositionSignature(route.start.position)
},
end: {
poiId: route.end.poiId,
floorId: route.end.floorId,
position: routePositionSignature(route.end.position)
},
segments: visibleSegments.map((segment) => ({
floorId: segment.floorId,
points: segment.points.map((point) => `${point.nodeId}:${routePositionSignature(point.position)}`)
})),
connectors: route.connectorPoints
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
.map((point) => `${point.nodeId}:${point.floorId}:${routePositionSignature(point.position)}`)
})
const renderRoutePreview = () => {
if (!props.showRoute || !props.routePreview || !routeGroup || !scene) {
clearRoutePreview()
return
}
const route = props.routePreview
const targetRouteGroup = routeGroup
const visibleSegments = getVisibleRouteSegments(route)
const markerSize = Math.max(getPoiMarkerSize() * 0.82, 2.8)
const signature = getRoutePreviewSignature(route, visibleSegments, markerSize)
if (signature === activeRoutePreviewSignature) return
clearRoutePreview()
activeRoutePreviewSignature = signature
visibleSegments.forEach((segment) => {
if (segment.points.length < 2) return
targetRouteGroup.add(createRouteLine(
segment.points.map((point) => routePointToVector(point.position, point.floorId)),
activeView.value === 'floor' ? 1 : 0.88
))
})
renderRouteEndpoint(route.start, '起', '#1f8f5f', markerSize)
renderRouteEndpoint(route.end, '终', '#cf3f59', markerSize)
renderRouteConnectorMarkers(route, markerSize)
}
const clearSceneData = () => {
cancelCameraTween()
if (activeModel && scene) {
clearFocusHallHighlight()
scene.remove(activeModel)
if (activeModel === cachedOverviewModel) {
cachedOverviewModel.visible = false
} else {
disposeObject(activeModel, collectReusableModelResources(cachedOverviewModel))
}
}
activeModel = null
disposeFocusLabel()
disposeFocusPulse()
disposeFocusBase()
selectedPOI.value = null
clearRoutePreview()
clearPoiGroupChildren()
}
const wait = (delayMs: number) => new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs)
})
const getErrorStatus = (error: unknown) => {
const candidate = error as {
status?: unknown
target?: { status?: unknown }
currentTarget?: { status?: unknown }
} | null
const status = candidate?.status ?? candidate?.target?.status ?? candidate?.currentTarget?.status
return typeof status === 'number' && Number.isFinite(status) ? status : undefined
}
const formatModelLoadError = (error: unknown, url: string) => {
if (!url.trim()) return '模型 URL 缺失'
const status = getErrorStatus(error)
if (status === 500) return '模型代理返回 500请检查 /gis/sdk/minio 或 MinIO 代理层'
if (status === 404) return '模型资源不存在或代理返回 404'
if (status && status >= 400) return `模型资源请求失败HTTP ${status}`
const rawMessage = error instanceof Error ? error.message : String(error || '')
const message = rawMessage.toLowerCase()
if (message.includes('timeout')) return '模型资源请求超时'
if (message.includes('parse') || message.includes('json') || message.includes('gltf')) {
return '模型资源解析失败'
}
return rawMessage || '模型资源加载失败'
}
const loadModelOnce = (
url: string,
label: string,
loadToken?: number,
options: LoadModelOptions = {}
) => new Promise<GLTF>((resolve, reject) => {
if (!loader) {
reject(new Error('GLTF 加载器未初始化'))
return
}
const startedAt = getNow()
logThreeMapDiagnostic('model-load-start', {
label,
url
})
loader.load(
url,
(gltf) => {
logThreeMapDiagnostic('model-load-complete', {
label,
url,
elapsedMs: Math.round(getNow() - startedAt)
})
resolve(gltf)
},
(event) => {
if (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return
if (event.total > 0) {
const loadedRatio = event.loaded / event.total
const percent = Math.round(loadedRatio * 100)
const modelProgress = Math.round(Math.min(loadedRatio, 0.98) * 68)
const progressMessage = percent >= 98
? `${label}: 下载完成,正在解析模型...`
: `${label}: ${percent}%`
setProgress(20 + modelProgress, progressMessage)
} else {
setProgress(45, `${label}: 正在下载模型...`)
}
},
(error) => {
logThreeMapDiagnostic('model-load-failed', {
label,
url,
elapsedMs: Math.round(getNow() - startedAt),
error: error instanceof Error ? error.message : String(error)
})
reject(error)
}
)
})
const loadModelFromNetwork = async (
url: string,
label: string,
loadToken?: number,
options: LoadModelOptions = {}
) => {
let lastError: unknown = null
const normalizedUrl = url.trim()
if (!normalizedUrl) {
throw new Error('模型 URL 缺失')
}
for (let attempt = 0; attempt <= modelLoadRetryDelaysMs.length; attempt += 1) {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
throw createStaleModelLoadError()
}
try {
return await loadModelOnce(normalizedUrl, label, loadToken, options)
} catch (error) {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
throw createStaleModelLoadError()
}
lastError = error
const retryDelay = modelLoadRetryDelaysMs[attempt]
if (retryDelay === undefined) break
if (!options.suppressProgress) {
setProgress(18, `${label} 加载失败,正在重试 ${attempt + 1}/${modelLoadRetryDelaysMs.length}`)
}
await wait(retryDelay)
}
}
throw new Error(formatModelLoadError(lastError, normalizedUrl))
}
const loadModelWithFallback = async (
urls: string[],
label: string,
loadToken?: number,
options: LoadModelOptions = {},
semanticKey?: string
) => {
const candidates = urls.map((url) => url.trim()).filter(Boolean)
if (!candidates.length) throw new Error('模型 URL 缺失')
const gltf = await guideModelLoadManager.load({
urls: candidates,
label,
semanticKey,
loadSource: (url) => loadModelFromNetwork(url, label, loadToken, options),
shouldStopOnError: isStaleModelLoadError
})
if (!options.suppressProgress) {
setProgress(92, `${label}: 正在准备三维场景...`)
}
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
disposeObject(gltf.scene)
throw createStaleModelLoadError()
}
return gltf
}
const prepareModel = (model: THREE.Object3D) => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = false
child.receiveShadow = true
child.frustumCulled = false
}
})
}
const getObjectSize = (object: THREE.Object3D) => (
getObjectBox(object).getSize(new THREE.Vector3())
)
const getObjectMaxDim = (object: THREE.Object3D) => {
const size = getObjectSize(object)
return Math.max(size.x, size.y, size.z, 1)
}
const cacheAutoSwitchOverviewMaxDim = (model: THREE.Object3D) => {
autoSwitchOverviewMaxDim = getObjectMaxDim(model)
}
const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => {
const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1)
const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1)
return Math.max(maxFloorHeight * 5.2, maxFootprint * 0.32, 68)
}
const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
if (!items.length) return
const verticalGap = getMultiFloorVerticalGap(items)
const centerIndex = (items.length - 1) / 2
items.forEach((item, index) => {
const offsetY = (centerIndex - index) * verticalGap
item.model.position.y += offsetY
item.model.userData.multiFloorOffsetY = offsetY
item.model.userData.multiFloorVerticalGap = verticalGap
})
}
const getSharedMultiFloorModelUrl = (floorsToLoad: FloorIndexItem[]) => {
if (!floorsToLoad.length || !floorsToLoad.every((floor) => floor.sharedModelAsset)) return ''
const [firstFloor] = floorsToLoad
return floorsToLoad.every((floor) => floor.modelUrl === firstFloor.modelUrl)
? firstFloor.modelUrl
: ''
}
const canAttachCachedSharedModel = (modelUrl: string) => (
Boolean(cachedOverviewModel && cachedSharedModelUrl === modelUrl && scene)
)
const canLoadFloorSilently = (floorId: string) => {
const floor = floorIndex.value.find((item) => item.floorId === floorId)
return Boolean(floor && canAttachCachedSharedModel(floor.modelUrl))
}
const getFloorModelUrls = (floor: FloorIndexItem) => (
floor.modelUrls?.length ? floor.modelUrls : [floor.modelUrl]
)
const getOverviewModelUrls = (packageData: GuideModelRenderPackage) => (
packageData.overviewModelUrls?.length ? packageData.overviewModelUrls : [packageData.overviewModelUrl]
)
const preloadModelUrls = async (urls: string[], label: string, preloadSeq: number) => {
const candidates = urls.map((url) => url.trim()).filter(Boolean)
if (!candidates.length || preloadSeq !== adjacentPreloadSeq || isDisposed) return
const availableCandidates = candidates.filter((url) => {
const isCached = guideModelLoadManager.has(url)
const isPending = guideModelLoadManager.isInFlight(url)
if (isCached || isPending) {
logThreeMapDiagnostic('adjacent-preload-skip', {
label,
url,
reason: isCached ? 'cache-hit' : 'in-flight-hit',
diagnostics: guideModelLoadManager.getDiagnostics()
})
return false
}
return true
})
if (!availableCandidates.length) return
try {
await guideModelLoadManager.preload({
urls: availableCandidates,
label,
loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
})
} catch (error) {
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
urls: availableCandidates,
label,
error: error instanceof Error ? error.message : String(error)
})
}
}
const getAdjacentFloors = (floorId: string) => {
const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
const currentIndex = orderedFloors.findIndex((floor) => floor.floorId === floorId)
if (currentIndex < 0) return []
return [
orderedFloors[currentIndex - 1],
orderedFloors[currentIndex + 1]
].filter((floor): floor is FloorIndexItem => Boolean(floor?.modelUrl))
}
const scheduleAdjacentFloorPreload = (floorId: string) => {
const adjacentFloors = getAdjacentFloors(floorId)
if (!adjacentFloors.length) return
const preloadSeq = adjacentPreloadSeq + 1
adjacentPreloadSeq = preloadSeq
window.setTimeout(() => {
if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
void (async () => {
const seenModelKeys = new Set<string>()
for (const floor of adjacentFloors) {
if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
const uniqueUrls = getFloorModelUrls(floor).filter((url) => {
const key = guideModelLoadManager.createCacheKey(url)
if (!key) return false
if (seenModelKeys.has(key)) {
logThreeMapDiagnostic('adjacent-preload-skip', {
floorId: floor.floorId,
url,
reason: 'duplicate-shared-url'
})
return false
}
seenModelKeys.add(key)
return true
})
await preloadModelUrls(
uniqueUrls,
`预加载 ${formatFloorLabel(floor.floorId)} 模型`,
preloadSeq
)
}
})()
}, adjacentPreloadDelayMs)
}
const getFloorMatchKeys = (floor: FloorIndexItem) => (
[floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
.filter((value): value is string => Boolean(value))
)
const resolveFloorIdFromRequest = (requestedFloorId?: string | null) => {
const requested = requestedFloorId?.trim()
if (!requested) return ''
const normalizedRequest = normalizeModelMatchKey(requested)
return floorIndex.value.find((floor) => getFloorMatchKeys(floor).some((key) => (
key === requested
|| key.toLowerCase() === requested.toLowerCase()
|| normalizeModelMatchKey(key) === normalizedRequest
)))?.floorId || ''
}
const isGroundEntryFloor = (floor: FloorIndexItem) => (
getFloorMatchKeys(floor).some((key) => {
const normalizedKey = normalizeModelMatchKey(key)
return normalizedKey === 'l1' || normalizedKey === '1f'
})
)
const getPreferredInitialFloorId = () => (
floorIndex.value.find(isGroundEntryFloor)?.floorId
|| floorIndex.value[0]?.floorId
|| ''
)
const getDefaultFloorId = () => (
resolveFloorIdFromRequest(props.initialFloorId)
|| getPreferredInitialFloorId()
|| props.initialFloorId
|| 'L1'
)
const getAutoEntryTargetFloorId = () => (
hasLoadedFloorViewOnce
? currentFloor.value || getDefaultFloorId()
: getDefaultFloorId()
)
const hasRenderableSceneForFloorTransition = () => Boolean(scene && activeModel)
const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => {
const floorModel = sourceModel.clone(true)
floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
floorModel.userData.floorId = floor.floorId
prepareModel(floorModel)
applyModelVisibilityForView(floorModel, 'floor', floor.floorId)
return {
floor,
label: formatFloorLabel(floor.floorId),
model: floorModel,
size: getObjectSize(floorModel)
}
}
const disposeDetachedMultiFloorModels = (
items: MultiFloorModelItem[],
group: THREE.Group,
protectedResources = collectReusableModelResources(null)
) => {
items
.filter((item) => !group.children.includes(item.model))
.forEach((item) => disposeObject(item.model, protectedResources))
}
const updateLastControlDistance = () => {
if (!controls) return
const distance = controls.getDistance()
if (Number.isFinite(distance)) {
lastControlDistance = distance
}
}
const resetOverviewEntryIntent = () => {
overviewEntryIntentStartedAt = 0
overviewEntryStartDistance = 0
overviewEntryZoomInRounds = 0
overviewWheelZoomInRounds = 0
overviewWheelZoomIntentUntil = 0
}
const resetFloorExitCandidate = () => {
floorExitCandidateStartedAt = 0
floorExitCandidateBaseDistance = 0
floorExitZoomOutAccumulatedRatio = 0
}
const markFloorAutoSwitchEntry = () => {
indoorContextLockedUntil = Date.now() + indoorContextLockMs
indoorEntryGestureLocked = activeControlGestureStartedAt > 0
resetOverviewEntryIntent()
resetFloorExitCandidate()
updateLastControlDistance()
}
const resetAutoSwitchDistanceTracking = () => {
activeControlGestureStartedAt = 0
indoorEntryGestureLocked = false
indoorContextLockedUntil = 0
resetOverviewEntryIntent()
resetFloorExitCandidate()
updateLastControlDistance()
}
const clearOverviewWheelAutoSwitchTimer = () => {
if (overviewWheelAutoSwitchTimer) {
clearTimeout(overviewWheelAutoSwitchTimer)
overviewWheelAutoSwitchTimer = null
}
}
const hasOverviewWheelEntryRounds = () => (
activeView.value === 'overview'
&& overviewWheelZoomInRounds >= overviewEntryMinRounds
)
const hasPendingOverviewWheelEntryIntent = (now: number) => (
hasOverviewWheelEntryRounds()
&& isOverviewEntryIntentActive(now)
)
const canRunPendingOverviewEntry = (now: number) => (
props.autoSwitch
&& !autoSwitchTemporarilyDisabled
&& !isLoading.value
&& !isAutoSwitchLocked
&& now - lastAutoSwitchTime >= props.autoSwitchCooldown
&& Boolean(controls && activeModel)
)
const handleWheelIntent = (event: WheelEvent) => {
if (!controls || activeView.value !== 'overview' || event.deltaY >= 0) return
const distance = controls.getDistance()
if (!Number.isFinite(distance)) return
const now = Date.now()
if (!isOverviewEntryIntentActive(now)) {
overviewEntryIntentStartedAt = now
overviewEntryStartDistance = distance
overviewEntryZoomInRounds = 0
overviewWheelZoomInRounds = 0
}
overviewEntryIntentStartedAt = now
overviewEntryZoomInRounds += 1
overviewWheelZoomInRounds += 1
overviewWheelZoomIntentUntil = now + 900
clearOverviewWheelAutoSwitchTimer()
overviewWheelAutoSwitchTimer = setTimeout(() => {
const triggerNow = Date.now()
overviewWheelAutoSwitchTimer = null
if (hasPendingOverviewWheelEntryIntent(triggerNow) && canRunPendingOverviewEntry(triggerNow)) {
checkAutoSwitch({ forceOverviewEntry: true })
}
}, overviewWheelEntryDelayMs)
}
const isOverviewEntryIntentActive = (now: number) => (
overviewEntryIntentStartedAt > 0
&& (
now - overviewEntryIntentStartedAt <= overviewEntryIntentWindowMs
|| now <= overviewWheelZoomIntentUntil
)
)
const getOverviewEntryAccumulatedRatio = (distance: number) => {
if (!overviewEntryStartDistance || !Number.isFinite(distance)) return 0
return Math.max(0, 1 - distance / overviewEntryStartDistance)
}
const trackOverviewEntryIntent = (distance: number, previousDistance: number, now: number) => {
if (!Number.isFinite(distance) || !Number.isFinite(previousDistance) || previousDistance <= 0) return
const zoomInRatio = (previousDistance - distance) / previousDistance
const hasClearZoomIn = zoomInRatio >= overviewEntryMinZoomInRatio
if (!hasClearZoomIn) {
if (!isOverviewEntryIntentActive(now)) {
resetOverviewEntryIntent()
}
return
}
if (!isOverviewEntryIntentActive(now) || !overviewEntryStartDistance || distance > overviewEntryStartDistance) {
overviewEntryIntentStartedAt = now
overviewEntryStartDistance = previousDistance
overviewEntryZoomInRounds = 0
}
overviewEntryIntentStartedAt = now
overviewEntryZoomInRounds += 1
const accumulatedRatio = getOverviewEntryAccumulatedRatio(distance)
overviewEntryZoomInRounds = Math.max(
overviewEntryZoomInRounds,
Math.floor(accumulatedRatio / overviewEntryMinZoomInRatio)
)
}
const shouldEnterFloorFromOverview = (
distance: number,
maxDim: number,
now: number,
hasForcedOverviewEntryIntent = false
) => {
const enterFloorAt = maxDim * props.autoSwitchThresholdLow
if (distance <= enterFloorAt) return true
if (hasForcedOverviewEntryIntent) return true
if (!isOverviewEntryIntentActive(now)) return false
const accumulatedRatio = getOverviewEntryAccumulatedRatio(distance)
const hasRepeatedZoomIn = overviewEntryZoomInRounds >= overviewEntryMinRounds
&& accumulatedRatio >= overviewEntryAccumulatedRatio
const hasSustainedZoomIn = overviewEntryZoomInRounds >= overviewEntryMaxRounds
&& accumulatedRatio >= overviewEntryAccumulatedRatio * 0.68
return hasRepeatedZoomIn || hasSustainedZoomIn
}
const isFloorAutoExitLocked = (now: number) => (
props.disableAutoExit
|| Boolean(props.targetFocus)
|| indoorEntryGestureLocked
|| now < indoorContextLockedUntil
)
const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => {
const now = Date.now()
const hasForcedOverviewEntryIntent = options.forceOverviewEntry === true
&& hasOverviewWheelEntryRounds()
&& canRunPendingOverviewEntry(now)
if (
!props.autoSwitch
|| autoSwitchTemporarilyDisabled
|| isLoading.value
|| (!hasForcedOverviewEntryIntent && isProgrammaticCameraChange)
) {
return
}
if (isAutoSwitchLocked) {
emit('autoSwitchBlocked', { reason: 'loading-locked' })
return
}
if (now - lastAutoSwitchTime < props.autoSwitchCooldown) {
emit('autoSwitchBlocked', { reason: 'cooldown' })
return
}
if (!controls || !activeModel || activeView.value === 'multi') return
const distance = controls.getDistance()
const previousDistance = lastControlDistance || distance
const distanceDelta = distance - previousDistance
lastControlDistance = distance
// 自动切换使用全馆稳定尺寸,避免单楼层可见性过滤压低 floor -> overview 阈值。
const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel)
if (activeView.value === 'overview') {
if (!hasForcedOverviewEntryIntent) {
trackOverviewEntryIntent(distance, previousDistance, now)
}
if (!shouldEnterFloorFromOverview(distance, maxDim, now, hasForcedOverviewEntryIntent)) {
return
}
const targetFloorId = getAutoEntryTargetFloorId()
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
isAutoSwitchLocked = true
lastAutoSwitchTime = now
resetFloorExitCandidate()
resetOverviewEntryIntent()
clearOverviewWheelAutoSwitchTimer()
void runAutoSwitchLoad(
{
from: 'overview',
to: 'floor',
trigger: 'zoom-in',
distance
},
async () => {
await loadFloor(targetFloorId, {
preserveCurrentSceneUntilReady: hasSceneToKeep,
suppressProgress: hasSceneToKeep,
detachPoiBeforeLoad: true
})
},
'单楼层模型加载失败',
{ showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) }
)
return
}
if (activeView.value !== 'floor') {
resetFloorExitCandidate()
return
}
if (isFloorAutoExitLocked(now)) {
resetFloorExitCandidate()
return
}
const baseExitThreshold = maxDim * props.autoSwitchThresholdHigh
const candidateExitAt = baseExitThreshold * floorExitCandidateThresholdMultiplier
const confirmExitAt = baseExitThreshold * floorExitConfirmThresholdMultiplier
const isBeyondCandidateThreshold = distance >= candidateExitAt
if (!isBeyondCandidateThreshold) {
resetFloorExitCandidate()
return
}
if (!floorExitCandidateStartedAt) {
floorExitCandidateStartedAt = now
floorExitCandidateBaseDistance = Math.max(previousDistance, distance, 1)
floorExitZoomOutAccumulatedRatio = 0
return
}
const zoomOutRatio = previousDistance > 0 ? distanceDelta / previousDistance : 0
const isClearZoomOutIntent = zoomOutRatio >= floorExitZoomOutIntentMinDeltaRatio
if (isClearZoomOutIntent || distance > floorExitCandidateBaseDistance) {
floorExitZoomOutAccumulatedRatio = Math.max(
floorExitZoomOutAccumulatedRatio,
distance / Math.max(floorExitCandidateBaseDistance, 1) - 1
)
}
const hasConfirmedScale = distance >= confirmExitAt
&& floorExitZoomOutAccumulatedRatio >= floorExitAccumulatedZoomOutRatio
const hasHeldCandidate = now - floorExitCandidateStartedAt >= floorExitCandidateHoldMs
if (!hasConfirmedScale || !hasHeldCandidate) {
return
}
isAutoSwitchLocked = true
lastAutoSwitchTime = now
resetFloorExitCandidate()
void runAutoSwitchLoad(
{
from: 'floor',
to: 'overview',
trigger: 'zoom-out',
distance
},
loadOverview,
'建筑外观模型加载失败'
)
}
const runAutoSwitchLoad = async (
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
loadTask: () => Promise<unknown>,
fallbackMessage: string,
options: { showLoading?: boolean } = {}
) => {
const showLoading = options.showLoading ?? true
try {
if (showLoading) {
isLoading.value = true
}
loadError.value = false
await loadTask()
if (showLoading) {
isLoading.value = false
}
emit('autoSwitch', event)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('馆内 3D 自动视角切换失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : fallbackMessage)
} finally {
isAutoSwitchLocked = false
}
}
const disableAutoSwitchTemporarily = (durationMs: number) => {
autoSwitchTemporarilyDisabled = true
if (autoSwitchDisableTimer) {
clearTimeout(autoSwitchDisableTimer)
}
autoSwitchDisableTimer = setTimeout(() => {
autoSwitchTemporarilyDisabled = false
autoSwitchDisableTimer = null
}, durationMs)
}
const setCameraView = (
center: THREE.Vector3,
maxDim: number,
direction: THREE.Vector3,
options: CameraFitOptions = {}
) => {
if (!camera || !controls) return
const fov = camera.fov * (Math.PI / 180)
const distanceFactor = options.distanceFactor ?? 0.58
const targetOffset = options.targetOffsetRatio
? new THREE.Vector3(
options.targetOffsetRatio.x * maxDim,
options.targetOffsetRatio.y * maxDim,
options.targetOffsetRatio.z * maxDim
)
: new THREE.Vector3()
const targetCenter = center.clone().add(targetOffset)
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor
camera.near = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1000)
camera.far = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20)
camera.up.copy(options.up || new THREE.Vector3(0, 1, 0))
camera.updateProjectionMatrix()
const nextTarget = targetCenter.clone()
const nextPosition = targetCenter.clone().add(direction.clone().normalize().multiplyScalar(distance))
if (options.screenOffsetRatio) {
const viewDirection = nextTarget.clone().sub(nextPosition).normalize()
const cameraRight = new THREE.Vector3().crossVectors(viewDirection, camera.up).normalize()
const viewHeight = 2 * distance * Math.tan(fov / 2)
const viewWidth = viewHeight * camera.aspect
const screenOffset = cameraRight
.multiplyScalar(options.screenOffsetRatio.x * viewWidth)
.add(camera.up.clone().multiplyScalar(options.screenOffsetRatio.y * viewHeight))
nextPosition.add(screenOffset)
nextTarget.add(screenOffset)
}
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08)
controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 8)
moveCameraTo(nextPosition, nextTarget)
}
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
if (!camera || !controls) return
const useOverviewBuildingFit = activeView.value === 'overview' && preset !== 'top'
const box = useOverviewBuildingFit ? getOverviewBuildingSubjectBox(object) : getObjectBox(object)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 1)
const direction = preset === 'top'
? new THREE.Vector3(0.02, 1, 0.02)
: activeView.value === 'overview'
? new THREE.Vector3(0.76, 0.48, 1)
: new THREE.Vector3(0.85, 0.62, 1)
const overviewFitOptions: CameraFitOptions = activeView.value === 'overview'
? {
distanceFactor: 0.9,
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.overviewScreenOffsetRatio,
up: new THREE.Vector3(0, 1, 0)
}
: activeView.value === 'floor'
? {
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
up: new THREE.Vector3(0, 1, 0)
}
: {}
setCameraView(center, maxDim, direction, overviewFitOptions)
}
const resetCamera = () => {
if (activeModel) {
fitCameraToObject(activeModel)
}
}
const setCameraPreset = (preset: CameraPreset) => {
if (activeModel) {
fitCameraToObject(activeModel, preset)
}
}
const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => {
if (!camera || !controls) return
const offset = camera.position.clone().sub(controls.target)
const currentDistance = offset.length()
const zoomFactor = direction === 'in' ? 0.72 : 1.28
const minDistance = controls.minDistance || 2
const maxDistance = controls.maxDistance || SGS_VISUAL_RENDER_CONFIG.controls.maxDistance
const nextDistance = Math.min(maxDistance, Math.max(minDistance, currentDistance * zoomFactor))
if (!Number.isFinite(nextDistance) || nextDistance <= 0) return
const shouldTrackUserZoom = options.source === 'button'
if (shouldTrackUserZoom && direction === 'in' && activeView.value === 'overview') {
const now = Date.now()
if (!isOverviewEntryIntentActive(now)) {
overviewEntryIntentStartedAt = now
overviewEntryStartDistance = currentDistance
overviewEntryZoomInRounds = 0
}
overviewEntryIntentStartedAt = now
overviewEntryZoomInRounds += 1
overviewWheelZoomInRounds += 1
overviewWheelZoomIntentUntil = now + 900
}
offset.setLength(nextDistance)
const nextPosition = controls.target.clone().add(offset)
moveCameraTo(nextPosition, controls.target, {
onComplete: handleControlChange
})
if (shouldTrackUserZoom) {
lastControlDistance = currentDistance
checkAutoSwitch({
forceOverviewEntry: direction === 'in' && activeView.value === 'overview'
})
}
handleControlChange()
}
const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
if (!shouldRenderPoiMarkers.value) return
const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value) || floorIndex.value[0]
if (!floor) return
if (activeView.value === 'floor') {
await loadFloorPOIs(floor, loadToken)
assertCurrentModelLoad(loadToken)
return
}
const entry = await prepareFloorPOIs(floor, loadToken)
if (entry) {
attachPoiMarkerGroup(entry)
}
assertCurrentModelLoad(loadToken)
}
const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
if (!shouldRenderPoiMarkers.value) return
const startedAt = getNow()
logThreeMapDiagnostic('poi-background-start', {
floorId: currentFloor.value,
view: activeView.value
})
void loadCurrentFloorPoiMarkers(loadToken)
.then(() => {
if (!isCurrentModelLoad(loadToken)) return
logThreeMapDiagnostic('poi-background-ready', {
floorId: currentFloor.value,
view: activeView.value,
elapsedMs: Math.round(getNow() - startedAt)
})
refreshPoiVisibilityByDistance()
renderRoutePreview()
})
.catch((error) => {
if (isStaleModelLoadError(error)) return
console.warn('馆内外观点位标记后台加载失败:', error)
})
}
const loadOverview = async () => {
const packageData = renderPackage.value
if (!packageData || !scene) return
const loadToken = startModelLoad()
activeView.value = 'overview'
clearSceneData()
if (cachedOverviewModel) {
const targetScene = scene
if (!targetScene) throw createStaleModelLoadError()
activeModel = cachedOverviewModel
activeModel.visible = true
applyModelVisibilityForView(activeModel, 'overview')
cacheAutoSwitchOverviewMaxDim(activeModel)
targetScene.add(activeModel)
fitCameraToObject(activeModel)
loadCurrentFloorPoiMarkersInBackground(loadToken)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('overview', {
source: 'cached-overview',
modelUrl: cachedSharedModelUrl
})
return
}
setProgress(18, '正在加载建筑外观模型...')
const gltf = await loadModelWithFallback(getOverviewModelUrls(packageData), '正在加载建筑外观模型', loadToken)
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
if (!targetScene) {
disposeObject(gltf.scene)
throw createStaleModelLoadError()
}
activeModel = gltf.scene
activeModel.name = 'GuideOverviewModel'
prepareModel(activeModel)
applyModelVisibilityForView(activeModel, 'overview')
cacheAutoSwitchOverviewMaxDim(activeModel)
cachedOverviewModel = activeModel
cachedSharedModelUrl = packageData.overviewModelUrl
targetScene.add(activeModel)
fitCameraToObject(activeModel)
loadCurrentFloorPoiMarkersInBackground(loadToken)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('overview', {
source: 'network',
modelUrl: packageData.overviewModelUrl
})
}
const prepareFloorScene = async (
floor: FloorIndexItem,
loadToken: number,
options: LoadFloorOptions = {}
): Promise<PreparedFloorScene> => {
const requestedFloorId = floor.floorId
let model: THREE.Object3D | null = null
let ownsModel = true
let cacheAsSharedModel = false
let protectedResources: ReusableModelResources | undefined
const cachedExactSharedModel = canAttachCachedSharedModel(floor.modelUrl)
? cachedOverviewModel
: null
if (cachedExactSharedModel) {
model = cachedExactSharedModel
ownsModel = false
protectedResources = collectReusableModelResources(cachedExactSharedModel)
model.name = `GuideFloorModel_${requestedFloorId}`
model.userData.floorId = requestedFloorId
model.visible = true
applyModelVisibilityForView(model, 'floor', requestedFloorId)
} else {
if (!options.suppressProgress) {
setProgress(18, `正在加载 ${formatFloorLabel(requestedFloorId)} 模型...`)
}
const gltf = await loadModelWithFallback(
getFloorModelUrls(floor),
`正在加载 ${formatFloorLabel(requestedFloorId)} 模型`,
loadToken,
{ suppressProgress: options.suppressProgress }
)
try {
assertCurrentModelLoad(loadToken, gltf.scene)
} catch (error) {
disposeObject(gltf.scene)
throw error
}
model = gltf.scene
model.name = `GuideFloorModel_${requestedFloorId}`
model.userData.floorId = requestedFloorId
prepareModel(model)
applyModelVisibilityForView(model, 'floor', requestedFloorId)
cacheAsSharedModel = Boolean(floor.sharedModelAsset)
if (cacheAsSharedModel) {
cacheAutoSwitchOverviewMaxDim(model)
}
}
if (!model) {
throw new Error(`楼层模型加载失败:${requestedFloorId}`)
}
const poiEntry = shouldRenderPoiMarkers.value
? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floor.floorId)) || null
: null
const prepared: PreparedFloorScene = {
floor,
model,
poiEntry: poiEntry || null,
ownsModel,
cacheAsSharedModel,
protectedResources
}
assertPreparedFloorScene(prepared, loadToken, requestedFloorId)
return prepared
}
const commitPreparedFloorScene = (
prepared: PreparedFloorScene,
loadToken: number,
expectedFloorId: string
) => {
assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined)
assertPreparedFloorScene(prepared, loadToken, expectedFloorId)
const targetScene = scene
if (!targetScene) {
if (prepared.ownsModel) {
disposeObject(prepared.model, prepared.protectedResources)
}
throw createStaleModelLoadError()
}
activeView.value = 'floor'
currentFloor.value = expectedFloorId
clearSceneData()
activeModel = prepared.model
activeModel.userData.floorId = expectedFloorId
if (prepared.cacheAsSharedModel) {
cachedOverviewModel = activeModel
cachedSharedModelUrl = prepared.floor.modelUrl
}
targetScene.add(activeModel)
if (prepared.poiEntry) {
attachPoiMarkerGroup(prepared.poiEntry)
} else if (poiGroup) {
poiGroup.userData.floorId = expectedFloorId
poiGroup.userData.currentFloor = expectedFloorId
}
assertCommittedFloorScene(loadToken, expectedFloorId)
activeFocusPoiId.value = ''
selectedPOI.value = null
updatePoiMarkerFocus()
hasLoadedFloorViewOnce = true
fitCameraToObject(activeModel)
markFloorAutoSwitchEntry()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('floor', {
floorId: expectedFloorId,
modelUrl: prepared.floor.modelUrl,
poiFromCache: Boolean(prepared.poiEntry)
})
loadCurrentFloorPoiMarkersInBackground(loadToken)
scheduleAdjacentFloorPreload(expectedFloorId)
}
const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor) {
throw new Error(`未找到楼层模型:${floorId}`)
}
if (!scene) {
throw createStaleModelLoadError()
}
const requestedFloorId = floor.floorId
if (
!options.allowSameFloorReload
&& activeView.value === 'floor'
&& currentFloor.value === requestedFloorId
&& activeModel?.userData.floorId === requestedFloorId
&& !isFloorSwitching
) {
return false
}
const loadToken = startFloorContextTransaction(requestedFloorId)
if (options.detachPoiBeforeLoad) {
detachActivePoiLayer()
}
try {
const prepared = await prepareFloorScene(floor, loadToken, options)
assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
return true
} catch (error) {
if (!isStaleModelLoadError(error)) {
restoreCommittedFloorPoiLayer()
}
throw error
} finally {
completeFloorContextTransaction(loadToken, requestedFloorId)
}
}
const loadMultiFloor = async () => {
if (!scene || !floorIndex.value.length) return
const loadToken = startModelLoad()
activeView.value = 'multi'
clearSceneData()
setProgress(18, '正在加载多层展示模型...')
const group = new THREE.Group()
group.name = 'GuideMultiFloorModel'
const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
const loadedFloors: MultiFloorModelItem[] = []
const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)
if (sharedModelUrl) {
let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null
if (!sourceModel) {
const gltf = await loadModelWithFallback([sharedModelUrl], '正在加载多层共享模型', loadToken)
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
if (!targetScene) {
disposeObject(gltf.scene)
disposeObject(group)
throw createStaleModelLoadError()
}
sourceModel = gltf.scene
sourceModel.name = 'GuideOverviewModel'
prepareModel(sourceModel)
applyModelVisibilityForView(sourceModel, 'overview')
cacheAutoSwitchOverviewMaxDim(sourceModel)
cachedOverviewModel = sourceModel
cachedSharedModelUrl = sharedModelUrl
}
const sharedSourceModel = sourceModel
const protectedResources = collectReusableModelResources(sharedSourceModel)
try {
orderedFloors.forEach((floor, index) => {
assertCurrentModelLoad(loadToken, group)
const item = createMultiFloorItemFromSharedModel(sharedSourceModel, floor)
loadedFloors.push(item)
setProgress(
18 + Math.round(((index + 1) / orderedFloors.length) * 72),
`已准备 ${item.label}`
)
})
assertCurrentModelLoad(loadToken, group)
applyMultiFloorLayout(loadedFloors)
loadedFloors.forEach((item) => group.add(item.model))
const targetScene = scene
if (!targetScene) {
disposeObject(group, protectedResources)
throw createStaleModelLoadError()
}
activeModel = group
targetScene.add(activeModel)
fitCameraToObject(activeModel)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('multi', {
source: 'shared-model',
modelUrl: sharedModelUrl,
floorCount: orderedFloors.length
})
return
} catch (error) {
disposeObject(group, protectedResources)
disposeDetachedMultiFloorModels(loadedFloors, group, protectedResources)
if (isStaleModelLoadError(error)) {
throw error
}
throw error
}
}
for (let index = 0; index < orderedFloors.length; index += 1) {
try {
assertCurrentModelLoad(loadToken, group)
} catch (error) {
disposeDetachedMultiFloorModels(loadedFloors, group)
throw error
}
const floor = orderedFloors[index]
const label = formatFloorLabel(floor.floorId)
const gltf = await loadModelWithFallback(getFloorModelUrls(floor), `正在加载 ${label} 多层模型`, loadToken)
if (!isCurrentModelLoad(loadToken)) {
disposeObject(gltf.scene)
disposeObject(group)
disposeDetachedMultiFloorModels(loadedFloors, group)
throw createStaleModelLoadError()
}
const floorModel = gltf.scene
floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
floorModel.userData.floorId = floor.floorId
prepareModel(floorModel)
applyModelVisibilityForView(floorModel, 'floor', floor.floorId)
loadedFloors.push({
floor,
label,
model: floorModel,
size: getObjectSize(floorModel)
})
setProgress(
18 + Math.round(((index + 1) / orderedFloors.length) * 72),
`已加载 ${label}`
)
}
try {
assertCurrentModelLoad(loadToken, group)
} catch (error) {
disposeDetachedMultiFloorModels(loadedFloors, group)
throw error
}
applyMultiFloorLayout(loadedFloors)
loadedFloors.forEach((item) => group.add(item.model))
const targetScene = scene
if (!targetScene) {
disposeObject(group)
disposeDetachedMultiFloorModels(loadedFloors, group)
throw createStaleModelLoadError()
}
activeModel = group
targetScene.add(activeModel)
fitCameraToObject(activeModel)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
markFirstModelVisible('multi', {
source: 'per-floor-models',
floorCount: orderedFloors.length
})
}
const createPoiMaterial = (poi: RenderPoi) => {
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 156
const context = canvas.getContext('2d')
if (context) {
const color = getPoiColor(poi.primaryCategory)
const glyph = getPoiGlyph(poi)
context.clearRect(0, 0, canvas.width, canvas.height)
context.shadowColor = 'rgba(31, 35, 41, 0.24)'
context.shadowBlur = 14
context.shadowOffsetY = 8
context.beginPath()
context.arc(64, 56, 31, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.shadowColor = 'transparent'
context.lineWidth = 6
context.strokeStyle = '#ffffff'
context.stroke()
context.beginPath()
context.moveTo(64, 116)
context.lineTo(42, 78)
context.lineTo(86, 78)
context.closePath()
context.fillStyle = color
context.fill()
context.lineWidth = 6
context.strokeStyle = '#ffffff'
context.stroke()
context.beginPath()
context.arc(64, 56, 21, 0, Math.PI * 2)
context.fillStyle = 'rgba(255, 255, 255, 0.94)'
context.fill()
context.font = `700 28px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = color
context.fillText(glyph, 64, 57, 46)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
return new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
})
}
const createPoiHitTargetMaterial = () => {
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const context = canvas.getContext('2d')
if (context) {
context.clearRect(0, 0, canvas.width, canvas.height)
context.beginPath()
context.arc(16, 16, 15, 0, Math.PI * 2)
context.fillStyle = 'rgba(255, 255, 255, 0.02)'
context.fill()
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
return new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.01,
colorWrite: false
})
}
const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 560
canvas.height = 168
const context = canvas.getContext('2d')
const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}` : poi.name
if (context) {
const color = getPoiColor(poi.primaryCategory)
context.clearRect(0, 0, canvas.width, canvas.height)
context.shadowColor = 'rgba(31, 35, 41, 0.2)'
context.shadowBlur = 18
context.shadowOffsetY = 8
context.fillStyle = 'rgba(255, 255, 255, 0.96)'
context.beginPath()
context.roundRect(40, 24, 480, 92, 28)
context.fill()
context.shadowColor = 'transparent'
context.lineWidth = 3
context.strokeStyle = 'rgba(31, 35, 41, 0.08)'
context.stroke()
context.beginPath()
context.moveTo(258, 116)
context.lineTo(302, 116)
context.lineTo(280, 144)
context.closePath()
context.fill()
context.beginPath()
context.arc(86, 70, 20, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.font = `700 24px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'left'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
context.fillText(getPoiGlyph(poi), 74, 70, 28)
context.font = `700 34px ${CANVAS_FONT_FAMILY}`
context.fillStyle = '#1f2329'
context.fillText(label, 120, 61, 360)
context.font = `500 22px ${CANVAS_FONT_FAMILY}`
context.fillStyle = '#6b7178'
context.fillText(`${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`, 120, 94, 360)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
}))
sprite.userData.isPoiLabel = true
sprite.userData.labelBaseScaleX = markerSize * 5.6
sprite.userData.labelBaseScaleY = markerSize * 1.68
sprite.renderOrder = 30
sprite.frustumCulled = false
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
return sprite
}
const createAmbientPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 360
canvas.height = 112
const context = canvas.getContext('2d')
const labelLimit = isHallPoi(poi) ? 10 : 7
const label = poi.name.length > labelLimit ? `${poi.name.slice(0, labelLimit)}` : poi.name
if (context) {
const color = getPoiColor(poi.primaryCategory)
const hallLabel = isHallPoi(poi)
const boxX = hallLabel ? 28 : 42
const boxY = hallLabel ? 18 : 26
const boxWidth = hallLabel ? 304 : 276
const boxHeight = hallLabel ? 60 : 48
const radius = hallLabel ? 22 : 18
context.clearRect(0, 0, canvas.width, canvas.height)
context.shadowColor = 'rgba(31, 35, 41, 0.18)'
context.shadowBlur = hallLabel ? 14 : 10
context.shadowOffsetY = hallLabel ? 7 : 5
context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
context.beginPath()
context.roundRect(boxX, boxY, boxWidth, boxHeight, radius)
context.fill()
context.shadowColor = 'transparent'
context.lineWidth = hallLabel ? 3 : 2
context.strokeStyle = hallLabel ? `${color}66` : 'rgba(31, 35, 41, 0.1)'
context.stroke()
context.beginPath()
context.moveTo(166, boxY + boxHeight)
context.lineTo(194, boxY + boxHeight)
context.lineTo(180, boxY + boxHeight + 18)
context.closePath()
context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
context.fill()
context.strokeStyle = hallLabel ? `${color}50` : 'rgba(31, 35, 41, 0.08)'
context.stroke()
context.beginPath()
context.arc(boxX + 30, boxY + boxHeight * 0.5, hallLabel ? 15 : 12, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.font = `700 ${hallLabel ? 18 : 15}px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
context.fillText(getPoiGlyph(poi), boxX + 30, boxY + boxHeight * 0.5, 24)
context.font = `700 ${hallLabel ? 24 : 20}px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'left'
context.fillStyle = '#1f2329'
context.fillText(label, boxX + 56, boxY + boxHeight * 0.5, boxWidth - 76)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
}))
sprite.userData.isPoiLabel = true
sprite.userData.poi = poi
sprite.userData.labelBaseScaleX = markerSize * (isHallPoi(poi) ? 3.9 : 3.05)
sprite.userData.labelBaseScaleY = markerSize * (isHallPoi(poi) ? 1.22 : 0.95)
sprite.renderOrder = isHallPoi(poi) ? 18 : 15
sprite.frustumCulled = false
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
sprite.visible = false
return sprite
}
const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 160
canvas.height = 160
const context = canvas.getContext('2d')
if (context) {
const color = getPoiColor(poi.primaryCategory)
context.clearRect(0, 0, canvas.width, canvas.height)
context.beginPath()
context.arc(80, 80, 56, 0, Math.PI * 2)
context.fillStyle = `${color}30`
context.fill()
context.lineWidth = 6
context.strokeStyle = `${color}66`
context.stroke()
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.8
}))
sprite.userData.isPoiPulse = true
sprite.userData.baseScale = markerSize * 1.9
sprite.renderOrder = 9
sprite.frustumCulled = false
sprite.scale.set(markerSize * 1.9, markerSize * 1.9, markerSize * 1.9)
return sprite
}
const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 180
canvas.height = 180
const context = canvas.getContext('2d')
if (context) {
const color = getPoiColor(poi.primaryCategory)
const gradient = context.createRadialGradient(90, 90, 8, 90, 90, 76)
gradient.addColorStop(0, `${color}50`)
gradient.addColorStop(0.45, `${color}28`)
gradient.addColorStop(1, `${color}00`)
context.clearRect(0, 0, canvas.width, canvas.height)
context.beginPath()
context.arc(90, 90, 76, 0, Math.PI * 2)
context.fillStyle = gradient
context.fill()
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.7
}))
sprite.userData.isPoiBase = true
sprite.userData.baseScale = markerSize * 2.15
sprite.renderOrder = 8
sprite.frustumCulled = false
sprite.scale.set(markerSize * 2.15, markerSize * 2.15, markerSize * 2.15)
return sprite
}
const disposeFocusLabel = () => {
if (!activeFocusLabelSprite) return
activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
disposeObject(activeFocusLabelSprite)
activeFocusLabelSprite = null
}
const disposeFocusPulse = () => {
if (!activeFocusPulseSprite) return
activeFocusPulseSprite.parent?.remove(activeFocusPulseSprite)
disposeObject(activeFocusPulseSprite)
activeFocusPulseSprite = null
}
const disposeFocusBase = () => {
if (!activeFocusBaseSprite) return
activeFocusBaseSprite.parent?.remove(activeFocusBaseSprite)
disposeObject(activeFocusBaseSprite)
activeFocusBaseSprite = null
}
const showFocusPoiLabel = (poi: RenderPoi) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!poiGroup || !displayPosition) return
disposeFocusLabel()
const markerSize = getPoiMarkerSize()
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
activeFocusLabelSprite.frustumCulled = false
activeFocusLabelSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusLabelLiftFactor))
updateFocusLabelScale()
poiGroup.add(activeFocusLabelSprite)
}
const showFocusPoiBase = (poi: RenderPoi) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!poiGroup || !displayPosition) return
disposeFocusBase()
const markerSize = getPoiMarkerSize()
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
activeFocusBaseSprite.frustumCulled = false
activeFocusBaseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusBaseLiftFactor))
poiGroup.add(activeFocusBaseSprite)
}
const showFocusPoiPulse = (poi: RenderPoi) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!poiGroup || !displayPosition) return
disposeFocusPulse()
const markerSize = getPoiMarkerSize()
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
activeFocusPulseSprite.frustumCulled = false
activeFocusPulseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusPulseLiftFactor))
poiGroup.add(activeFocusPulseSprite)
}
const showFocusPoiAffordances = (poi: RenderPoi) => {
showFocusHallHighlight(poi)
showFocusPoiBase(poi)
showFocusPoiPulse(poi)
showFocusPoiLabel(poi)
}
const getPoiColor = (category: string) => {
const colorMap: Record<string, string> = {
poi: '#56606b',
touring_poi: '#2f6fed',
exhibition_hall: '#2f6fed',
exhibition_hall_entrance: '#4659d8',
basic_service_facility: '#1f8f5f',
business_poi: '#0f9aa5',
transport_circulation: '#d77b20',
accessibility_special_service: '#8a5cf6',
operation_experience: '#cf3f59'
}
return colorMap[category] || '#1f2329'
}
const getPoiMarkerSize = () => (
activeModel
? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
: 3
)
const getFocusLabelScaleBoost = () => {
if (!controls || !activeModel) return 1
const distanceRatio = controls.getDistance() / getActiveModelSpan()
return Math.min(2.25, Math.max(1, distanceRatio / 0.36))
}
const updateFocusLabelScale = () => {
if (!activeFocusLabelSprite) return
const userData = getPoiSpriteUserData(activeFocusLabelSprite)
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
? userData.labelBaseScaleX
: activeFocusLabelSprite.scale.x
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
? userData.labelBaseScaleY
: activeFocusLabelSprite.scale.y
const scaleBoost = getFocusLabelScaleBoost()
activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1)
}
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
const userData = getPoiSpriteUserData(sprite)
const baseScale = typeof userData.baseScale === 'number'
? userData.baseScale
: sprite.scale.x
const scaleBoost = userData.isCorePoi ? 1.18 : 1
const scale = focused ? baseScale * scaleBoost * 1.62 : baseScale * scaleBoost
sprite.scale.set(scale, scale, scale)
sprite.renderOrder = focused ? 22 : (userData.isCorePoi ? 12 : 10)
sprite.material.opacity = focused ? 1 : 0.86
if (userData.hitTarget) {
const hitBaseScale = userData.hitTargetBaseScale || baseScale * poiHitTargetScaleMultiplier
userData.hitTarget.scale.set(hitBaseScale, hitBaseScale, hitBaseScale)
userData.hitTarget.renderOrder = focused ? 21 : 11
}
}
const updatePoiMarkerFocus = () => {
if (!poiGroup) return
getPoiSprites().forEach((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
setPoiSpriteFocusStyle(sprite, Boolean(poi && poi.id === activeFocusPoiId.value))
})
refreshPoiVisibilityByDistance()
}
const findPoiSprite = (poiId: string) => (
getPoiSprites().find((sprite) => (sprite.userData.poi as RenderPoi | undefined)?.id === poiId)
)
const findLoadedPoi = (poiId: string) => (
findPoiSprite(poiId)?.userData.poi as RenderPoi | undefined
)
const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
const sprite = new THREE.Sprite(createPoiMaterial(poi))
const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
const labelSprite = shouldCreateAmbientPoiLabel(poi)
? createAmbientPoiLabelSprite(poi, markerSize)
: null
const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview'
const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)
sprite.userData.baseScale = markerSize
sprite.userData.poi = poi
sprite.userData.isCorePoi = isCorePoi
sprite.userData.hitTarget = hitTarget
sprite.frustumCulled = false
if (labelSprite) {
sprite.userData.labelSprite = labelSprite
}
sprite.userData.hitTargetBaseScale = hitTargetScale
hitTarget.userData.baseScale = hitTargetScale
hitTarget.userData.poi = poi
hitTarget.userData.visibleMarker = sprite
hitTarget.userData.isPoiHitTarget = true
hitTarget.renderOrder = 11
hitTarget.frustumCulled = false
hitTarget.scale.set(hitTargetScale, hitTargetScale, hitTargetScale)
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
return { sprite, hitTarget, labelSprite }
}
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
if (!request.positionGltf) return null
return {
id: request.poiId,
name: request.name || '目标位置',
floorId: request.floorId,
primaryCategory: 'target_preview',
primaryCategoryZh: request.primaryCategoryZh || '位置预览',
iconType: 'target_preview',
positionGltf: request.positionGltf,
sourceObjectName: request.sourceObjectName
}
}
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
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)
poiGroup.add(sprite)
poiGroup.add(hitTarget)
return poi
}
const createPoiMarkerGroup = (
floorId: string,
displayMode: PoiDisplayMode,
pois: RenderPoi[],
markerSize: number
) => {
const group = new THREE.Group()
group.name = `GuideModelPOI_${floorId}_${displayMode}`
group.userData.floorId = floorId
group.userData.currentFloor = floorId
group.userData.displayMode = displayMode
pois.forEach((poi) => {
const displayPosition = getPoiDisplayPosition(poi, floorId)
if (!displayPosition) return
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
hitTarget.position.copy(sprite.position)
if (labelSprite) {
labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
labelSprite.visible = false
}
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
hitTarget.visible = sprite.visible
group.add(sprite)
group.add(hitTarget)
if (labelSprite) {
group.add(labelSprite)
}
})
return group
}
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
if (!poiGroup) return
detachPoiMarkerGroups()
if (entry.group.parent !== poiGroup) {
poiGroup.add(entry.group)
}
poiGroup.userData.floorId = entry.floorId
poiGroup.userData.currentFloor = entry.floorId
updatePoiMarkerFocus()
logThreeMapDiagnostic('poi-marker-render-diagnostics', {
floorId: entry.floorId,
currentFloor: currentFloor.value,
displayMode: entry.displayMode,
rawPoiCount: entry.rawPoiCount ?? entry.pois.length,
rawPositionedPoiCount: entry.rawPositionedPoiCount ?? entry.pois.filter((poi) => Boolean(poi.positionGltf)).length,
renderPoiCount: entry.pois.length,
renderPositionedPoiCount: entry.pois.filter((poi) => Boolean(poi.positionGltf)).length,
renderCategoryCounts: countPoisByCategory(entry.pois),
filteredCategoryCounts: entry.filteredCategoryCounts || {},
markerObjects: countPoiMarkerObjects(entry.group),
poiGroupFloorId: poiGroup.userData.floorId,
poiGroupCurrentFloor: poiGroup.userData.currentFloor,
selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value),
markerSize: roundDiagnosticNumber(entry.markerSize),
displayOffsetY: roundDiagnosticNumber(entry.displayOffsetY),
visualLiftY: roundDiagnosticNumber(entry.markerSize * poiMarkerVisualLiftFactor)
})
}
const restoreCommittedFloorPoiLayer = () => {
if (!poiGroup || activeView.value !== 'floor' || !activeModel) return
const floorId = currentFloor.value
const modelFloorId = typeof activeModel.userData.floorId === 'string'
? activeModel.userData.floorId
: ''
if (!floorId || modelFloorId !== floorId) return
const entry = shouldRenderPoiMarkers.value
? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floorId))
: null
if (entry?.floorId === floorId) {
attachPoiMarkerGroup(entry)
refreshPoiVisibilityByDistance()
return
}
poiGroup.userData.floorId = floorId
poiGroup.userData.currentFloor = floorId
}
const getPoiMarkerSizeForModel = (model: THREE.Object3D | null) => (
model
? Math.max(new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
: getPoiMarkerSize()
)
const prepareFloorPOIs = async (
floor: FloorIndexItem,
loadToken?: number,
markerModel: THREE.Object3D | null = activeModel
) => {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
const displayMode = getPoiDisplayMode()
const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode)
const cachedEntry = poiMarkerGroupCache.get(markerCacheKey)
if (cachedEntry) {
return cachedEntry
}
const cachedPois = poiDataCache.get(floor.floorId)
const floorPois = cachedPois || await props.modelSource.loadFloorPois(floor.floorId)
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
poiDataCache.set(floor.floorId, floorPois)
const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi))
const filteredPois = floorPois.filter((poi) => !validPois.some((validPoi) => validPoi.id === poi.id))
logThreeMapDiagnostic('poi-filter-diagnostics', {
floorId: floor.floorId,
currentFloor: currentFloor.value,
displayMode,
rawPoiCount: floorPois.length,
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
rawCategoryCounts: countPoisByCategory(floorPois),
poiCategoryCount: floorPois.filter((poi) => poi.primaryCategory === 'poi').length,
renderPoiCount: validPois.length,
renderPositionedPoiCount: validPois.filter((poi) => Boolean(poi.positionGltf)).length,
renderCategoryCounts: countPoisByCategory(validPois),
renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length,
filteredCategoryCounts: countPoisByCategory(filteredPois)
})
const displayOffsetY = markerModel
? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois)
: getCachedPoiDisplayOffsetY(floor.floorId)
const markerSize = getPoiMarkerSizeForModel(markerModel)
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
const entry: PoiMarkerCacheEntry = {
floorId: floor.floorId,
displayMode,
pois: validPois,
group: markerGroup,
markerSize,
displayOffsetY,
rawPoiCount: floorPois.length,
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
filteredCategoryCounts: countPoisByCategory(filteredPois)
}
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
disposeObject(markerGroup)
return
}
poiMarkerGroupCache.set(markerCacheKey, entry)
return entry
}
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
if (!poiGroup) return
if (loadToken !== undefined) {
assertActiveFloorModelState(loadToken, floor.floorId)
}
const entry = await prepareFloorPOIs(floor, loadToken)
if (!entry) return
if (loadToken !== undefined) {
assertActiveFloorModelState(loadToken, floor.floorId)
}
attachPoiMarkerGroup(entry)
}
const clearMapSelection = (shouldEmit = true) => {
activeFocusPoiId.value = ''
selectedPOI.value = null
clearFocusHallHighlight()
disposeFocusLabel()
disposeFocusPulse()
disposeFocusBase()
updatePoiMarkerFocus()
if (shouldEmit) {
emit('selectionClear')
}
}
const findFloorObject = (object: THREE.Object3D) => {
let current: THREE.Object3D | null = object
while (current) {
if (typeof current.userData.floorId === 'string') {
return current
}
current = current.parent
}
return null
}
const getPoiMarkerFromHit = (object: THREE.Object3D) => {
if (object instanceof THREE.Sprite && isPoiHitTargetSprite(object)) {
return getPoiSpriteUserData(object).visibleMarker || null
}
if (object instanceof THREE.Sprite && isPoiMarkerSprite(object)) {
return object
}
return null
}
const triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
}
const handleSceneTap = async (event: PointerEvent) => {
if (!camera || !renderer || !getContainerElement()) return
const rect = renderer.domElement.getBoundingClientRect()
const pointer = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
)
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(pointer, camera)
if ((activeView.value === 'floor' || activeView.value === 'overview') && poiGroup) {
const hitTargets = getPoiHitTargets()
const poiSprites = getPoiSprites()
const poiHits = raycaster.intersectObjects([...hitTargets, ...poiSprites], false)
const hit = poiHits.find((item) => (
item.object.visible
&& item.object instanceof THREE.Sprite
&& getPoiMarkerFromHit(item.object)
))
const fallbackMarker = hit ? null : findNearestPoiMarkerByScreenPoint(event, rect)
const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : fallbackMarker
logThreeMapDiagnostic('poi-hit-diagnostics', {
view: activeView.value,
currentFloor: currentFloor.value,
poiGroupFloorId: poiGroup.userData.floorId,
pointer: {
x: Math.round(event.clientX - rect.left),
y: Math.round(event.clientY - rect.top)
},
visibleMarkerCount: poiSprites.filter((sprite) => sprite.visible).length,
hitTargetCount: hitTargets.length,
rayHitCount: poiHits.length,
raySelectedPoiId: hit ? (getPoiMarkerFromHit(hit.object)?.userData.poi as RenderPoi | undefined)?.id : null,
fallbackSelectedPoiId: (fallbackMarker?.userData.poi as RenderPoi | undefined)?.id || null
})
if (hitMarker?.userData.poi) {
const selectedPoi = hitMarker.userData.poi as RenderPoi
if (activeView.value === 'overview') {
disableAutoSwitchTemporarily(10000)
try {
await loadFloor(selectedPoi.floorId, {
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
suppressProgress: true,
detachPoiBeforeLoad: true
})
emit('floorChange', selectedPoi.floorId)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('展厅点位楼层切换失败:', error)
}
}
const poi = findLoadedPoi(selectedPoi.id) || selectedPoi
const focusMarker = findPoiSprite(poi.id) || hitMarker
selectedPOI.value = poi
activeFocusPoiId.value = poi.id
triggerPoiTapFeedback(focusMarker)
updatePoiMarkerFocus()
showFocusPoiAffordances(poi)
focusCameraOnPoi(poi)
refreshPoiVisibilityByDistance()
emit('poiClick', poi)
return
}
}
if (activeView.value === 'multi' && activeModel) {
const floorHits = raycaster.intersectObjects(activeModel.children, true)
const floorObject = floorHits
.map((hit) => findFloorObject(hit.object))
.find((object): object is THREE.Object3D => Boolean(object))
if (floorObject?.userData.floorId) {
disableAutoSwitchTemporarily(10000)
void handleFloorChange(floorObject.userData.floorId as string)
return
}
}
clearMapSelection()
}
const handlePointerDown = (event: PointerEvent) => {
pointerDownState = {
x: event.clientX,
y: event.clientY
}
}
const handlePointerUp = (event: PointerEvent) => {
if (!pointerDownState) return
const moveDistance = Math.hypot(event.clientX - pointerDownState.x, event.clientY - pointerDownState.y)
pointerDownState = null
if (moveDistance > 8) return
void handleSceneTap(event)
}
const emitTargetFocus = (
request: TargetPoiFocusRequest,
status: TargetPoiFocusResult['status'],
message?: string
) => {
emit('targetFocus', {
requestId: request.requestId,
poiId: request.poiId,
floorId: request.floorId,
status,
message
})
}
const clearTargetFocus = () => {
pendingTargetFocus = null
clearMapSelection(false)
}
const isSceneReadyForTargetFocus = () => (
Boolean(scene && camera && controls && loader && floorIndex.value.length)
&& !isLoading.value
&& !loadError.value
)
const focusCameraOnPoi = (poi: RenderPoi) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!camera || !controls || !displayPosition) 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 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 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)
}
const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
if (!request.poiId || !request.floorId) {
emitTargetFocus(request, 'missing', '目标位置数据不完整')
return false
}
const floor = floorIndex.value.find((item) => item.floorId === request.floorId)
if (!floor) {
emitTargetFocus(request, 'missing', '目标楼层不在当前三维资源中')
return false
}
activeFocusPoiId.value = request.poiId
try {
if (activeView.value !== 'floor' || currentFloor.value !== request.floorId) {
loadError.value = false
await loadFloor(request.floorId, {
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
suppressProgress: true,
detachPoiBeforeLoad: true
})
isLoading.value = false
emit('floorChange', request.floorId)
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
await loadFloorPOIs(floor, modelLoadVersion)
}
const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)
if (!poi?.positionGltf) {
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
emitTargetFocus(request, 'missing', '目标暂无三维坐标')
return false
}
selectedPOI.value = poi
updatePoiMarkerFocus()
showFocusPoiAffordances(poi)
focusCameraOnPoi(poi)
refreshPoiVisibilityByDistance()
emitTargetFocus(request, 'focused')
return true
} catch (error) {
if (isStaleModelLoadError(error)) return false
console.error('目标 POI 聚焦失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '目标位置聚焦失败')
emitTargetFocus(request, 'error', error instanceof Error ? error.message : '目标位置聚焦失败')
return false
}
}
const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
if (!request) {
clearTargetFocus()
return
}
pendingTargetFocus = request
if (!isSceneReadyForTargetFocus()) return
const nextRequest = pendingTargetFocus
if (!nextRequest) return
pendingTargetFocus = null
targetFocusQueue = targetFocusQueue
.then(() => focusTargetPoi(nextRequest))
.finally(() => {
if (pendingTargetFocus) {
queueTargetFocus(pendingTargetFocus)
}
})
}
const loadModelPackage = async () => {
setProgress(8, '正在读取馆内导览资源...')
const packageData = await props.modelSource.loadPackage()
renderPackage.value = packageData
setProgress(14, '正在读取楼层索引...')
floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom)
logThreeMapDiagnostic('package-ready', {
initialView: props.initialView,
requestedInitialFloorId: props.initialFloorId,
overviewModelUrl: packageData.overviewModelUrl,
floorCount: floorIndex.value.length,
floorModels: floorIndex.value.map((floor) => ({
floorId: floor.floorId,
modelUrl: floor.modelUrl,
sharedModelAsset: floor.sharedModelAsset
}))
})
const requestedInitialFloorId = resolveFloorIdFromRequest(props.initialFloorId)
if (requestedInitialFloorId) {
currentFloor.value = requestedInitialFloorId
} else if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
currentFloor.value = getDefaultFloorId()
}
if (props.initialView === 'floor') {
const didCommit = await loadFloor(currentFloor.value)
if (didCommit) {
emit('floorChange', currentFloor.value)
}
return
}
if (props.initialView === 'multi') {
await loadMultiFloor()
return
}
await loadOverview()
}
const init3DScene = async () => {
try {
disposeScene()
isDisposed = false
firstModelLoadStartedAt = getNow()
firstModelVisibleReported = false
initialModelSettled = false
isLoading.value = true
loadError.value = false
selectedPOI.value = null
hasLoadedFloorViewOnce = false
setProgress(0, '正在初始化三维场景...')
logThreeMapDiagnostic('init-start', {
initialView: props.initialView,
initialFloorId: props.initialFloorId
})
await initThree()
await loadModelPackage()
setProgress(100, '馆内三维模型加载完成')
initialModelSettled = true
isLoading.value = false
emit('initialModelReady', {
view: activeView.value,
floorId: currentFloor.value,
elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined
})
await syncRequestedIndoorView()
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('馆内 3D 模型资源加载失败:', error)
loadError.value = true
isLoading.value = false
const message = error instanceof Error ? error.message : '请检查模型资源或网络状态后重试'
setProgress(0, message)
initialModelSettled = true
emit('initialModelFailed', {
view: activeView.value,
floorId: currentFloor.value,
message,
elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined
})
}
}
const handleFloorChange = async (floorId: string) => {
const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId
const isCommittedSameFloor = (
activeView.value === 'floor'
&& currentFloor.value === requestedFloorId
&& activeModel?.userData.floorId === requestedFloorId
&& !isFloorSwitching
)
if (isCommittedSameFloor) {
emit('floorChange', requestedFloorId)
return
}
if (isFloorSwitching && floorSwitchRequestedFloorId === requestedFloorId) {
return
}
const previousLoadToken = modelLoadVersion
try {
isLoading.value = true
loadError.value = false
setProgress(12, `正在切换到 ${formatFloorLabel(requestedFloorId)}...`)
const didCommit = await loadFloor(requestedFloorId, {
preserveCurrentSceneUntilReady: true,
suppressProgress: false,
detachPoiBeforeLoad: true
})
updatePoiVisibilityByDistance()
if (didCommit) {
emit('floorChange', requestedFloorId)
}
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('楼层模型加载失败:', error)
loadError.value = true
restoreCommittedFloorPoiLayer()
setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败')
} finally {
const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1
if (!requestWasSuperseded || floorSwitchRequestedFloorId === requestedFloorId) {
isLoading.value = false
}
}
}
const showOverview = async () => {
// 建筑外观不再作为手动切换入口;只在初始加载与缩放自动切换时展示。
if (activeView.value === 'overview') {
resetCamera()
}
}
const showMultiFloor = async () => {
try {
isLoading.value = true
loadError.value = false
activeFocusPoiId.value = ''
await loadMultiFloor()
isLoading.value = false
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('多层展示模型加载失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '多层展示模型加载失败')
}
}
const retryLoad = () => {
init3DScene()
}
const disposeScene = () => {
isDisposed = true
adjacentPreloadSeq += 1
invalidateModelLoads()
if (animationId) {
window.cancelAnimationFrame(animationId)
animationId = 0
}
const container = getContainerElement()
container?.removeEventListener('pointerdown', handlePointerDown)
container?.removeEventListener('pointerup', handlePointerUp)
renderer?.domElement.removeEventListener('wheel', handleWheelIntent, true)
resizeObserver?.disconnect()
resizeObserver = null
// 清理自动切换相关资源
if (controls) {
controls.removeEventListener('start', handleControlStart)
controls.removeEventListener('end', handleControlEnd)
controls.removeEventListener('change', handleControlChange)
controls.dispose()
}
controls = null
if (autoSwitchDisableTimer) {
clearTimeout(autoSwitchDisableTimer)
autoSwitchDisableTimer = null
}
clearOverviewWheelAutoSwitchTimer()
clearProgrammaticCameraTimer()
cameraTween = null
isProgrammaticCameraChange = false
lastControlDistance = 0
activeControlGestureStartedAt = 0
indoorEntryGestureLocked = false
indoorContextLockedUntil = 0
resetOverviewEntryIntent()
resetFloorExitCandidate()
clearSceneData()
disposePoiMarkerCache()
disposeCachedOverviewModel()
scene?.clear()
scene = null
camera = null
loader = null
dracoLoader?.dispose()
dracoLoader = null
poiGroup = null
routeGroup = null
if (renderer) {
renderer.dispose()
renderer.forceContextLoss()
renderer.domElement.remove()
}
renderer = null
}
defineExpose({
switchFloor: handleFloorChange,
showOverview,
showMultiFloor,
resetCamera,
setCameraPreset,
zoomCamera,
focusTargetPoi: (request: TargetPoiFocusRequest) => {
queueTargetFocus(request)
},
clearSelection: clearMapSelection,
clearRoute: clearRoutePreview,
clearNavigation: () => clearMapSelection(false),
disableAutoSwitchTemporarily
})
watch(() => props.modelSource, () => {
init3DScene()
})
watch(() => [props.showControls, props.touchGestureMode], () => {
syncControlInteractionOptions()
})
watch(() => props.targetFocus, (request) => {
queueTargetFocus(request || null)
}, {
deep: true,
immediate: true
})
watch(() => [props.routePreview, props.showRoute], () => {
renderRoutePreview()
}, {
deep: true
})
const syncRequestedIndoorView = async () => {
if (!scene || !floorIndex.value.length || isLoading.value) return
if (props.initialView === 'multi') {
if (activeView.value !== 'multi') {
await showMultiFloor()
}
return
}
if (props.initialView === 'floor') {
const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId)
|| currentFloor.value
if (requestedFloorId && (activeView.value !== 'floor' || currentFloor.value !== requestedFloorId)) {
await handleFloorChange(requestedFloorId)
}
}
}
watch(() => [props.initialView, props.initialFloorId], () => {
void syncRequestedIndoorView()
})
onMounted(() => {
init3DScene()
})
onUnmounted(() => {
disposeScene()
})
</script>
<style scoped lang="scss">
.three-map-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #f7f8f4;
}
.three-canvas-wrapper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
:deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
touch-action: none;
}
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(247, 248, 244, 0.9);
z-index: 20;
}
.loading-content {
width: min(260px, calc(100vw - 80px));
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 34px;
height: 34px;
border: 3px solid rgba(31, 35, 41, 0.16);
border-top-color: #1f2329;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 13px;
line-height: 18px;
color: #1f2329;
text-align: center;
}
.loading-progress {
width: 100%;
height: 4px;
overflow: hidden;
background: rgba(31, 35, 41, 0.12);
border-radius: 2px;
}
.loading-bar {
height: 100%;
background: #1f8f5f;
transition: width 0.2s ease;
}
.error-overlay {
background: rgba(31, 35, 41, 0.28);
}
.error-content {
padding: 18px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(31, 35, 41, 0.1);
border-radius: 8px;
}
.error-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #1f2329;
}
.error-text {
font-size: 13px;
line-height: 18px;
color: #5f666d;
text-align: center;
}
.retry-btn {
min-width: 96px;
height: 34px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #1f2329;
border-radius: 6px;
}
.retry-text {
font-size: 13px;
line-height: 18px;
font-weight: 600;
color: #ffffff;
}
.map-toolbar {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 12;
}
.overview-btn {
height: 34px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(31, 35, 41, 0.12);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(31, 35, 41, 0.12);
}
.overview-btn.active {
background: #1f2329;
}
.overview-text {
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: #1f2329;
}
.overview-btn.active .overview-text {
color: #ffffff;
}
.poi-detail-popup {
position: absolute;
left: 50%;
bottom: 118px;
width: min(280px, calc(100vw - 64px));
padding: 12px 14px;
transform: translateX(-50%);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(31, 35, 41, 0.1);
border-radius: 8px;
box-shadow: 0 10px 28px rgba(31, 35, 41, 0.16);
z-index: 60;
pointer-events: auto;
}
.poi-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.poi-name {
font-size: 15px;
line-height: 20px;
font-weight: 700;
color: #1f2329;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.poi-floor {
font-size: 12px;
line-height: 16px;
color: #5f666d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>