4273 lines
122 KiB
Vue
4273 lines
122 KiB
Vue
<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'
|
|
|
|
type ViewMode = 'overview' | 'floor' | 'multi'
|
|
type CameraPreset = 'top' | 'oblique'
|
|
type TouchGestureMode = 'orbit' | 'pan'
|
|
type ZoomCameraSource = 'button' | 'gesture'
|
|
|
|
const CANVAS_FONT_FAMILY = '"HarmonyOS Sans SC", "HarmonyOS Sans", "鸿蒙黑体", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif'
|
|
const FLOOR_CAMERA_DISTANCE_FACTOR = 0.78
|
|
|
|
interface CameraFitOptions {
|
|
distanceFactor?: number
|
|
targetOffsetRatio?: THREE.Vector3
|
|
screenOffsetRatio?: THREE.Vector2
|
|
up?: THREE.Vector3
|
|
}
|
|
|
|
interface LoadFloorOptions {
|
|
preserveCurrentSceneUntilReady?: boolean
|
|
suppressProgress?: boolean
|
|
detachPoiBeforeLoad?: 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
|
|
}
|
|
|
|
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: 'orbit',
|
|
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' }]
|
|
}>()
|
|
|
|
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>()
|
|
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 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
|
|
|
|
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 syncControlInteractionOptions = () => {
|
|
if (!controls) return
|
|
|
|
controls.enablePan = true
|
|
controls.screenSpacePanning = true
|
|
controls.panSpeed = 0.85
|
|
controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE
|
|
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN
|
|
}
|
|
|
|
const corePoiCategories = new Set([
|
|
'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,
|
|
accessibility_special_service: 88,
|
|
basic_service_facility: 82,
|
|
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'
|
|
)
|
|
|
|
const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'
|
|
|
|
const poiGlyphMap: Record<string, string> = {
|
|
accessible_restroom: '无',
|
|
accessibility: '无',
|
|
restroom: '卫',
|
|
water: '水',
|
|
rest_area: '休',
|
|
elevator: '梯',
|
|
stair: '梯',
|
|
experience: '展',
|
|
theater: '演',
|
|
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 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 }) => {
|
|
if (!poi.positionGltf || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
|
|
labelSprite.visible = false
|
|
return
|
|
}
|
|
|
|
updateAmbientLabelScale(labelSprite, poi)
|
|
|
|
const [x, y, z] = poi.positionGltf
|
|
const baseOffset = markerSize * (isHallPoi(poi) ? 1.82 : 1.5)
|
|
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(x, y + baseOffset + offset, 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) {
|
|
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) => {
|
|
loadingProgress.value = Math.min(100, Math.max(0, progress))
|
|
loadingMessage.value = message
|
|
}
|
|
|
|
const staleModelLoadMessage = 'STALE_MODEL_LOAD'
|
|
|
|
const startModelLoad = () => {
|
|
modelLoadVersion += 1
|
|
return modelLoadVersion
|
|
}
|
|
|
|
const invalidateModelLoads = () => {
|
|
modelLoadVersion += 1
|
|
pendingRequestedFloorId = ''
|
|
}
|
|
|
|
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 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(0xf7f8f4)
|
|
|
|
const width = Math.max(1, container.clientWidth)
|
|
const height = Math.max(1, container.clientHeight)
|
|
camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 10000)
|
|
camera.position.set(80, 80, 120)
|
|
|
|
renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
alpha: true,
|
|
powerPreference: 'high-performance'
|
|
})
|
|
renderer.setSize(width, height)
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
|
renderer.toneMapping = THREE.NeutralToneMapping
|
|
renderer.toneMappingExposure = 1.0
|
|
container.appendChild(renderer.domElement)
|
|
|
|
controls = new OrbitControls(camera, renderer.domElement)
|
|
controls.enableDamping = true
|
|
controls.dampingFactor = 0.08
|
|
controls.enableZoom = true
|
|
controls.minDistance = 6
|
|
controls.maxDistance = 1200
|
|
controls.minPolarAngle = 0.08
|
|
controls.maxPolarAngle = Math.PI * 0.48
|
|
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 ambientLight = new THREE.AmbientLight(0xffffff, 0.9)
|
|
scene.add(ambientLight)
|
|
|
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.0)
|
|
keyLight.position.set(80, 120, 60)
|
|
scene.add(keyLight)
|
|
|
|
const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.45)
|
|
fillLight.position.set(-80, 60, -80)
|
|
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 disposeObject = (
|
|
object: THREE.Object3D,
|
|
protectedResources = collectReusableModelResources(null)
|
|
) => {
|
|
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
|
|
&& !protectedResources.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 && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
|
|
disposeMaterialTextures(material, protectedResources, disposedTextures)
|
|
material.dispose()
|
|
disposedMaterials.add(material)
|
|
}
|
|
})
|
|
}
|
|
|
|
if (child instanceof THREE.Sprite) {
|
|
if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
|
|
disposeMaterialTextures(child.material, protectedResources, disposedTextures)
|
|
child.material.dispose()
|
|
disposedMaterials.add(child.material)
|
|
}
|
|
}
|
|
|
|
if (child instanceof THREE.Line) {
|
|
if (
|
|
child.geometry
|
|
&& !protectedResources.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 && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
|
|
disposeMaterialTextures(material, protectedResources, 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()
|
|
clearRoutePreview()
|
|
clearPoiGroupChildren()
|
|
}
|
|
|
|
const disposePoiMarkerCache = () => {
|
|
detachPoiMarkerGroups()
|
|
poiMarkerGroupCache.forEach((entry) => {
|
|
disposeObject(entry.group)
|
|
})
|
|
poiMarkerGroupCache.clear()
|
|
poiDataCache.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) => {
|
|
if (!poi.positionGltf) 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)
|
|
const [x, y, z] = poi.positionGltf
|
|
|
|
mesh.name = 'GuideFocusHallGlow'
|
|
mesh.position.set(x, y + 0.08, 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 loadModel = (
|
|
url: string,
|
|
label: string,
|
|
loadToken?: number,
|
|
options: LoadModelOptions = {}
|
|
) => new Promise<GLTF>((resolve, reject) => {
|
|
if (!loader) {
|
|
reject(new Error('GLTF 加载器未初始化'))
|
|
return
|
|
}
|
|
|
|
loader.load(
|
|
url,
|
|
resolve,
|
|
(event) => {
|
|
if (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return
|
|
|
|
if (event.total > 0) {
|
|
const modelProgress = Math.round((event.loaded / event.total) * 70)
|
|
setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
|
|
} else {
|
|
setProgress(45, label)
|
|
}
|
|
},
|
|
reject
|
|
)
|
|
})
|
|
|
|
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 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
|
|
},
|
|
() => 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<void>,
|
|
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(0.1, maxDim / 1000)
|
|
camera.far = Math.max(10000, 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(2, maxDim * 0.08)
|
|
controls.maxDistance = Math.max(20, 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: new THREE.Vector2(-0.045, 0.02),
|
|
up: new THREE.Vector3(0, 1, 0)
|
|
}
|
|
: activeView.value === 'floor'
|
|
? {
|
|
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
|
|
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 || 1200
|
|
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
|
|
|
|
await loadFloorPOIs(floor, loadToken)
|
|
assertCurrentModelLoad(loadToken)
|
|
}
|
|
|
|
const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
|
|
if (!shouldRenderPoiMarkers.value) return
|
|
|
|
void loadCurrentFloorPoiMarkers(loadToken)
|
|
.then(() => {
|
|
if (!isCurrentModelLoad(loadToken)) return
|
|
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()
|
|
return
|
|
}
|
|
|
|
setProgress(18, '正在加载建筑外观模型...')
|
|
|
|
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载建筑外观模型', 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()
|
|
}
|
|
|
|
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 loadModel(
|
|
floor.modelUrl,
|
|
`正在加载 ${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}`)
|
|
}
|
|
|
|
let poiEntry: PoiMarkerCacheEntry | undefined | null = null
|
|
try {
|
|
poiEntry = shouldRenderPoiMarkers.value
|
|
? await prepareFloorPOIs(floor, loadToken)
|
|
: null
|
|
} catch (error) {
|
|
if (ownsModel) {
|
|
disposeObject(model, protectedResources)
|
|
}
|
|
throw error
|
|
}
|
|
|
|
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
|
|
) => {
|
|
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
|
|
|
|
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)
|
|
}
|
|
|
|
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 loadToken = startModelLoad()
|
|
const requestedFloorId = floor.floorId
|
|
pendingRequestedFloorId = requestedFloorId
|
|
if (options.detachPoiBeforeLoad || activeView.value === 'floor' || currentFloor.value !== requestedFloorId) {
|
|
detachActivePoiLayer()
|
|
}
|
|
|
|
const prepared = await prepareFloorScene(floor, loadToken, options)
|
|
commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
|
|
|
|
if (!activeModel) throw createStaleModelLoadError()
|
|
assertCommittedFloorScene(loadToken, requestedFloorId)
|
|
hasLoadedFloorViewOnce = true
|
|
fitCameraToObject(activeModel)
|
|
markFloorAutoSwitchEntry()
|
|
|
|
refreshPoiVisibilityByDistance()
|
|
renderRoutePreview()
|
|
}
|
|
|
|
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 loadModel(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()
|
|
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 loadModel(floor.modelUrl, `正在加载 ${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()
|
|
}
|
|
|
|
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.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.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.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.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) => {
|
|
if (!poiGroup || !poi.positionGltf) return
|
|
|
|
disposeFocusLabel()
|
|
|
|
const markerSize = getPoiMarkerSize()
|
|
const [x, y, z] = poi.positionGltf
|
|
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
|
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
|
|
updateFocusLabelScale()
|
|
poiGroup.add(activeFocusLabelSprite)
|
|
}
|
|
|
|
const showFocusPoiBase = (poi: RenderPoi) => {
|
|
if (!poiGroup || !poi.positionGltf) return
|
|
|
|
disposeFocusBase()
|
|
|
|
const markerSize = getPoiMarkerSize()
|
|
const [x, y, z] = poi.positionGltf
|
|
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
|
|
activeFocusBaseSprite.position.set(x, y + markerSize * 0.04, z)
|
|
poiGroup.add(activeFocusBaseSprite)
|
|
}
|
|
|
|
const showFocusPoiPulse = (poi: RenderPoi) => {
|
|
if (!poiGroup || !poi.positionGltf) return
|
|
|
|
disposeFocusPulse()
|
|
|
|
const markerSize = getPoiMarkerSize()
|
|
const [x, y, z] = poi.positionGltf
|
|
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
|
|
activeFocusPulseSprite.position.set(x, y + markerSize * 0.15, z)
|
|
poiGroup.add(activeFocusPulseSprite)
|
|
}
|
|
|
|
const showFocusPoiAffordances = (poi: RenderPoi) => {
|
|
showFocusHallHighlight(poi)
|
|
showFocusPoiBase(poi)
|
|
showFocusPoiPulse(poi)
|
|
showFocusPoiLabel(poi)
|
|
}
|
|
|
|
const getPoiColor = (category: string) => {
|
|
const colorMap: Record<string, string> = {
|
|
touring_poi: '#2f6fed',
|
|
exhibition_hall: '#2f6fed',
|
|
exhibition_hall_entrance: '#4659d8',
|
|
basic_service_facility: '#1f8f5f',
|
|
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
|
|
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.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)
|
|
if (!poi?.positionGltf) return null
|
|
|
|
const [x, y, z] = poi.positionGltf
|
|
const markerSize = getPoiMarkerSize()
|
|
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
|
sprite.position.set(x, y + markerSize * 0.5, z)
|
|
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 [x, y, z] = poi.positionGltf!
|
|
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
|
|
sprite.position.set(x, y + markerSize * 0.5, z)
|
|
hitTarget.position.copy(sprite.position)
|
|
if (labelSprite) {
|
|
labelSprite.position.set(x, y + markerSize * 1.82, z)
|
|
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()
|
|
}
|
|
|
|
const prepareFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
|
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 markerSize = getPoiMarkerSize()
|
|
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
|
|
const entry: PoiMarkerCacheEntry = {
|
|
floorId: floor.floorId,
|
|
displayMode,
|
|
pois: validPois,
|
|
group: markerGroup,
|
|
markerSize
|
|
}
|
|
|
|
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 poiHits = raycaster.intersectObjects([...getPoiHitTargets(), ...getPoiSprites()], false)
|
|
const hit = poiHits.find((item) => (
|
|
item.object.visible
|
|
&& item.object instanceof THREE.Sprite
|
|
&& getPoiMarkerFromHit(item.object)
|
|
))
|
|
const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : findNearestPoiMarkerByScreenPoint(event, rect)
|
|
|
|
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) => {
|
|
if (!camera || !controls || !poi.positionGltf) return
|
|
|
|
const [x, y, z] = poi.positionGltf
|
|
const target = new THREE.Vector3(x, y, z)
|
|
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(0.1, maxDim / 1200)
|
|
camera.far = Math.max(10000, maxDim * 20)
|
|
camera.updateProjectionMatrix()
|
|
|
|
controls.minDistance = Math.max(2, maxDim * 0.025)
|
|
controls.maxDistance = Math.max(20, 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)
|
|
|
|
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') {
|
|
await loadFloor(currentFloor.value)
|
|
return
|
|
}
|
|
|
|
if (props.initialView === 'multi') {
|
|
await loadMultiFloor()
|
|
return
|
|
}
|
|
|
|
await loadOverview()
|
|
}
|
|
|
|
const init3DScene = async () => {
|
|
try {
|
|
disposeScene()
|
|
isDisposed = false
|
|
isLoading.value = true
|
|
loadError.value = false
|
|
selectedPOI.value = null
|
|
hasLoadedFloorViewOnce = false
|
|
setProgress(0, '正在初始化三维场景...')
|
|
|
|
await initThree()
|
|
await loadModelPackage()
|
|
|
|
setProgress(100, '馆内三维模型加载完成')
|
|
isLoading.value = false
|
|
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
|
|
} catch (error) {
|
|
if (isStaleModelLoadError(error)) return
|
|
|
|
console.error('馆内 3D 模型资源加载失败:', error)
|
|
loadError.value = true
|
|
isLoading.value = false
|
|
setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试')
|
|
}
|
|
}
|
|
|
|
const handleFloorChange = async (floorId: string) => {
|
|
const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId
|
|
|
|
try {
|
|
isLoading.value = true
|
|
loadError.value = false
|
|
setProgress(12, `正在切换到 ${formatFloorLabel(requestedFloorId)}...`)
|
|
await loadFloor(requestedFloorId, {
|
|
preserveCurrentSceneUntilReady: true,
|
|
suppressProgress: false,
|
|
detachPoiBeforeLoad: true
|
|
})
|
|
updatePoiVisibilityByDistance()
|
|
isLoading.value = false
|
|
emit('floorChange', requestedFloorId)
|
|
} catch (error) {
|
|
if (isStaleModelLoadError(error)) return
|
|
|
|
console.error('楼层模型加载失败:', error)
|
|
loadError.value = true
|
|
isLoading.value = false
|
|
setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败')
|
|
}
|
|
}
|
|
|
|
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
|
|
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: 14;
|
|
}
|
|
|
|
.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>
|