3097 lines
86 KiB
Vue
3097 lines
86 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'
|
|
|
|
type ViewMode = 'overview' | 'floor' | 'multi'
|
|
type CameraPreset = 'top' | 'oblique'
|
|
type TouchGestureMode = 'orbit' | 'pan'
|
|
interface CameraFitOptions {
|
|
distanceFactor?: number
|
|
targetOffsetRatio?: THREE.Vector3
|
|
screenOffsetRatio?: THREE.Vector2
|
|
up?: THREE.Vector3
|
|
}
|
|
|
|
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
|
|
labelBaseScaleX?: number
|
|
labelBaseScaleY?: number
|
|
isPoiLabel?: boolean
|
|
isPoiPulse?: boolean
|
|
isPoiBase?: 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
|
|
}
|
|
|
|
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
|
|
autoSwitchThresholdLow?: number
|
|
autoSwitchThresholdHigh?: number
|
|
autoSwitchCooldown?: number
|
|
}>(), {
|
|
assetBaseUrl: '',
|
|
initialFloorId: 'L1',
|
|
initialView: 'overview',
|
|
showControls: true,
|
|
showPoi: false,
|
|
targetFocus: null,
|
|
touchGestureMode: 'orbit',
|
|
targetFocusDistanceFactor: 0.22,
|
|
routePreview: null,
|
|
showRoute: false,
|
|
autoSwitch: true,
|
|
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((a, b) => b.order - a.order)
|
|
.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 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 floorAutoSwitchProtectedUntil = 0
|
|
let floorExitThresholdExceededSince = 0
|
|
let floorExitZoomOutAccumulatedDistance = 0
|
|
let autoSwitchTemporarilyDisabled = false
|
|
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
|
let isProgrammaticCameraChange = false
|
|
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const floorAutoSwitchProtectionMs = 1600
|
|
const floorExitZoomOutIntentMinDeltaRatio = 0.01
|
|
const floorExitZoomOutAccumulatedRatio = 0.12
|
|
const floorExitThresholdHoldMs = 650
|
|
|
|
const syncControlInteractionOptions = () => {
|
|
if (!controls) return
|
|
|
|
const shouldEnablePan = props.showControls || props.touchGestureMode === 'pan'
|
|
controls.enablePan = shouldEnablePan
|
|
controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE
|
|
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN
|
|
}
|
|
|
|
const corePoiCategories = new Set([
|
|
'basic_service_facility',
|
|
'transport_circulation',
|
|
'accessibility_special_service'
|
|
])
|
|
|
|
const poiCategoryPriority: Record<string, number> = {
|
|
target_preview: 100,
|
|
accessibility_special_service: 88,
|
|
basic_service_facility: 82,
|
|
transport_circulation: 76,
|
|
touring_poi: 58,
|
|
operation_experience: 46
|
|
}
|
|
|
|
const poiGlyphMap: Record<string, string> = {
|
|
accessible_restroom: '无',
|
|
accessibility: '无',
|
|
restroom: '卫',
|
|
water: '水',
|
|
rest_area: '休',
|
|
elevator: '梯',
|
|
stair: '梯',
|
|
experience: '展',
|
|
theater: '演',
|
|
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 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 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)
|
|
)
|
|
|
|
const getActiveModelSpan = () => {
|
|
if (!activeModel) return 1
|
|
|
|
const size = getObjectSize(activeModel)
|
|
return Math.max(size.x, size.y, size.z, 1)
|
|
}
|
|
|
|
const getPoiVisibilityTier = (): PoiVisibilityTier => {
|
|
if (activeView.value === 'floor') return 'full'
|
|
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 (tier === 'tight') {
|
|
return corePoiCategories.has(poi.primaryCategory)
|
|
}
|
|
|
|
if (tier === 'balanced') {
|
|
return 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
|
|
|
|
return selectedBoost + categoryPriority
|
|
}
|
|
|
|
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 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 && (isTooClose || exceedsLimit))) {
|
|
sprite.visible = false
|
|
return
|
|
}
|
|
|
|
sprite.visible = true
|
|
acceptedPositions.push(screenPosition)
|
|
visibleCount += 1
|
|
})
|
|
}
|
|
|
|
const refreshPoiVisibilityByDistance = () => {
|
|
updatePoiVisibilityByDistance()
|
|
}
|
|
|
|
const handleControlChange = () => {
|
|
updatePoiVisibilityByDistance()
|
|
checkAutoSwitch()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 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(0xf1f3ee)
|
|
|
|
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.ACESFilmicToneMapping
|
|
renderer.toneMappingExposure = 1.05
|
|
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, 1.6)
|
|
scene.add(ambientLight)
|
|
|
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.2)
|
|
keyLight.position.set(80, 120, 60)
|
|
scene.add(keyLight)
|
|
|
|
const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.9)
|
|
fillLight.position.set(-80, 60, -80)
|
|
scene.add(fillLight)
|
|
|
|
resizeObserver = new ResizeObserver(handleResize)
|
|
resizeObserver.observe(container)
|
|
container.addEventListener('pointerdown', handlePointerDown)
|
|
container.addEventListener('pointerup', handlePointerUp)
|
|
|
|
// 监听控制器变化,用于自动切换和点位可见层级更新
|
|
if (controls) {
|
|
controls.addEventListener('change', handleControlChange)
|
|
}
|
|
|
|
startRenderLoop()
|
|
}
|
|
|
|
const startRenderLoop = () => {
|
|
const render = () => {
|
|
if (isDisposed || !renderer || !scene || !camera) return
|
|
|
|
controls?.update()
|
|
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 detachPoiMarkerGroups = () => {
|
|
poiMarkerGroupCache.forEach((entry) => {
|
|
entry.group.parent?.remove(entry.group)
|
|
})
|
|
}
|
|
|
|
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 sans-serif'
|
|
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 = () => {
|
|
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) => new Promise<GLTF>((resolve, reject) => {
|
|
if (!loader) {
|
|
reject(new Error('GLTF 加载器未初始化'))
|
|
return
|
|
}
|
|
|
|
loader.load(
|
|
url,
|
|
resolve,
|
|
(event) => {
|
|
if (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 = (index - centerIndex) * 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 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 markFloorAutoSwitchEntry = () => {
|
|
floorAutoSwitchProtectedUntil = Date.now() + floorAutoSwitchProtectionMs
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
updateLastControlDistance()
|
|
}
|
|
|
|
const resetAutoSwitchDistanceTracking = () => {
|
|
floorAutoSwitchProtectedUntil = 0
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
updateLastControlDistance()
|
|
}
|
|
|
|
const checkAutoSwitch = () => {
|
|
// 检查是否启用自动切换
|
|
if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) {
|
|
return
|
|
}
|
|
|
|
// 检查加载锁
|
|
if (isAutoSwitchLocked) {
|
|
emit('autoSwitchBlocked', { reason: 'loading-locked' })
|
|
return
|
|
}
|
|
|
|
// 检查冷却时间
|
|
const now = Date.now()
|
|
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)
|
|
|
|
const enterFloorAt = maxDim * props.autoSwitchThresholdLow
|
|
const exitFloorAt = maxDim * props.autoSwitchThresholdHigh
|
|
|
|
// 判断是否需要切换
|
|
if (activeView.value === 'overview' && distance < enterFloorAt) {
|
|
// 从建筑外观自动切换到单楼层
|
|
isAutoSwitchLocked = true
|
|
lastAutoSwitchTime = now
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
|
|
void runAutoSwitchLoad(
|
|
{
|
|
from: 'overview',
|
|
to: 'floor',
|
|
trigger: 'zoom-in',
|
|
distance
|
|
},
|
|
() => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
|
|
'单楼层模型加载失败'
|
|
)
|
|
return
|
|
}
|
|
|
|
if (activeView.value !== 'floor' || props.targetFocus) {
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
return
|
|
}
|
|
|
|
if (now < floorAutoSwitchProtectedUntil) {
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
return
|
|
}
|
|
|
|
const zoomOutIntentDelta = Math.max(maxDim * floorExitZoomOutIntentMinDeltaRatio, 1)
|
|
const isClearZoomOutIntent = distanceDelta > zoomOutIntentDelta
|
|
const isBeyondExitThreshold = distance > exitFloorAt
|
|
|
|
if (!isBeyondExitThreshold) {
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
return
|
|
}
|
|
|
|
if (isClearZoomOutIntent) {
|
|
floorExitZoomOutAccumulatedDistance += distanceDelta
|
|
if (!floorExitThresholdExceededSince) {
|
|
floorExitThresholdExceededSince = now
|
|
}
|
|
}
|
|
|
|
const requiredZoomOutDistance = Math.max(maxDim * floorExitZoomOutAccumulatedRatio, 8)
|
|
if (
|
|
!floorExitThresholdExceededSince
|
|
|| floorExitZoomOutAccumulatedDistance < requiredZoomOutDistance
|
|
) {
|
|
return
|
|
}
|
|
|
|
if (now - floorExitThresholdExceededSince >= floorExitThresholdHoldMs) {
|
|
// 从单楼层自动切换到完整外围模型
|
|
isAutoSwitchLocked = true
|
|
lastAutoSwitchTime = now
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
|
|
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
|
|
) => {
|
|
try {
|
|
isLoading.value = true
|
|
loadError.value = false
|
|
await loadTask()
|
|
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
|
|
|
|
isProgrammaticCameraChange = true
|
|
if (programmaticCameraTimer) {
|
|
clearTimeout(programmaticCameraTimer)
|
|
}
|
|
|
|
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
|
|
|
|
try {
|
|
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.position.copy(targetCenter).add(direction.normalize().multiplyScalar(distance))
|
|
camera.updateProjectionMatrix()
|
|
|
|
controls.target.copy(targetCenter)
|
|
if (options.screenOffsetRatio) {
|
|
const viewDirection = controls.target.clone().sub(camera.position).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))
|
|
|
|
camera.position.add(screenOffset)
|
|
controls.target.add(screenOffset)
|
|
}
|
|
controls.minDistance = Math.max(2, maxDim * 0.08)
|
|
controls.maxDistance = Math.max(20, maxDim * 8)
|
|
controls.update()
|
|
} finally {
|
|
programmaticCameraTimer = window.setTimeout(() => {
|
|
isProgrammaticCameraChange = false
|
|
programmaticCameraTimer = null
|
|
}, 120)
|
|
}
|
|
}
|
|
|
|
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
|
|
if (!camera || !controls) return
|
|
|
|
const box = 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.16, 1, 0.18)
|
|
: new THREE.Vector3(0.85, 0.62, 1)
|
|
const overviewFitOptions: CameraFitOptions = activeView.value === 'overview'
|
|
? {
|
|
distanceFactor: 1.1,
|
|
screenOffsetRatio: new THREE.Vector2(-0.085, 0.065),
|
|
up: new THREE.Vector3(-0.82, 0, -0.57)
|
|
}
|
|
: {}
|
|
|
|
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') => {
|
|
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
|
|
|
|
offset.setLength(nextDistance)
|
|
camera.position.copy(controls.target).add(offset)
|
|
controls.update()
|
|
lastControlDistance = nextDistance
|
|
|
|
if (direction === 'in' && activeView.value === 'overview' && !isLoading.value && !isAutoSwitchLocked) {
|
|
isAutoSwitchLocked = true
|
|
lastAutoSwitchTime = Date.now()
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
|
|
void runAutoSwitchLoad(
|
|
{
|
|
from: 'overview',
|
|
to: 'floor',
|
|
trigger: 'zoom-in',
|
|
distance: nextDistance
|
|
},
|
|
() => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
|
|
'单楼层模型加载失败'
|
|
)
|
|
return
|
|
}
|
|
|
|
handleControlChange()
|
|
}
|
|
|
|
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)
|
|
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)
|
|
resetAutoSwitchDistanceTracking()
|
|
refreshPoiVisibilityByDistance()
|
|
renderRoutePreview()
|
|
}
|
|
|
|
const attachCachedSharedModel = (
|
|
modelUrl: string,
|
|
name: string,
|
|
loadToken: number,
|
|
floorId?: string
|
|
) => {
|
|
if (!cachedOverviewModel || cachedSharedModelUrl !== modelUrl || !scene) return false
|
|
|
|
const targetScene = scene
|
|
activeModel = cachedOverviewModel
|
|
activeModel.name = name
|
|
activeModel.userData.floorId = floorId || ''
|
|
activeModel.visible = true
|
|
applyModelVisibilityForView(activeModel, floorId ? 'floor' : 'multi', floorId)
|
|
targetScene.add(activeModel)
|
|
assertCurrentModelLoad(loadToken)
|
|
return true
|
|
}
|
|
|
|
const loadFloor = async (floorId: string) => {
|
|
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
|
if (!floor || !scene) return
|
|
|
|
const loadToken = startModelLoad()
|
|
activeView.value = 'floor'
|
|
currentFloor.value = floor.floorId
|
|
clearSceneData()
|
|
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
|
|
|
|
if (!attachCachedSharedModel(floor.modelUrl, `GuideFloorModel_${floor.floorId}`, loadToken, floor.floorId)) {
|
|
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, loadToken)
|
|
assertCurrentModelLoad(loadToken, gltf.scene)
|
|
|
|
const targetScene = scene
|
|
if (!targetScene) {
|
|
disposeObject(gltf.scene)
|
|
throw createStaleModelLoadError()
|
|
}
|
|
|
|
activeModel = gltf.scene
|
|
activeModel.name = `GuideFloorModel_${floor.floorId}`
|
|
activeModel.userData.floorId = floor.floorId
|
|
prepareModel(activeModel)
|
|
if (floor.sharedModelAsset) {
|
|
cacheAutoSwitchOverviewMaxDim(activeModel)
|
|
}
|
|
applyModelVisibilityForView(activeModel, 'floor', floor.floorId)
|
|
if (floor.sharedModelAsset) {
|
|
cachedOverviewModel = activeModel
|
|
cachedSharedModelUrl = floor.modelUrl
|
|
}
|
|
targetScene.add(activeModel)
|
|
}
|
|
|
|
if (!activeModel) throw createStaleModelLoadError()
|
|
fitCameraToObject(activeModel)
|
|
markFloorAutoSwitchEntry()
|
|
|
|
if (shouldRenderPoiMarkers.value) {
|
|
await loadFloorPOIs(floor, loadToken)
|
|
assertCurrentModelLoad(loadToken)
|
|
}
|
|
|
|
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((a, b) => a.order - b.order)
|
|
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 sans-serif'
|
|
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 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 sans-serif'
|
|
context.textAlign = 'left'
|
|
context.textBaseline = 'middle'
|
|
context.fillStyle = '#ffffff'
|
|
context.fillText(getPoiGlyph(poi), 74, 70, 28)
|
|
|
|
context.font = '700 34px sans-serif'
|
|
context.fillStyle = '#1f2329'
|
|
context.fillText(label, 120, 61, 360)
|
|
|
|
context.font = '500 22px sans-serif'
|
|
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 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',
|
|
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
|
|
}
|
|
|
|
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 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 = new THREE.Sprite(createPoiMaterial(poi))
|
|
sprite.position.set(x, y + markerSize * 0.5, z)
|
|
sprite.userData.baseScale = markerSize
|
|
sprite.userData.poi = poi
|
|
sprite.userData.isCorePoi = true
|
|
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
|
poiGroup.add(sprite)
|
|
|
|
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.displayMode = displayMode
|
|
|
|
pois.forEach((poi) => {
|
|
const [x, y, z] = poi.positionGltf!
|
|
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
|
sprite.position.set(x, y + markerSize * 0.5, z)
|
|
sprite.userData.baseScale = markerSize
|
|
sprite.userData.poi = poi
|
|
sprite.userData.isCorePoi = corePoiCategories.has(poi.primaryCategory)
|
|
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
|
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
|
|
group.add(sprite)
|
|
})
|
|
|
|
return group
|
|
}
|
|
|
|
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
|
if (!poiGroup) return
|
|
|
|
detachPoiMarkerGroups()
|
|
if (entry.group.parent !== poiGroup) {
|
|
poiGroup.add(entry.group)
|
|
}
|
|
updatePoiMarkerFocus()
|
|
}
|
|
|
|
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
|
if (!poiGroup) return
|
|
|
|
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
|
|
|
const displayMode = getPoiDisplayMode()
|
|
const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode)
|
|
const cachedEntry = poiMarkerGroupCache.get(markerCacheKey)
|
|
if (cachedEntry) {
|
|
attachPoiMarkerGroup(cachedEntry)
|
|
return
|
|
}
|
|
|
|
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)
|
|
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 handleSceneTap = (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' && poiGroup) {
|
|
const poiHits = raycaster.intersectObjects(getPoiSprites(), false)
|
|
const hit = poiHits.find((item) => (
|
|
item.object.visible
|
|
&& item.object instanceof THREE.Sprite
|
|
&& !isPoiAuxiliarySprite(item.object)
|
|
&& item.object.userData.poi
|
|
))
|
|
|
|
if (hit?.object.userData.poi) {
|
|
const poi = hit.object.userData.poi as RenderPoi
|
|
selectedPOI.value = poi
|
|
activeFocusPoiId.value = poi.id
|
|
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
|
|
|
|
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()
|
|
|
|
camera.position.copy(target).add(direction.multiplyScalar(distance))
|
|
camera.near = Math.max(0.1, maxDim / 1200)
|
|
camera.far = Math.max(10000, maxDim * 20)
|
|
camera.updateProjectionMatrix()
|
|
|
|
controls.target.copy(target)
|
|
controls.minDistance = Math.max(2, maxDim * 0.025)
|
|
controls.maxDistance = Math.max(20, maxDim * 6)
|
|
controls.update()
|
|
}
|
|
|
|
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) {
|
|
isLoading.value = true
|
|
loadError.value = false
|
|
await loadFloor(request.floorId)
|
|
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((a, b) => a.order - b.order)
|
|
|
|
if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
|
|
currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1'
|
|
}
|
|
|
|
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
|
|
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) => {
|
|
try {
|
|
isLoading.value = true
|
|
loadError.value = false
|
|
await loadFloor(floorId)
|
|
updatePoiVisibilityByDistance()
|
|
isLoading.value = false
|
|
emit('floorChange', floorId)
|
|
} 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)
|
|
|
|
resizeObserver?.disconnect()
|
|
resizeObserver = null
|
|
|
|
// 清理自动切换相关资源
|
|
if (controls) {
|
|
controls.removeEventListener('change', handleControlChange)
|
|
controls.dispose()
|
|
}
|
|
controls = null
|
|
|
|
if (autoSwitchDisableTimer) {
|
|
clearTimeout(autoSwitchDisableTimer)
|
|
autoSwitchDisableTimer = null
|
|
}
|
|
|
|
if (programmaticCameraTimer) {
|
|
clearTimeout(programmaticCameraTimer)
|
|
programmaticCameraTimer = null
|
|
}
|
|
isProgrammaticCameraChange = false
|
|
lastControlDistance = 0
|
|
floorAutoSwitchProtectedUntil = 0
|
|
floorExitThresholdExceededSince = 0
|
|
floorExitZoomOutAccumulatedDistance = 0
|
|
|
|
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
|
|
})
|
|
|
|
watch(() => props.initialView, (newView) => {
|
|
if (newView === 'floor' || newView === 'multi') {
|
|
activeView.value = newView
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
init3DScene()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
disposeScene()
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.three-map-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: #f1f3ee;
|
|
}
|
|
|
|
.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(241, 243, 238, 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>
|