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