10051 lines
319 KiB
Vue
10051 lines
319 KiB
Vue
<template>
|
||
<view class="three-map-container">
|
||
<view
|
||
ref="containerRef"
|
||
class="three-canvas-wrapper"
|
||
:data-render-loop-state="threeRendererRetainedForTwoD ? 'paused-2d' : 'active-3d'"
|
||
:data-actual-renderer="actualRendererPath"
|
||
>
|
||
<view ref="poiDomLabelLayerRef" class="three-poi-dom-label-layer"></view>
|
||
</view>
|
||
|
||
<WeakNetworkGuideFallback
|
||
v-if="weakNetworkFallbackActive"
|
||
ref="weakNetworkFallbackRef"
|
||
:floor-id="weakNetworkFallbackFloor?.floorId || currentFloor"
|
||
:floor-label="weakNetworkFallbackFloor?.label || formatFloorLabel(currentFloor)"
|
||
:floor-model-version="weakNetworkFallbackFloor?.modelVersion || ''"
|
||
:floors="floorIndex"
|
||
:pois="weakNetworkFallbackPois"
|
||
:route-preview="routePreview"
|
||
:visible-poi-ids="visiblePoiIds"
|
||
:selected-poi-id="selectedPOI?.id || ''"
|
||
:selected-poi="selectedPOI"
|
||
:scene-view="sceneView === 'overview' ? 'overview' : 'floor'"
|
||
:scene-viewport="sceneViewport"
|
||
:presentation="fallbackPresentation || 'weak-network'"
|
||
:show-chrome="fallbackPresentation !== 'two-dimensional' || isWeakNetworkPreviewMode()"
|
||
:route-navigation-active="routeNavigationActive"
|
||
:route-start-selection-active="routeStartSelectionActive"
|
||
:route-selectable-points="routeSelectablePoints"
|
||
@poi-click="handleWeakNetworkPoiClick"
|
||
@floor-change="handleFloorChange"
|
||
@route-start-candidate="emit('routeStartCandidate', $event)"
|
||
@route-start-candidate-rejected="emit('routeStartCandidateRejected')"
|
||
@route-roaming-progress="emit('routeRoamingProgress', $event)"
|
||
@route-roaming-transfer="emit('routeRoamingTransfer', $event)"
|
||
@zoom-intent="handleWeakNetworkZoomIntent"
|
||
@viewport-change="handleWeakNetworkViewportChange"
|
||
@retry="retryLoad"
|
||
/>
|
||
|
||
<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 && !weakNetworkFallbackActive" 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 && activeView === 'floor'"
|
||
:floors="floors"
|
||
:current-floor="currentFloor"
|
||
@floor-change="handleFloorChange"
|
||
/>
|
||
|
||
<view
|
||
v-if="isCameraCalibrationMode && !isCameraCalibrationPanelExpanded"
|
||
class="camera-calibration-toggle"
|
||
data-camera-calibration-action="open"
|
||
@tap="isCameraCalibrationPanelExpanded = true"
|
||
>
|
||
<text>相机</text>
|
||
</view>
|
||
|
||
<view v-if="isCameraCalibrationMode" class="camera-calibration-panel" :class="{ expanded: isCameraCalibrationPanelExpanded }">
|
||
<view class="camera-calibration-header">
|
||
<view>
|
||
<text class="camera-calibration-title">相机校准</text>
|
||
<text class="camera-calibration-subtitle">{{ activeView === 'overview' ? '外观模型' : formatFloorLabel(currentFloor) }}</text>
|
||
</view>
|
||
<view class="camera-calibration-close" data-camera-calibration-action="collapse" @tap="isCameraCalibrationPanelExpanded = false">×</view>
|
||
</view>
|
||
|
||
<view class="camera-calibration-row">
|
||
<text>水平朝向 {{ formatCalibrationValue(cameraCalibration.yaw) }}°</text>
|
||
<slider data-camera-calibration="yaw" :value="cameraCalibration.yaw" :min="-75" :max="75" :step="1" activeColor="#1565C0" @change="updateCameraCalibration('yaw', Number($event.detail.value))" />
|
||
</view>
|
||
<view class="camera-calibration-row">
|
||
<text>三维俯视 {{ formatCalibrationValue(cameraCalibration.elevation) }}°</text>
|
||
<slider data-camera-calibration="elevation" :value="cameraCalibration.elevation" :min="35" :max="75" :step="1" activeColor="#1565C0" @change="updateCameraCalibration('elevation', Number($event.detail.value))" />
|
||
</view>
|
||
<view class="camera-calibration-row">
|
||
<text>FOV {{ formatCalibrationValue(cameraCalibration.fov) }}°</text>
|
||
<slider data-camera-calibration="fov" :value="cameraCalibration.fov" :min="30" :max="60" :step="1" activeColor="#1565C0" @change="updateCameraCalibration('fov', Number($event.detail.value))" />
|
||
</view>
|
||
<view class="camera-calibration-row">
|
||
<text>距离 {{ formatCalibrationValue(cameraCalibration.distance) }}</text>
|
||
<slider data-camera-calibration="distance" :value="cameraCalibration.distance" :min="20" :max="1500" :step="1" activeColor="#1565C0" @change="updateCameraCalibration('distance', Number($event.detail.value))" />
|
||
</view>
|
||
|
||
<view class="camera-calibration-center">
|
||
<text class="camera-calibration-center-title">视觉中心</text>
|
||
<view v-for="key in cameraCalibrationCenterKeys" :key="key" class="camera-calibration-input-row">
|
||
<text>{{ key.toUpperCase() }}</text>
|
||
<input :data-camera-calibration="key" type="text" :value="formatCalibrationValue(cameraCalibration[key])" @input="updateCameraCalibration(key, getCameraCalibrationInputValue($event))" />
|
||
</view>
|
||
</view>
|
||
|
||
<text class="camera-calibration-output">{{ cameraCalibrationSummary }}</text>
|
||
<view class="camera-calibration-actions">
|
||
<view data-camera-calibration-action="reset" @tap="resetCameraCalibration"><text>复位</text></view>
|
||
<view data-camera-calibration-action="copy" @tap="copyCameraCalibration"><text>复制参数</text></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showControls && selectedPOI && !routeStartSelectionActive" class="poi-detail-popup">
|
||
<view class="poi-content">
|
||
<text class="poi-name">{{ presentVisitorPoi({ ...selectedPOI, floorLabel: formatFloorLabel(selectedPOI.floorId), primaryCategory: { label: selectedPOI.primaryCategoryZh } }).displayName }}</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 WeakNetworkGuideFallback from './WeakNetworkGuideFallback.vue'
|
||
import {
|
||
isolateMeshMaterialsForFocus,
|
||
restoreIsolatedFocusMaterials,
|
||
type IsolatedFocusMaterialState
|
||
} from './focusMaterialIsolation'
|
||
import { presentVisitorPoi } from '@/view-models/visitorPoiPresentation'
|
||
import type {
|
||
GuideModelFloorAsset,
|
||
GuideModelRenderPackage,
|
||
GuideModelRouteAsset,
|
||
GuideModelSource,
|
||
GuideRenderPoi
|
||
} from '@/domain/guideModel'
|
||
import type { GuideViewportState } from '@/composables/useGuideSceneState'
|
||
import {
|
||
createGuideBoundaryIntentState,
|
||
GUIDE_AUTO_SWITCH_ENTER_RATIO,
|
||
GUIDE_AUTO_SWITCH_EXIT_RATIO,
|
||
isGuideFloorAutoExitBlocked,
|
||
reduceGuideBoundaryIntent
|
||
} from '@/domain/guideViewport'
|
||
import {
|
||
createGuideObliqueReturnPose,
|
||
createGuideTopViewPose
|
||
} from '@/domain/guideTopView'
|
||
import {
|
||
hasReachedOutdoorExitBoundary,
|
||
OUTDOOR_OUTWARD_INTENT_GRACE_MS
|
||
} from '@/domain/guideSemanticZoom'
|
||
import {
|
||
getOverviewMapLabelDefinition,
|
||
isOverviewMapLabelMatch,
|
||
OVERVIEW_MAP_LABEL_DEFINITIONS,
|
||
type OverviewMapLabelDefinition
|
||
} from '@/domain/overviewMapLabels'
|
||
import type {
|
||
GuideRouteEndpoint,
|
||
GuideRouteFloorSegment,
|
||
GuideRouteResult
|
||
} from '@/domain/museum'
|
||
import {
|
||
compileNavigationScene,
|
||
type NavigationScenePlan
|
||
} from '@/domain/navigationScene'
|
||
import {
|
||
getAmbientFacilityKind,
|
||
getAmbientFacilityLimit,
|
||
getAmbientFacilityTier,
|
||
getPoiDisplayPolicy,
|
||
getPoiMapLabelText,
|
||
getPoiMarkerLimit,
|
||
getPoiScreenSpacing,
|
||
getPoiVisibilityTier as getPoiVisibilityTierForDistanceRatio,
|
||
isPoiVisibilityTierAtLeast,
|
||
type AmbientFacilityKind,
|
||
type PoiVisibilityTier
|
||
} from '@/domain/poiDisplay'
|
||
import {
|
||
ensurePoiIconSprite,
|
||
getPoiIconHref,
|
||
resolvePoiIconKey,
|
||
type PoiIconKey
|
||
} from '@/domain/poiCategories'
|
||
import {
|
||
compareFloorsTopToBottom,
|
||
getFloorSortLevel
|
||
} from '@/domain/guideFloor'
|
||
import {
|
||
guideModelLoadManager,
|
||
type GuideModelResourceSet
|
||
} from '@/services/model/GuideModelLoadManager'
|
||
import { guideModelPersistentCache } from '@/services/model/GuideModelPersistentCache'
|
||
import {
|
||
createBackgroundPreloadScheduler,
|
||
getBackgroundPreloadPolicy,
|
||
getBrowserNetworkInfo
|
||
} from '@/services/performance/backgroundPreloadScheduler'
|
||
import { getGuideRendererPixelRatio } from '@/services/performance/rendererPerformance'
|
||
import { startGuidePerformance } from '@/services/performance/guidePerformance'
|
||
import {
|
||
GuideAutoSwitchStateMachine,
|
||
type GuideAutoSwitchDirection,
|
||
type GuideAutoSwitchInputSource,
|
||
type GuideAutoSwitchRequest
|
||
} from './guideAutoSwitchStateMachine'
|
||
import {
|
||
domLabelBoundsOverlap,
|
||
getDomLabelBounds,
|
||
isDomLabelWithinViewport,
|
||
projectObjectToDom,
|
||
type PoiDomLabelAnchor,
|
||
type PoiDomLabelKind
|
||
} from './poiDomLabels'
|
||
import { applyGuideModelMaterialPolicy } from './modelMaterialPolicy'
|
||
import { loadFloorPoisForInitialRender } from './floorPoiLoader'
|
||
import {
|
||
projectRoutePositionToSurface,
|
||
resolveDominantRouteSurfaceY
|
||
} from './routeSurfaceProjection'
|
||
import {
|
||
resolveRouteStartCandidate,
|
||
toRouteStartCandidatePayload,
|
||
type RouteStartCandidatePayload,
|
||
type RouteStartSelectablePoint
|
||
} from './routeStartCandidateResolver'
|
||
|
||
type ViewMode = 'overview' | 'floor' | 'multi'
|
||
type CameraPreset = 'top' | 'oblique'
|
||
type TouchGestureMode = 'orbit' | 'pan'
|
||
type ZoomCameraSource = 'button' | 'gesture'
|
||
type InteractionPolicyMode = 'display' | 'explore'
|
||
type RenderMode = 'three-d' | 'two-d'
|
||
type FallbackPresentation = 'weak-network' | 'two-dimensional'
|
||
type ActualRendererPath = 'three-d' | 'live-glb-top' | 'webp-fallback'
|
||
|
||
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
|
||
// The approved indoor entry composition is one zoom step closer than the
|
||
// former floor baseline. It is also the outward-zoom boundary for every floor.
|
||
const INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR = 0.56
|
||
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),
|
||
overviewReferenceVerticalOffset: 44,
|
||
// Keep two to three zoom-out levels of the 3D building overview before
|
||
// the semantic boundary hands the visitor to the Tencent outdoor map.
|
||
overviewMaxDistanceFactor: 1.85,
|
||
// The left floor rail and right zoom tools make the geometric center look
|
||
// left-biased. Shift the floor framing right into the visible map area.
|
||
floorScreenOffsetRatio: new THREE.Vector2(-0.05, -0.055)
|
||
}
|
||
} as const
|
||
|
||
// 外观模型采用建筑 54 度方向观察,保留 42 度俯视的空间层次。
|
||
const GUIDE_CAMERA_YAW_DEGREES = 54
|
||
const GUIDE_CAMERA_ELEVATION_DEGREES = 42
|
||
// Route cameras use one GLB-space contract and never derive distance from a
|
||
// floor or composite-model bounding box.
|
||
const ROUTE_PREVIEW_CAMERA_DISTANCE = 310
|
||
const ROUTE_PREVIEW_LOOK_AHEAD = 14
|
||
const ROUTE_NAVIGATION_CAMERA_DISTANCE = 160
|
||
const ROUTE_NAVIGATION_LOOK_AHEAD = 16
|
||
const ROUTE_NAVIGATION_CAMERA_ENTRY_MS = 460
|
||
const ROUTE_NAVIGATION_RECENTER_MS = 620
|
||
const ROUTE_NAVIGATION_RECENTER_MIN_INTERVAL_MS = 2200
|
||
// L1 is the complete indoor footprint. Keep this reference dimension for every
|
||
// floor so partial upper-floor assets do not receive an artificial zoom-in.
|
||
const INDOOR_REFERENCE_FIT_DIMENSION = 270.39
|
||
|
||
const getGuideCameraDirection = () => {
|
||
const yaw = THREE.MathUtils.degToRad(GUIDE_CAMERA_YAW_DEGREES)
|
||
const elevation = THREE.MathUtils.degToRad(GUIDE_CAMERA_ELEVATION_DEGREES)
|
||
const horizontal = Math.cos(elevation)
|
||
|
||
return new THREE.Vector3(
|
||
Math.sin(yaw) * horizontal,
|
||
Math.sin(elevation),
|
||
Math.cos(yaw) * horizontal
|
||
).normalize()
|
||
}
|
||
|
||
const getIndoorReferenceCameraDistance = (
|
||
fov: number = SGS_VISUAL_RENDER_CONFIG.camera.fov
|
||
) => (
|
||
Math.abs(INDOOR_REFERENCE_FIT_DIMENSION / Math.sin(THREE.MathUtils.degToRad(fov) / 2))
|
||
* INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR
|
||
)
|
||
|
||
// 使用调试面板确认的建筑外观中心和基准距离。
|
||
const OVERVIEW_INITIAL_CAMERA_TARGET = new THREE.Vector3(29.0598, -61.8768, 23.9424)
|
||
const OVERVIEW_INITIAL_CAMERA_PARAMS = {
|
||
position: OVERVIEW_INITIAL_CAMERA_TARGET.clone().add(getGuideCameraDirection().multiplyScalar(720)),
|
||
target: OVERVIEW_INITIAL_CAMERA_TARGET,
|
||
zoom: 1
|
||
} as const
|
||
|
||
// Indoor views inherit the exterior's approved visual center. Individual floor
|
||
// bounding-box centers are not visitor-facing camera targets.
|
||
const FLOOR_INITIAL_CAMERA_TARGET = OVERVIEW_INITIAL_CAMERA_TARGET.clone()
|
||
const FLOOR_INITIAL_CAMERA_PARAMS = {
|
||
position: FLOOR_INITIAL_CAMERA_TARGET.clone()
|
||
.add(getGuideCameraDirection().multiplyScalar(getIndoorReferenceCameraDistance())),
|
||
target: FLOOR_INITIAL_CAMERA_TARGET,
|
||
zoom: 1
|
||
} as const
|
||
|
||
// Indoor and exterior share the approved yaw/elevation/FOV contract. Floors
|
||
// vary only by their precomputed target and fitted distance.
|
||
const getIndoorInitialCameraDirection = () => getGuideCameraDirection()
|
||
|
||
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
|
||
durationMs?: number
|
||
immediate?: boolean
|
||
onComplete?: () => void
|
||
}
|
||
|
||
interface LoadFloorOptions {
|
||
preserveCurrentSceneUntilReady?: boolean
|
||
suppressProgress?: boolean
|
||
detachPoiBeforeLoad?: boolean
|
||
preserveRouteRoaming?: boolean
|
||
allowSameFloorReload?: boolean
|
||
cameraSnapshot?: CameraSnapshot
|
||
applyFloorBaseline?: boolean
|
||
onCameraStable?: () => void
|
||
}
|
||
|
||
interface LoadOverviewOptions {
|
||
cameraSnapshot?: CameraSnapshot
|
||
onCameraStable?: () => void
|
||
}
|
||
|
||
interface LoadModelOptions {
|
||
suppressProgress?: boolean
|
||
modelVersion?: string
|
||
}
|
||
|
||
interface CameraTweenState {
|
||
fromPosition: THREE.Vector3
|
||
toPosition: THREE.Vector3
|
||
fromTarget: THREE.Vector3
|
||
toTarget: THREE.Vector3
|
||
startedAt: number
|
||
durationMs: number
|
||
reason?: 'poi-focus'
|
||
onComplete?: () => void
|
||
}
|
||
|
||
interface CameraSnapshot {
|
||
position: THREE.Vector3
|
||
target: THREE.Vector3
|
||
up: THREE.Vector3
|
||
quaternion: THREE.Quaternion
|
||
fov: number
|
||
zoom: number
|
||
near: number
|
||
far: number
|
||
distance: number
|
||
}
|
||
|
||
type ResetViewBaselineOptions = {
|
||
view: 'overview' | 'floor'
|
||
floorId?: string
|
||
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||
}
|
||
|
||
interface FloorViewBaseline {
|
||
floorId: string
|
||
modelUrl: string
|
||
packageEpoch: number
|
||
aspectRatio: number
|
||
boundsSignature: string
|
||
camera: CameraSnapshot
|
||
minDistance: number
|
||
maxDistance: number
|
||
}
|
||
|
||
interface OrbitControlsSnapshot {
|
||
enabled: boolean
|
||
cursor: THREE.Vector3
|
||
enableDamping: boolean
|
||
dampingFactor: number
|
||
enableZoom: boolean
|
||
zoomSpeed: number
|
||
zoomToCursor: boolean
|
||
minDistance: number
|
||
maxDistance: number
|
||
minZoom: number
|
||
maxZoom: number
|
||
minTargetRadius: number
|
||
maxTargetRadius: number
|
||
enableRotate: boolean
|
||
rotateSpeed: number
|
||
minPolarAngle: number
|
||
maxPolarAngle: number
|
||
minAzimuthAngle: number
|
||
maxAzimuthAngle: number
|
||
enablePan: boolean
|
||
panSpeed: number
|
||
screenSpacePanning: boolean
|
||
keyPanSpeed: number
|
||
autoRotate: boolean
|
||
autoRotateSpeed: number
|
||
keys: OrbitControls['keys']
|
||
mouseButtons: OrbitControls['mouseButtons']
|
||
touches: OrbitControls['touches']
|
||
target0: THREE.Vector3
|
||
position0: THREE.Vector3
|
||
zoom0: number
|
||
}
|
||
|
||
type CameraCalibrationKey = 'yaw' | 'elevation' | 'fov' | 'distance' | 'x' | 'y' | 'z'
|
||
|
||
interface CameraCalibrationState {
|
||
yaw: number
|
||
elevation: number
|
||
fov: number
|
||
distance: number
|
||
x: number
|
||
y: number
|
||
z: number
|
||
}
|
||
|
||
// 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。
|
||
const referenceOverviewCameraState: CameraSnapshot = (() => {
|
||
const referenceCamera = new THREE.PerspectiveCamera(
|
||
SGS_VISUAL_RENDER_CONFIG.camera.fov,
|
||
1,
|
||
SGS_VISUAL_RENDER_CONFIG.camera.near,
|
||
SGS_VISUAL_RENDER_CONFIG.camera.far
|
||
)
|
||
referenceCamera.position.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.position)
|
||
referenceCamera.up.set(0, 1, 0)
|
||
referenceCamera.zoom = OVERVIEW_INITIAL_CAMERA_PARAMS.zoom
|
||
referenceCamera.lookAt(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
|
||
referenceCamera.updateProjectionMatrix()
|
||
|
||
return {
|
||
position: referenceCamera.position.clone(),
|
||
target: OVERVIEW_INITIAL_CAMERA_PARAMS.target.clone(),
|
||
up: referenceCamera.up.clone(),
|
||
quaternion: referenceCamera.quaternion.clone(),
|
||
fov: referenceCamera.fov,
|
||
zoom: referenceCamera.zoom,
|
||
near: referenceCamera.near,
|
||
far: referenceCamera.far,
|
||
distance: referenceCamera.position.distanceTo(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
|
||
}
|
||
})()
|
||
|
||
const referenceFloorCameraState: CameraSnapshot = (() => {
|
||
const referenceCamera = new THREE.PerspectiveCamera(
|
||
SGS_VISUAL_RENDER_CONFIG.camera.fov,
|
||
1,
|
||
SGS_VISUAL_RENDER_CONFIG.camera.near,
|
||
SGS_VISUAL_RENDER_CONFIG.camera.far
|
||
)
|
||
referenceCamera.position.copy(FLOOR_INITIAL_CAMERA_PARAMS.position)
|
||
referenceCamera.up.set(0, 1, 0)
|
||
referenceCamera.zoom = FLOOR_INITIAL_CAMERA_PARAMS.zoom
|
||
referenceCamera.lookAt(FLOOR_INITIAL_CAMERA_PARAMS.target)
|
||
referenceCamera.updateProjectionMatrix()
|
||
|
||
return {
|
||
position: referenceCamera.position.clone(),
|
||
target: FLOOR_INITIAL_CAMERA_PARAMS.target.clone(),
|
||
up: referenceCamera.up.clone(),
|
||
quaternion: referenceCamera.quaternion.clone(),
|
||
fov: referenceCamera.fov,
|
||
zoom: referenceCamera.zoom,
|
||
near: referenceCamera.near,
|
||
far: referenceCamera.far,
|
||
distance: referenceCamera.position.distanceTo(FLOOR_INITIAL_CAMERA_PARAMS.target)
|
||
}
|
||
})()
|
||
|
||
type FloorIndexItem = GuideModelFloorAsset
|
||
|
||
interface FloorOption {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
type RenderPoi = GuideRenderPoi
|
||
type PoiDisplayMode = 'core' | 'balanced' | 'detail'
|
||
|
||
interface PoiSpriteUserData {
|
||
poi?: RenderPoi
|
||
baseScale?: number
|
||
hitTargetBaseScale?: number
|
||
visibleMarker?: THREE.Sprite
|
||
hitTarget?: THREE.Sprite
|
||
labelHandle?: PoiDomLabelHandle
|
||
feedbackUntil?: number
|
||
labelBaseScaleX?: number
|
||
labelBaseScaleY?: number
|
||
isPoiLabel?: boolean
|
||
isPoiPulse?: boolean
|
||
isPoiBase?: boolean
|
||
isPoiHitTarget?: boolean
|
||
isCorePoi?: boolean
|
||
usesDomLabelIcon?: boolean
|
||
}
|
||
|
||
interface PoiDomLabelHandle {
|
||
element: HTMLDivElement
|
||
anchor: THREE.Object3D
|
||
poi: RenderPoi
|
||
kind: PoiDomLabelKind
|
||
anchorMode: PoiDomLabelAnchor
|
||
floorId: string
|
||
size: { width: number; height: number }
|
||
active: boolean
|
||
layoutOffset: { x: number; y: number }
|
||
ownsAnchor?: 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
|
||
dataTier: 'fast' | 'full'
|
||
pois: RenderPoi[]
|
||
rawPois?: RenderPoi[]
|
||
group: THREE.Group
|
||
domLabelHandles: PoiDomLabelHandle[]
|
||
markerSize: number
|
||
rawPoiCount?: number
|
||
rawPositionedPoiCount?: number
|
||
filteredCategoryCounts?: Record<string, number>
|
||
}
|
||
|
||
interface PreparedFloorScene {
|
||
floor: FloorIndexItem
|
||
model: THREE.Object3D
|
||
poiEntry: PoiMarkerCacheEntry | null
|
||
ownsModel: boolean
|
||
cacheAsSharedModel: boolean
|
||
protectedResources?: ReusableModelResources
|
||
}
|
||
|
||
interface PreparedFloorModelCacheEntry {
|
||
floorId: string
|
||
modelUrl: string
|
||
model: THREE.Object3D
|
||
}
|
||
|
||
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
||
|
||
type RouteSelectablePoint = RouteStartSelectablePoint
|
||
|
||
const props = withDefaults(defineProps<{
|
||
assetBaseUrl?: string
|
||
modelSource: GuideModelSource
|
||
activeFloor?: string
|
||
initialFloorId?: string
|
||
initialView?: ViewMode
|
||
sceneView?: ViewMode
|
||
showControls?: boolean
|
||
showPoi?: boolean
|
||
visiblePoiIds?: string[] | null
|
||
targetFocus?: TargetPoiFocusRequest | null
|
||
touchGestureMode?: TouchGestureMode
|
||
targetFocusDistanceFactor?: number
|
||
routePreview?: GuideRouteResult | null
|
||
showRoute?: boolean
|
||
routeNavigationActive?: boolean
|
||
routeStartSelectionActive?: boolean
|
||
routeSelectablePoiIds?: string[]
|
||
routeSelectablePoints?: RouteSelectablePoint[]
|
||
renderMode?: RenderMode
|
||
sceneRevision?: number
|
||
sceneViewport?: GuideViewportState | null
|
||
autoSwitch?: boolean
|
||
disableAutoExit?: boolean
|
||
autoSwitchCooldown?: number
|
||
semanticZoomEnabled?: boolean
|
||
}>(), {
|
||
assetBaseUrl: '',
|
||
activeFloor: '',
|
||
initialFloorId: 'L1',
|
||
initialView: 'overview',
|
||
sceneView: undefined,
|
||
showControls: true,
|
||
showPoi: false,
|
||
visiblePoiIds: null,
|
||
targetFocus: null,
|
||
touchGestureMode: 'pan',
|
||
targetFocusDistanceFactor: 0.36,
|
||
routePreview: null,
|
||
showRoute: false,
|
||
routeNavigationActive: false,
|
||
routeStartSelectionActive: false,
|
||
routeSelectablePoiIds: () => [],
|
||
routeSelectablePoints: () => [],
|
||
renderMode: 'three-d',
|
||
sceneRevision: 0,
|
||
sceneViewport: null,
|
||
autoSwitch: true,
|
||
disableAutoExit: false,
|
||
autoSwitchCooldown: 1500,
|
||
semanticZoomEnabled: false
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
floorChange: [floorId: string, sceneRevision?: number]
|
||
poiClick: [poi: RenderPoi]
|
||
routeStartCandidate: [candidate: RouteStartCandidatePayload]
|
||
routeStartCandidateRejected: []
|
||
selectionClear: []
|
||
targetFocus: [result: TargetPoiFocusResult]
|
||
routeRoamingProgress: [event: { remainingMeters: number; progress: number }]
|
||
routeRoamingTransfer: [event: {
|
||
fromFloorId: string
|
||
toFloorId: string
|
||
transferType: string
|
||
connectorName?: string
|
||
}]
|
||
autoSwitch: [event: {
|
||
from: 'overview' | 'floor'
|
||
to: 'overview' | 'floor'
|
||
trigger: 'zoom-in' | 'zoom-out'
|
||
distance: number
|
||
sceneRevision: number
|
||
}]
|
||
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
|
||
semanticExteriorExit: []
|
||
initialModelProgress: [event: InitialModelProgressEvent]
|
||
initialModelReady: [event: { view: ViewMode; floorId?: string; elapsedMs?: number }]
|
||
initialModelFailed: [event: {
|
||
view: ViewMode
|
||
floorId?: string
|
||
message: string
|
||
elapsedMs?: number
|
||
fallbackAvailable?: boolean
|
||
actualRenderMode?: RenderMode
|
||
}]
|
||
renderModeFallback: [event: {
|
||
mode: 'two-d'
|
||
view: 'overview' | 'floor'
|
||
floorId?: string
|
||
reason: string
|
||
sceneRevision: number
|
||
}]
|
||
sceneViewportChange: [viewport: GuideViewportState]
|
||
}>()
|
||
|
||
const containerRef = ref<ContainerRef>(null)
|
||
const poiDomLabelLayerRef = ref<ContainerRef>(null)
|
||
const isLoading = ref(true)
|
||
const loadError = ref(false)
|
||
const weakNetworkFallbackActive = ref(false)
|
||
const threeRendererRetainedForTwoD = ref(false)
|
||
const liveGlbTopActive = ref(false)
|
||
const actualRendererPath = computed<ActualRendererPath>(() => (
|
||
weakNetworkFallbackActive.value
|
||
? 'webp-fallback'
|
||
: liveGlbTopActive.value
|
||
? 'live-glb-top'
|
||
: 'three-d'
|
||
))
|
||
const weakNetworkFallbackPois = ref<GuideRenderPoi[]>([])
|
||
const weakNetworkFallbackPoiCache = new Map<string, GuideRenderPoi[]>()
|
||
let weakNetworkFallbackPoiRequestRevision = 0
|
||
const fallbackPresentation = ref<FallbackPresentation | null>(null)
|
||
const weakNetworkFallbackRef = ref<{
|
||
resetCamera?: () => void
|
||
zoomCamera?: (direction: 'in' | 'out', source?: ZoomCameraSource) => GuideViewportState | void
|
||
getGuideViewportState?: () => GuideViewportState | null
|
||
} | null>(null)
|
||
let pendingWeakNetworkZoom: { direction: 'in' | 'out'; source: ZoomCameraSource } | null = null
|
||
const loadingProgress = ref(0)
|
||
const loadingMessage = ref('正在读取馆内导览资源...')
|
||
const loadingDisplayMessage = '正在加载馆内三维场景'
|
||
const modelLoadErrorTitle = '馆内三维场景加载失败'
|
||
const modelLoadErrorMessage = '可能是网络原因,请重新加载。'
|
||
const setFriendlyModelLoadError = () => {
|
||
setProgress(0, modelLoadErrorMessage)
|
||
}
|
||
const activeView = ref<ViewMode>(props.initialView)
|
||
|
||
// `sceneView` is the page-owned business scene. `initialView` remains only as
|
||
// a compatibility default for existing embedding points.
|
||
const requestedSceneView = computed<ViewMode>(() => props.sceneView || props.initialView)
|
||
|
||
const emitFloorChange = (floorId: string, sceneRevision = props.sceneRevision) => {
|
||
emit('floorChange', floorId, sceneRevision)
|
||
}
|
||
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 isCameraCalibrationMode = ref(
|
||
import.meta.env.DEV
|
||
&& typeof window !== 'undefined'
|
||
&& new URLSearchParams(window.location.search).get('camera-calibration') === '1'
|
||
)
|
||
const isWeakNetworkPreviewMode = () => (
|
||
import.meta.env.DEV
|
||
&& typeof window !== 'undefined'
|
||
&& new URLSearchParams(window.location.search).get('weak-map-preview') === '1'
|
||
)
|
||
const isTwoDimensionalMode = computed(() => (
|
||
props.renderMode === 'two-d' || isWeakNetworkPreviewMode()
|
||
))
|
||
const weakNetworkFallbackFloor = computed(() => {
|
||
if (activeView.value === 'overview') {
|
||
return getWeakNetworkOverviewFloor()
|
||
}
|
||
|
||
return floorIndex.value.find((floor) => floor.floorId === currentFloor.value) || null
|
||
})
|
||
const isCameraCalibrationPanelExpanded = ref(false)
|
||
const cameraCalibration = ref<CameraCalibrationState>({
|
||
yaw: GUIDE_CAMERA_YAW_DEGREES,
|
||
elevation: GUIDE_CAMERA_ELEVATION_DEGREES,
|
||
fov: SGS_VISUAL_RENDER_CONFIG.camera.fov,
|
||
distance: OVERVIEW_INITIAL_CAMERA_PARAMS.position.distanceTo(OVERVIEW_INITIAL_CAMERA_PARAMS.target),
|
||
x: OVERVIEW_INITIAL_CAMERA_PARAMS.target.x,
|
||
y: OVERVIEW_INITIAL_CAMERA_PARAMS.target.y,
|
||
z: OVERVIEW_INITIAL_CAMERA_PARAMS.target.z
|
||
})
|
||
const cameraCalibrationCenterKeys = ['x', 'y', 'z'] as const
|
||
const cameraCalibrationCopyStatus = ref('')
|
||
|
||
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 activeRouteCompositeModel: THREE.Object3D | null = null
|
||
let activeRouteCompositeUrl = ''
|
||
let routeCompositeLoadSignature = ''
|
||
let routeCompositeExitSyncSeq = 0
|
||
const routeFloorSurfaceYCache = new WeakMap<THREE.Object3D, Map<string, number>>()
|
||
const resolvedRouteFloorSurfaceCache = new WeakMap<THREE.Object3D, Set<string>>()
|
||
const routePointSurfaceYCache = new WeakMap<THREE.Object3D, Map<string, number>>()
|
||
let poiGroup: THREE.Group | null = null
|
||
let routeGroup: THREE.Group | null = null
|
||
let activeRoutePreviewSignature = ''
|
||
let routeRoamingStartedAt = 0
|
||
let routeRoamingDurationMs = 0
|
||
let routeRoamingPoints: THREE.Vector3[] = []
|
||
let routeRoamingMarker: THREE.Sprite | null = null
|
||
let routeRoamingSessionActive = false
|
||
let routeRoamingTransitioning = false
|
||
let routeRoamingSegmentIndex = 0
|
||
let routeRoamingCompletedDistance = 0
|
||
let routeRoamingTotalDistance = 0
|
||
let lastRouteRoamingRemainingMeters: number | null = null
|
||
let routeRoamingCameraOffset: THREE.Vector3 | null = null
|
||
let routeRoamingLookAhead = 0
|
||
let routeRoamingLastCameraMoveAt = 0
|
||
|
||
const ensureModelLoader = () => {
|
||
if (!loader) {
|
||
loader = new GLTFLoader()
|
||
}
|
||
if (!dracoLoader) {
|
||
dracoLoader = new DRACOLoader()
|
||
dracoLoader.setDecoderPath('/static/three/draco/')
|
||
}
|
||
loader.setDRACOLoader(dracoLoader)
|
||
return loader
|
||
}
|
||
|
||
const poiDataCache = new Map<string, RenderPoi[]>()
|
||
const poiDataTierCache = new Map<string, 'fast' | 'full'>()
|
||
const poiDataLoadInFlight = new Map<string, Promise<{ floorPois: RenderPoi[]; dataTier: 'fast' | 'full' }>>()
|
||
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
|
||
const poiEnrichmentInFlight = new Map<string, Promise<void>>()
|
||
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
|
||
const poiCoordinateDiagnosticsKeys = new Set<string>()
|
||
let activeFocusDomLabel: PoiDomLabelHandle | null = null
|
||
let poiDomLabelResizeObserver: ResizeObserver | null = null
|
||
const poiDomLabelHandlesByElement = new Map<HTMLElement, PoiDomLabelHandle>()
|
||
const overviewMapLabelHandles: PoiDomLabelHandle[] = []
|
||
let overviewMapLabelOwner: THREE.Object3D | null = null
|
||
let activeFocusPulseSprites: THREE.Sprite[] = []
|
||
let activeFocusBaseSprites: THREE.Sprite[] = []
|
||
let activeFocusStartedDataTier: 'fast' | 'full' | null = null
|
||
let activeFocusHallGlowMesh: THREE.Mesh | null = null
|
||
let activeFocusHallMaterialStates: IsolatedFocusMaterialState[] = []
|
||
let activeFocusModelRootCount = 0
|
||
let activeFocusModelRootNames: string[] = []
|
||
let animationId = 0
|
||
let renderLoopRunning = false
|
||
let resizeObserver: ResizeObserver | null = null
|
||
let isDisposed = false
|
||
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
||
let targetFocusQueue: Promise<unknown> = Promise.resolve()
|
||
let targetFocusGeneration = 0
|
||
let modelLoadVersion = 0
|
||
let sceneInitializationVersion = 0
|
||
let renderModeTransitionRevision = 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 hasActiveUserCameraGesture = false
|
||
let overviewGestureStartDistance = 0
|
||
let semanticOutwardZoomIntentAt = 0
|
||
let semanticExteriorExitRequested = false
|
||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||
let cameraTween: CameraTweenState | null = null
|
||
let buttonAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
|
||
let floorNavigationDistance = 0
|
||
let poiFocusCameraAnimationCount = 0
|
||
let cameraSnapshotRestoreCount = 0
|
||
let adjacentPreloadSeq = 0
|
||
let defaultFloorPreloadSeq = 0
|
||
let weakNetworkFallbackPreloadSeq = 0
|
||
const adjacentPreloadScheduler = createBackgroundPreloadScheduler()
|
||
const defaultFloorPreloadScheduler = createBackgroundPreloadScheduler()
|
||
const poiEnrichmentScheduler = createBackgroundPreloadScheduler()
|
||
let firstModelLoadStartedAt = 0
|
||
let firstModelVisibleReported = false
|
||
let initialModelSettled = false
|
||
let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null
|
||
const floorViewBaselines = new Map<string, FloorViewBaseline>()
|
||
let modelPackageEpoch = 0
|
||
let twoDViewportChangedSinceModeEntry = false
|
||
let weakNetworkBoundaryIntentState = createGuideBoundaryIntentState()
|
||
let liveGlbThreeDCameraSnapshot: CameraSnapshot | null = null
|
||
let liveGlbThreeDControlsSnapshot: OrbitControlsSnapshot | null = null
|
||
|
||
const cameraTweenEnabled = true
|
||
const cameraTweenDurationMs = 480
|
||
const buttonZoomTweenDurationMs = 240
|
||
const programmaticCameraTailMs = 40
|
||
const poiTapFeedbackDurationMs = 180
|
||
const poiHitTargetScaleMultiplier = 4.8
|
||
const poiHitTargetCoreScaleMultiplier = 5.4
|
||
// Keep blank-map taps from being absorbed by a nearby POI. Labels have their
|
||
// full DOM rectangle as the hit area; these radii only cover the small icon.
|
||
const poiScreenHitRadiusPx = 32
|
||
const poiScreenHitRadiusCorePx = 42
|
||
const modelLoadRetryDelaysMs = [300, 900]
|
||
const foregroundModelLoadStallTimeoutMs = 2500
|
||
const foregroundInitialInteractiveBudgetMs = 5000
|
||
const backgroundModelLoadStallTimeoutMs = 6500
|
||
const backgroundModelLoadTotalTimeoutMs = 18000
|
||
const manualAutoSwitchPauseMs = 1500
|
||
// The shared exterior composition remains the indoor return reference, but users
|
||
// need a short indoor inspection band before a deliberate zoom-out exits it.
|
||
// 达到室内外退出阈值后在按钮动画内接续模型切换,避免停留在无效的中间状态。
|
||
const autoSwitchEnterHoldMs = 120
|
||
// Indoor entry framing is the visual baseline. The extra outward range leaves
|
||
// room for ordinary inspection gestures; only the final 3% is the exit band.
|
||
const floorZoomInLimitRatio = 0.2
|
||
const floorZoomOutLimitRatio = 1.18
|
||
|
||
type ThreeMapDiagnosticEvent =
|
||
| 'init-start'
|
||
| 'package-ready'
|
||
| 'model-load-start'
|
||
| 'model-request-start'
|
||
| 'model-request-complete'
|
||
| 'model-parse-complete'
|
||
| 'model-load-complete'
|
||
| 'model-load-failed'
|
||
| 'model-scene-prepare-complete'
|
||
| 'model-commit-complete'
|
||
| 'model-camera-stability'
|
||
| 'model-coordinate-audit'
|
||
| 'auto-switch-request'
|
||
| 'auto-switch-stable'
|
||
| 'first-model-visible'
|
||
| 'poi-background-start'
|
||
| 'poi-background-ready'
|
||
| 'poi-filter-diagnostics'
|
||
| 'poi-coordinate-diagnostics'
|
||
| 'poi-marker-render-diagnostics'
|
||
| 'poi-marker-attach-skipped'
|
||
| 'overview-label-anchor'
|
||
| 'poi-hit-diagnostics'
|
||
| 'route-surface-resolved'
|
||
| 'adjacent-preload-skip'
|
||
| 'default-floor-preload-schedule'
|
||
| 'default-floor-preload-start'
|
||
| 'default-floor-preload-skip'
|
||
| 'default-floor-preload-complete'
|
||
| 'weak-network-model-preload-start'
|
||
| 'weak-network-model-preload-skip'
|
||
| 'weak-network-model-preload-complete'
|
||
| 'weak-network-model-preload-failed'
|
||
|
||
interface BuildingAnchor {
|
||
id: string
|
||
position: THREE.Vector3
|
||
}
|
||
|
||
let referenceBuildingAnchors: BuildingAnchor[] = []
|
||
|
||
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
|
||
hasActiveUserCameraGesture = false
|
||
syncControlInteractionOptions()
|
||
}
|
||
|
||
const syncControlInteractionOptions = (event?: PointerEvent) => {
|
||
if (!controls) return
|
||
|
||
const policy = INTERACTION_POLICY[getInteractionPolicyMode()]
|
||
if (liveGlbTopActive.value) {
|
||
controls.enableRotate = false
|
||
controls.enablePan = policy.allowPan
|
||
controls.enableZoom = policy.allowZoom
|
||
controls.screenSpacePanning = true
|
||
controls.panSpeed = 0.85
|
||
controls.minPolarAngle = 0
|
||
controls.maxPolarAngle = Math.PI
|
||
controls.minAzimuthAngle = Number.NEGATIVE_INFINITY
|
||
controls.maxAzimuthAngle = Number.POSITIVE_INFINITY
|
||
controls.mouseButtons.LEFT = policy.allowPan ? THREE.MOUSE.PAN : null
|
||
controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
|
||
controls.mouseButtons.RIGHT = policy.allowPan ? THREE.MOUSE.PAN : null
|
||
controls.touches.ONE = policy.allowPan ? THREE.TOUCH.PAN : null
|
||
controls.touches.TWO = policy.allowZoom ? THREE.TOUCH.DOLLY_PAN : null
|
||
return
|
||
}
|
||
|
||
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 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 getPoiPolicy = (poi: RenderPoi) => (
|
||
poi.displayPolicy || getPoiDisplayPolicy({
|
||
primaryCategory: poi.primaryCategory,
|
||
iconType: poi.iconType,
|
||
kind: poi.kind
|
||
})
|
||
)
|
||
|
||
const waitForNextVisualFrame = () => new Promise<void>((resolve) => {
|
||
if (typeof requestAnimationFrame === 'function') {
|
||
requestAnimationFrame(() => resolve())
|
||
return
|
||
}
|
||
setTimeout(resolve, 0)
|
||
})
|
||
|
||
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 getWeakNetworkOverviewFloor = (): FloorIndexItem | null => {
|
||
const overviewFloorId = renderPackage.value?.overviewFloorId
|
||
if (!overviewFloorId || !renderPackage.value?.overviewModelUrl) return null
|
||
|
||
// The exterior WebP must never inherit L1 simply because that floor happens
|
||
// to be first in a package. Only an explicitly exterior floor is allowed to
|
||
// supply overview labels.
|
||
const indexedExteriorFloor = floorIndex.value.find((floor) => (
|
||
[floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
|
||
.some((value) => /^(EXTERIOR|OUTDOOR|OUT|室外)$/i.test(String(value || '').trim()))
|
||
))
|
||
if (indexedExteriorFloor) return indexedExteriorFloor
|
||
|
||
// The SDK can omit EXTERIOR from the indoor floor list. Keep this virtual
|
||
// source deliberately separate from the current indoor floor, but retain
|
||
// the real overview floor id so the repository can load the exterior bundle
|
||
// and its space labels instead of querying an unknown alias.
|
||
return {
|
||
floorId: overviewFloorId,
|
||
label: '室外导览',
|
||
order: Number.NEGATIVE_INFINITY,
|
||
modelUrl: renderPackage.value.overviewModelUrl,
|
||
modelUrls: renderPackage.value.overviewModelUrls,
|
||
modelVersion: renderPackage.value.overviewModelVersion,
|
||
sharedModelAsset: true,
|
||
modelMatchKeys: ['EXTERIOR', 'OUTDOOR', '室外']
|
||
}
|
||
}
|
||
|
||
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 getUniqueModelMatchKeys = (values: Array<string | undefined>) => values
|
||
.map((value) => value?.trim() || '')
|
||
.filter(Boolean)
|
||
.filter((value, index, keys) => keys.indexOf(value) === index)
|
||
|
||
const getPoiSourceModelMatchKeys = (poi: RenderPoi) => getUniqueModelMatchKeys([
|
||
poi.sourceObjectName,
|
||
...(poi.mergedSourceObjectNames || [])
|
||
])
|
||
|
||
const getPoiFallbackModelMatchKeys = (poi: RenderPoi) => getUniqueModelMatchKeys([
|
||
`${poi.floorId}_${poi.name}`,
|
||
poi.name
|
||
])
|
||
|
||
const isExactModelNodeNameMatch = (nodeName: string, matchKey: string) => {
|
||
const normalizedName = normalizeModelMatchKey(nodeName)
|
||
const normalizedKey = normalizeModelMatchKey(matchKey)
|
||
if (!normalizedName || !normalizedKey) return false
|
||
if (normalizedName === normalizedKey) return true
|
||
|
||
// Blender may append a .001-style duplicate suffix. Keep this compatibility
|
||
// narrow so numbered devices such as 导览屏1 never match 导览屏10.
|
||
const trimmedName = nodeName.trim().toLowerCase()
|
||
if (!/\.\d{3}$/.test(trimmedName)) return false
|
||
return normalizeModelMatchKey(trimmedName.replace(/\.\d{3}$/, '')) === normalizedKey
|
||
}
|
||
|
||
const doesObjectHierarchyMatchKeys = (object: THREE.Object3D, keys: string[]) => (
|
||
getModelNodeNames(object).some((name) => (
|
||
keys.some((key) => isExactModelNodeNameMatch(name, key))
|
||
))
|
||
)
|
||
|
||
const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
|
||
const sourceKeys = getPoiSourceModelMatchKeys(poi)
|
||
if (sourceKeys.length && doesObjectHierarchyMatchKeys(object, sourceKeys)) return true
|
||
|
||
return doesObjectHierarchyMatchKeys(object, getPoiFallbackModelMatchKeys(poi))
|
||
}
|
||
|
||
const findModelRootByKeys = (keys: string[]): THREE.Object3D | null => {
|
||
if (!activeModel) return null
|
||
|
||
let matchedRoot: THREE.Object3D | null = null
|
||
|
||
activeModel.traverse((child) => {
|
||
if (matchedRoot || child === activeModel) return
|
||
|
||
if (child.name && keys.some((key) => isExactModelNodeNameMatch(child.name, key))) {
|
||
matchedRoot = child
|
||
}
|
||
})
|
||
|
||
if (matchedRoot) return matchedRoot
|
||
|
||
activeModel.traverse((child) => {
|
||
if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return
|
||
|
||
if (doesObjectHierarchyMatchKeys(child, keys)) {
|
||
matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
|
||
}
|
||
})
|
||
|
||
return matchedRoot
|
||
}
|
||
|
||
const findPoiModelRoots = (poi: RenderPoi) => {
|
||
const sourceObjectNames = getPoiSourceModelMatchKeys(poi)
|
||
|
||
const roots = sourceObjectNames
|
||
.map((sourceObjectName) => findModelRootByKeys([sourceObjectName]))
|
||
.filter((root): root is THREE.Object3D => Boolean(root))
|
||
.filter((root, index, candidates) => candidates.indexOf(root) === index)
|
||
|
||
if (roots.length) return roots
|
||
const fallbackRoot = findModelRootByKeys(getPoiFallbackModelMatchKeys(poi))
|
||
return fallbackRoot ? [fallbackRoot] : []
|
||
}
|
||
|
||
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 roundDiagnosticNumber = (value: number) => Math.round(value * 1000) / 1000
|
||
|
||
const getPoiDisplayPosition = (poi: RenderPoi) => {
|
||
if (!poi.positionGltf) return null
|
||
|
||
const [x, y, z] = poi.positionGltf
|
||
return new THREE.Vector3(x, y, z)
|
||
}
|
||
|
||
const getPoiDisplayPositions = (poi: RenderPoi) => {
|
||
const positions = [poi.positionGltf, ...(poi.mergedDisplayPositions || [])]
|
||
.filter((position): position is [number, number, number] => Boolean(position))
|
||
|
||
return positions
|
||
.filter((position, index) => (
|
||
positions.findIndex((candidate) => (
|
||
candidate.every((value, coordinateIndex) => (
|
||
Math.abs(value - position[coordinateIndex]) < 1e-6
|
||
))
|
||
)) === index
|
||
))
|
||
.map(([x, y, z]) => new THREE.Vector3(x, y, z))
|
||
}
|
||
|
||
const getPoiMarkerPositions = (poi: RenderPoi) => {
|
||
const positions = [poi.positionGltf, ...(poi.mergedMarkerPositions || [])]
|
||
.filter((position): position is [number, number, number] => Boolean(position))
|
||
|
||
return positions
|
||
.filter((position, index) => (
|
||
positions.findIndex((candidate) => candidate.every((value, coordinateIndex) => (
|
||
Math.abs(value - position[coordinateIndex]) < 1e-6
|
||
))) === index
|
||
))
|
||
.map(([x, y, z]) => new THREE.Vector3(x, y, z))
|
||
}
|
||
|
||
const recordPoiCoordinateDiagnostics = (
|
||
floorId: string,
|
||
model: THREE.Object3D,
|
||
pois: RenderPoi[]
|
||
) => {
|
||
if (!import.meta.env.DEV) return
|
||
|
||
const box = getObjectBox(model)
|
||
const positionedPois = pois.filter((poi): poi is RenderPoi & { positionGltf: [number, number, number] } => (
|
||
Boolean(poi.positionGltf)
|
||
))
|
||
const coordinateErrors = positionedPois.map((poi) => {
|
||
const [x, y, z] = poi.positionGltf
|
||
const error = Math.hypot(
|
||
Math.max(box.min.x - x, 0, x - box.max.x),
|
||
Math.max(box.min.y - y, 0, y - box.max.y),
|
||
Math.max(box.min.z - z, 0, z - box.max.z)
|
||
)
|
||
return { poi, error }
|
||
})
|
||
const maxCoordinateError = Math.max(0, ...coordinateErrors.map(({ error }) => error))
|
||
const diagnosticsKey = [
|
||
floorId,
|
||
positionedPois.length,
|
||
roundDiagnosticNumber(maxCoordinateError),
|
||
roundDiagnosticNumber(box.min.x),
|
||
roundDiagnosticNumber(box.min.y),
|
||
roundDiagnosticNumber(box.min.z),
|
||
roundDiagnosticNumber(box.max.x),
|
||
roundDiagnosticNumber(box.max.y),
|
||
roundDiagnosticNumber(box.max.z)
|
||
].join(':')
|
||
|
||
if (poiCoordinateDiagnosticsKeys.has(diagnosticsKey)) return
|
||
|
||
poiCoordinateDiagnosticsKeys.add(diagnosticsKey)
|
||
logThreeMapDiagnostic('poi-coordinate-diagnostics', {
|
||
floorId,
|
||
coordinateSpace: 'GLB_METER',
|
||
modelBounds: {
|
||
min: serializeVector3(box.min),
|
||
max: serializeVector3(box.max)
|
||
},
|
||
positionedPoiCount: positionedPois.length,
|
||
maxCoordinateError: roundDiagnosticNumber(maxCoordinateError),
|
||
mismatchedPois: coordinateErrors
|
||
.filter(({ error }) => error > 1e-6)
|
||
.slice(0, 12)
|
||
.map(({ poi, error }) => ({
|
||
poiId: poi.id,
|
||
positionGltf: poi.positionGltf,
|
||
coordinateError: roundDiagnosticNumber(error)
|
||
}))
|
||
})
|
||
}
|
||
|
||
const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
|
||
const mode = getPoiDisplayMode()
|
||
const policy = getPoiPolicy(poi)
|
||
|
||
if (!policy.markerVisible) return false
|
||
if (mode === 'core') return policy.allowOverview
|
||
if (mode === 'balanced') return policy.allowMulti
|
||
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'
|
||
|
||
// Distance tiers must be reachable within the floor navigation range. The
|
||
// model span is useful for fitting a camera, but it is not the user's zoom
|
||
// baseline and varies between floor assets. Use the committed floor entry
|
||
// distance when available so every floor can reach balanced/full labels.
|
||
const referenceDistance = activeView.value === 'floor' && floorNavigationDistance > 0
|
||
? floorNavigationDistance
|
||
: getActiveModelSpan()
|
||
const ratio = controls.getDistance() / referenceDistance
|
||
|
||
return getPoiVisibilityTierForDistanceRatio(ratio)
|
||
}
|
||
|
||
const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
|
||
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') {
|
||
return true
|
||
}
|
||
|
||
const policy = getPoiPolicy(poi)
|
||
return policy.markerVisible
|
||
&& isPoiVisibilityTierAtLeast(tier, policy.minMarkerTier)
|
||
}
|
||
|
||
const isIndoorLandmarkPoi = (poi: RenderPoi) => (
|
||
activeView.value === 'floor'
|
||
&& getPoiPolicy(poi).pinInOverview
|
||
)
|
||
|
||
const isAmbientFacilityPoi = (poi: RenderPoi) => {
|
||
const policy = getPoiPolicy(poi)
|
||
return Boolean(getAmbientFacilityTier(poi.iconType))
|
||
&& policy.labelVisible
|
||
&& !policy.pinInOverview
|
||
}
|
||
|
||
const isRouteEndpointPoi = (poi: RenderPoi) => Boolean(
|
||
props.showRoute
|
||
&& props.routePreview
|
||
&& (poi.id === props.routePreview.start.poiId || poi.id === props.routePreview.end.poiId)
|
||
)
|
||
|
||
interface AmbientFacilityCounts {
|
||
total: number
|
||
balanced: number
|
||
nearOnly: number
|
||
byKind: Map<AmbientFacilityKind, number>
|
||
}
|
||
|
||
const createAmbientFacilityCounts = (): AmbientFacilityCounts => ({
|
||
total: 0,
|
||
balanced: 0,
|
||
nearOnly: 0,
|
||
byKind: new Map()
|
||
})
|
||
|
||
const exceedsAmbientFacilityQuota = (
|
||
poi: RenderPoi,
|
||
tier: PoiVisibilityTier,
|
||
counts: AmbientFacilityCounts
|
||
) => {
|
||
const facilityTier = getAmbientFacilityTier(poi.iconType)
|
||
const facilityKind = getAmbientFacilityKind(poi.iconType)
|
||
if (!facilityTier || !facilityKind) return false
|
||
|
||
if (counts.total >= getAmbientFacilityLimit(tier)) return true
|
||
if ((counts.byKind.get(facilityKind) || 0) >= 2) return true
|
||
if (facilityTier === 'near-only') return tier !== 'full' || counts.nearOnly >= 4
|
||
return counts.balanced >= 4
|
||
}
|
||
|
||
const recordAmbientFacility = (poi: RenderPoi, counts: AmbientFacilityCounts) => {
|
||
const facilityTier = getAmbientFacilityTier(poi.iconType)
|
||
const facilityKind = getAmbientFacilityKind(poi.iconType)
|
||
if (!facilityTier || !facilityKind) return
|
||
|
||
counts.total += 1
|
||
if (facilityTier === 'near-only') counts.nearOnly += 1
|
||
else counts.balanced += 1
|
||
counts.byKind.set(facilityKind, (counts.byKind.get(facilityKind) || 0) + 1)
|
||
}
|
||
|
||
const getPoiPriority = (poi: RenderPoi) => {
|
||
const categoryPriority = getPoiPolicy(poi).priority
|
||
const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
|
||
const hallBoost = activeView.value === 'floor' && isIndoorLandmarkPoi(poi) ? 40 : 0
|
||
const transportPenalty = isTransportPoi(poi) && !isAmbientFacilityPoi(poi) ? 18 : 0
|
||
|
||
return selectedBoost + hallBoost + categoryPriority - transportPenalty
|
||
}
|
||
|
||
const getPoiLabelDensityTier = getPoiVisibilityTier
|
||
|
||
const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
|
||
getPoiPolicy(poi).labelVisible
|
||
)
|
||
|
||
const shouldShowAmbientPoiLabel = (
|
||
poi: RenderPoi,
|
||
markerVisible: boolean,
|
||
densityTier: PoiVisibilityTier
|
||
) => {
|
||
if (props.showRoute && activeView.value === 'multi') return false
|
||
if (!markerVisible || activeView.value !== 'floor') return false
|
||
if (isRouteEndpointPoi(poi)) return true
|
||
if (visiblePoiIdFilter.value !== null && isPoiIncludedByVisibleFilter(poi)) return true
|
||
// 选中对象仍沿用同一枚轻量标签;详情仅由底部信息卡承载,避免出现重复的大浮层。
|
||
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return true
|
||
const policy = getPoiPolicy(poi)
|
||
return policy.labelVisible
|
||
&& isPoiVisibilityTierAtLeast(densityTier, policy.minLabelTier)
|
||
}
|
||
|
||
const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
|
||
if (isIndoorLandmarkPoi(poi)) return getPoiPriority(poi) + 120
|
||
if (isAmbientFacilityPoi(poi)) return getPoiPriority(poi)
|
||
if (isServiceFacilityPoi(poi)) return getPoiPriority(poi) + 20
|
||
|
||
return getPoiPriority(poi)
|
||
}
|
||
|
||
const getProjectedScreenPosition = (object: THREE.Object3D) => {
|
||
if (!camera || !renderer) return null
|
||
const point = projectObjectToDom(
|
||
object,
|
||
camera,
|
||
renderer.domElement.clientWidth,
|
||
renderer.domElement.clientHeight
|
||
)
|
||
return point ? new THREE.Vector2(point.x, point.y) : null
|
||
}
|
||
|
||
const getPoiDomLabelLayerElement = () => {
|
||
const layer = poiDomLabelLayerRef.value
|
||
if (!layer || typeof HTMLElement === 'undefined') return null
|
||
if (layer instanceof HTMLElement) return layer
|
||
const element = '$el' in layer ? layer.$el : null
|
||
return element instanceof HTMLElement ? element : null
|
||
}
|
||
|
||
const updatePoiDomLabelSize = (handle: PoiDomLabelHandle) => {
|
||
const rect = handle.element.getBoundingClientRect()
|
||
if (rect.width && rect.height) {
|
||
handle.size = { width: rect.width, height: rect.height }
|
||
}
|
||
}
|
||
|
||
const setPoiDomLabelVisible = (handle: PoiDomLabelHandle, visible: boolean) => {
|
||
handle.element.style.visibility = visible ? 'visible' : 'hidden'
|
||
handle.element.style.opacity = visible ? '1' : '0'
|
||
}
|
||
|
||
const isPoiScreenPositionInViewport = (
|
||
position: THREE.Vector2,
|
||
width: number,
|
||
height: number,
|
||
padding = 32
|
||
) => position.x >= -padding
|
||
&& position.x <= width + padding
|
||
&& position.y >= -padding
|
||
&& position.y <= height + padding
|
||
|
||
const getScreenCenterDistance = (position: THREE.Vector2, width: number, height: number) => (
|
||
position.distanceTo(new THREE.Vector2(width * 0.5, height * 0.5))
|
||
)
|
||
|
||
const disposePoiDomLabel = (handle: PoiDomLabelHandle | null) => {
|
||
if (!handle) return
|
||
poiDomLabelResizeObserver?.unobserve(handle.element)
|
||
poiDomLabelHandlesByElement.delete(handle.element)
|
||
handle.element.remove()
|
||
if (handle.ownsAnchor) {
|
||
handle.anchor.parent?.remove(handle.anchor)
|
||
}
|
||
}
|
||
|
||
const updatePoiDomLabelPosition = (handle: PoiDomLabelHandle) => {
|
||
if (!camera || !renderer || !handle.active) {
|
||
setPoiDomLabelVisible(handle, false)
|
||
return null
|
||
}
|
||
|
||
const point = projectObjectToDom(
|
||
handle.anchor,
|
||
camera,
|
||
renderer.domElement.clientWidth,
|
||
renderer.domElement.clientHeight
|
||
)
|
||
if (!point) {
|
||
setPoiDomLabelVisible(handle, false)
|
||
return null
|
||
}
|
||
|
||
const positionedPoint = {
|
||
x: point.x + handle.layoutOffset.x,
|
||
y: point.y + handle.layoutOffset.y
|
||
}
|
||
const bounds = getDomLabelBounds(positionedPoint, handle.size, handle.anchorMode)
|
||
if (!isDomLabelWithinViewport(bounds, renderer.domElement.clientWidth, renderer.domElement.clientHeight)) {
|
||
setPoiDomLabelVisible(handle, false)
|
||
return null
|
||
}
|
||
|
||
handle.element.style.transform = `translate3d(${positionedPoint.x}px, ${positionedPoint.y}px, 0) translate(-50%, -100%)`
|
||
return bounds
|
||
}
|
||
|
||
const B2_MOBILE_LABEL_OFFSETS: Record<string, { x: number; y: number }> = {
|
||
// These two authoritative anchors are intentionally close in the B2 model.
|
||
// Their measured mobile positions are moved inward and apart to keep both names legible
|
||
// without changing their GLB coordinates or drawing a leader line.
|
||
'space-354500707765842380': { x: 50, y: -17 },
|
||
'space-354500708231410188': { x: 35, y: 25 }
|
||
}
|
||
|
||
const getPoiLabelPresentationOffset = (poi: RenderPoi) => (
|
||
B2_MOBILE_LABEL_OFFSETS[poi.id] || { x: 0, y: 0 }
|
||
)
|
||
|
||
const updateAmbientPoiLabels = () => {
|
||
updateOverviewMapLabels()
|
||
|
||
if (!poiGroup || !camera || !renderer) return
|
||
|
||
const densityTier = getPoiLabelDensityTier()
|
||
const viewportWidth = renderer.domElement.clientWidth
|
||
const viewportHeight = renderer.domElement.clientHeight
|
||
const acceptedBounds: Array<{
|
||
bounds: ReturnType<typeof getDomLabelBounds>
|
||
spacing: number
|
||
}> = []
|
||
const facilityCounts = createAmbientFacilityCounts()
|
||
const candidates = getPoiSprites()
|
||
.map((sprite) => {
|
||
const poi = sprite.userData.poi as RenderPoi | undefined
|
||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||
|
||
return poi && labelHandle
|
||
? {
|
||
marker: sprite,
|
||
labelHandle,
|
||
poi,
|
||
priority: getPoiAmbientLabelPriority(poi),
|
||
screenPosition: getProjectedScreenPosition(sprite)
|
||
}
|
||
: null
|
||
})
|
||
.filter((candidate): candidate is {
|
||
marker: THREE.Sprite
|
||
labelHandle: PoiDomLabelHandle
|
||
poi: RenderPoi
|
||
priority: number
|
||
screenPosition: THREE.Vector2 | null
|
||
} => Boolean(candidate))
|
||
.sort((left, right) => {
|
||
const pinDifference = Number(getPoiPolicy(right.poi).pinInOverview)
|
||
- Number(getPoiPolicy(left.poi).pinInOverview)
|
||
const priorityDifference = right.priority - left.priority
|
||
if (pinDifference || priorityDifference) return pinDifference || priorityDifference
|
||
|
||
const leftDistance = left.screenPosition
|
||
? getScreenCenterDistance(left.screenPosition, viewportWidth, viewportHeight)
|
||
: Number.POSITIVE_INFINITY
|
||
const rightDistance = right.screenPosition
|
||
? getScreenCenterDistance(right.screenPosition, viewportWidth, viewportHeight)
|
||
: Number.POSITIVE_INFINITY
|
||
return leftDistance - rightDistance
|
||
})
|
||
|
||
candidates.forEach(({ marker, labelHandle, poi }) => {
|
||
labelHandle.element.classList.toggle(
|
||
'three-poi-dom-label--selected',
|
||
poi.id === activeFocusPoiId.value
|
||
)
|
||
const policy = getPoiPolicy(poi)
|
||
const isFacility = isAmbientFacilityPoi(poi)
|
||
const isForced = poi.id === activeFocusPoiId.value
|
||
|| poi.primaryCategory === 'target_preview'
|
||
|| isRouteEndpointPoi(poi)
|
||
|| (visiblePoiIdFilter.value !== null && isPoiIncludedByVisibleFilter(poi))
|
||
labelHandle.element.style.zIndex = policy.pinInOverview ? '3' : isForced ? '2' : '1'
|
||
|
||
if (
|
||
!shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)
|
||
|| (isFacility && !isForced && exceedsAmbientFacilityQuota(poi, densityTier, facilityCounts))
|
||
) {
|
||
setPoiDomLabelVisible(labelHandle, false)
|
||
return
|
||
}
|
||
|
||
labelHandle.layoutOffset = getPoiLabelPresentationOffset(poi)
|
||
const bounds = updatePoiDomLabelPosition(labelHandle)
|
||
if (!bounds) return
|
||
const overlaps = isFacility && !isForced && acceptedBounds.some((accepted) => (
|
||
domLabelBoundsOverlap(
|
||
bounds,
|
||
accepted.bounds,
|
||
Math.max(policy.labelCollisionSpacing, accepted.spacing)
|
||
)
|
||
))
|
||
if (overlaps) {
|
||
setPoiDomLabelVisible(labelHandle, false)
|
||
return
|
||
}
|
||
|
||
setPoiDomLabelVisible(labelHandle, true)
|
||
acceptedBounds.push({ bounds, spacing: policy.labelCollisionSpacing })
|
||
if (isFacility && !isForced) recordAmbientFacility(poi, facilityCounts)
|
||
})
|
||
}
|
||
|
||
const updatePoiVisibilityByDistance = () => {
|
||
if (!poiGroup || !controls || !activeModel || !camera || !renderer) return
|
||
|
||
const tier = getPoiVisibilityTier()
|
||
const limit = getPoiMarkerLimit(tier)
|
||
const spacing = getPoiScreenSpacing(tier)
|
||
const viewportWidth = renderer.domElement.clientWidth
|
||
const viewportHeight = renderer.domElement.clientHeight
|
||
const acceptedPositions: THREE.Vector2[] = []
|
||
let visibleCount = 0
|
||
const facilityCounts = createAmbientFacilityCounts()
|
||
|
||
const candidates = getPoiSprites()
|
||
.map((sprite) => {
|
||
const poi = sprite.userData.poi as RenderPoi | undefined
|
||
const projectedPosition = sprite.getWorldPosition(new THREE.Vector3()).project(camera!)
|
||
const screenPosition = new THREE.Vector2(
|
||
(projectedPosition.x + 1) * viewportWidth * 0.5,
|
||
(1 - projectedPosition.y) * viewportHeight * 0.5
|
||
)
|
||
|
||
return {
|
||
sprite,
|
||
poi,
|
||
screenPosition,
|
||
inViewport: projectedPosition.z >= -1
|
||
&& projectedPosition.z <= 1
|
||
&& isPoiScreenPositionInViewport(screenPosition, viewportWidth, viewportHeight),
|
||
centerDistance: getScreenCenterDistance(screenPosition, viewportWidth, viewportHeight)
|
||
}
|
||
})
|
||
.sort((a, b) => {
|
||
const viewportDifference = Number(b.inViewport) - Number(a.inViewport)
|
||
const priorityDifference = (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0)
|
||
return viewportDifference || priorityDifference || a.centerDistance - b.centerDistance
|
||
})
|
||
|
||
candidates.forEach(({ sprite, poi, screenPosition, inViewport }) => {
|
||
if (!poi || !isPoiIncludedByVisibleFilter(poi) || !inViewport) {
|
||
sprite.visible = false
|
||
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
|
||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||
if (hitTarget) {
|
||
hitTarget.visible = false
|
||
}
|
||
if (labelHandle) {
|
||
setPoiDomLabelVisible(labelHandle, 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' && isIndoorLandmarkPoi(poi)
|
||
const isRouteEndpoint = isRouteEndpointPoi(poi)
|
||
const isFacility = isAmbientFacilityPoi(poi)
|
||
// Indoor landmarks are the stable orientation layer of a floor map.
|
||
// Keep their neutral position marker and name visible at the entry view;
|
||
// density filtering only applies to the remaining service POIs.
|
||
const categoryVisible = isPinnedHall || isRouteEndpoint || shouldShowPoiAtDistance(poi, tier)
|
||
const isTooClose = spacing > 0 && acceptedPositions.some((position) => position.distanceTo(screenPosition) < spacing)
|
||
const exceedsLimit = isFacility
|
||
? exceedsAmbientFacilityQuota(poi, tier, facilityCounts)
|
||
: Number.isFinite(limit) && visibleCount >= limit
|
||
|
||
if (!categoryVisible || (!isSelected && !isPinnedHall && !isRouteEndpoint && (isTooClose || exceedsLimit))) {
|
||
sprite.visible = false
|
||
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
|
||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||
if (hitTarget) {
|
||
hitTarget.visible = false
|
||
}
|
||
if (labelHandle) {
|
||
setPoiDomLabelVisible(labelHandle, false)
|
||
}
|
||
return
|
||
}
|
||
|
||
sprite.visible = true
|
||
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
|
||
if (hitTarget) {
|
||
hitTarget.visible = true
|
||
}
|
||
acceptedPositions.push(screenPosition)
|
||
if (isFacility && !isSelected && !isRouteEndpoint) {
|
||
recordAmbientFacility(poi, facilityCounts)
|
||
} else {
|
||
visibleCount += 1
|
||
}
|
||
})
|
||
|
||
updateAmbientPoiLabels()
|
||
}
|
||
|
||
const refreshPoiVisibilityByDistance = () => {
|
||
updatePoiVisibilityByDistance()
|
||
}
|
||
|
||
const getCameraCalibrationDirection = (yawDegrees: number, elevationDegrees: number) => {
|
||
const yaw = THREE.MathUtils.degToRad(yawDegrees)
|
||
const elevation = THREE.MathUtils.degToRad(elevationDegrees)
|
||
const horizontal = Math.cos(elevation)
|
||
|
||
return new THREE.Vector3(
|
||
Math.sin(yaw) * horizontal,
|
||
Math.sin(elevation),
|
||
Math.cos(yaw) * horizontal
|
||
).normalize()
|
||
}
|
||
|
||
const formatCalibrationValue = (value: number) => (
|
||
Number.isFinite(value) ? Number(value.toFixed(3)).toString() : '-'
|
||
)
|
||
|
||
const getCameraCalibrationInputValue = (event: Event) => {
|
||
const nativeValue = (event.target as HTMLInputElement | null)?.value
|
||
return Number(nativeValue)
|
||
}
|
||
|
||
const syncCameraCalibrationFromCamera = () => {
|
||
if (!camera || !controls) return
|
||
|
||
const offset = camera.position.clone().sub(controls.target)
|
||
const distance = offset.length()
|
||
if (!Number.isFinite(distance) || distance <= 0) return
|
||
|
||
cameraCalibration.value = {
|
||
yaw: THREE.MathUtils.radToDeg(Math.atan2(offset.x, offset.z)),
|
||
elevation: THREE.MathUtils.radToDeg(Math.asin(THREE.MathUtils.clamp(offset.y / distance, -1, 1))),
|
||
fov: camera.fov,
|
||
distance,
|
||
x: controls.target.x,
|
||
y: controls.target.y,
|
||
z: controls.target.z
|
||
}
|
||
}
|
||
|
||
const cameraCalibrationSummary = computed(() => {
|
||
if (cameraCalibrationCopyStatus.value) return cameraCalibrationCopyStatus.value
|
||
|
||
const { yaw, elevation, fov, distance, x, y, z } = cameraCalibration.value
|
||
return `目标 (${formatCalibrationValue(x)}, ${formatCalibrationValue(y)}, ${formatCalibrationValue(z)}) · 距离 ${formatCalibrationValue(distance)} · FOV ${formatCalibrationValue(fov)}° · 朝向 ${formatCalibrationValue(yaw)}°/${formatCalibrationValue(elevation)}°`
|
||
})
|
||
|
||
const applyCameraCalibration = () => {
|
||
if (!camera || !controls) return
|
||
|
||
const calibration = cameraCalibration.value
|
||
const direction = getCameraCalibrationDirection(calibration.yaw, calibration.elevation)
|
||
const target = new THREE.Vector3(calibration.x, calibration.y, calibration.z)
|
||
const distance = Math.max(1, calibration.distance)
|
||
|
||
cancelCameraTween()
|
||
clearProgrammaticCameraTimer()
|
||
isProgrammaticCameraChange = true
|
||
autoSwitchStateMachine.reset(activeView.value)
|
||
|
||
// The calibration tool changes only camera state. Model, POI, route and label
|
||
// coordinates stay in the shared GLB coordinate system.
|
||
const dampingEnabled = controls.enableDamping
|
||
controls.enableDamping = false
|
||
try {
|
||
controls.minDistance = Math.min(controls.minDistance, distance)
|
||
controls.maxDistance = Math.max(controls.maxDistance, distance)
|
||
controls.target.copy(target)
|
||
camera.position.copy(target).add(direction.multiplyScalar(distance))
|
||
camera.up.set(0, 1, 0)
|
||
camera.fov = THREE.MathUtils.clamp(calibration.fov, 30, 60)
|
||
camera.updateProjectionMatrix()
|
||
controls.update()
|
||
camera.updateMatrixWorld()
|
||
} finally {
|
||
controls.enableDamping = dampingEnabled
|
||
isProgrammaticCameraChange = false
|
||
}
|
||
|
||
refreshPoiVisibilityByDistance()
|
||
syncCameraCalibrationFromCamera()
|
||
}
|
||
|
||
const updateCameraCalibration = (key: CameraCalibrationKey, value: number) => {
|
||
if (!Number.isFinite(value)) return
|
||
|
||
const next = { ...cameraCalibration.value }
|
||
switch (key) {
|
||
case 'yaw':
|
||
next.yaw = THREE.MathUtils.clamp(value, -75, 75)
|
||
break
|
||
case 'elevation':
|
||
next.elevation = THREE.MathUtils.clamp(value, 35, 75)
|
||
break
|
||
case 'fov':
|
||
next.fov = THREE.MathUtils.clamp(value, 30, 60)
|
||
break
|
||
case 'distance':
|
||
next.distance = THREE.MathUtils.clamp(value, 20, 1500)
|
||
break
|
||
case 'x':
|
||
case 'y':
|
||
case 'z':
|
||
next[key] = value
|
||
break
|
||
}
|
||
|
||
cameraCalibration.value = next
|
||
cameraCalibrationCopyStatus.value = ''
|
||
applyCameraCalibration()
|
||
}
|
||
|
||
const resetCameraCalibration = () => {
|
||
cameraCalibrationCopyStatus.value = ''
|
||
resetCamera()
|
||
syncCameraCalibrationFromCamera()
|
||
}
|
||
|
||
const copyCameraCalibration = async () => {
|
||
const { yaw, elevation, fov, distance, x, y, z } = cameraCalibration.value
|
||
const parameters = JSON.stringify({
|
||
view: activeView.value,
|
||
floorId: activeView.value === 'floor' ? currentFloor.value : undefined,
|
||
yaw,
|
||
elevation,
|
||
fov,
|
||
distance,
|
||
target: { x, y, z }
|
||
}, null, 2)
|
||
|
||
try {
|
||
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable')
|
||
await navigator.clipboard.writeText(parameters)
|
||
cameraCalibrationCopyStatus.value = '相机参数已复制,可作为镜头契约记录。'
|
||
} catch {
|
||
console.info('[camera-calibration]', parameters)
|
||
cameraCalibrationCopyStatus.value = '浏览器未授权复制,参数已输出到控制台。'
|
||
}
|
||
}
|
||
|
||
const getGuideViewportState = (): GuideViewportState | null => {
|
||
if (weakNetworkFallbackActive.value) {
|
||
return weakNetworkFallbackRef.value?.getGuideViewportState?.() || props.sceneViewport || null
|
||
}
|
||
if (!controls || !camera) return props.sceneViewport || null
|
||
|
||
const distance = controls.getDistance()
|
||
if (!Number.isFinite(distance) || distance <= 0) return props.sceneViewport || null
|
||
const scene = activeView.value === 'overview' ? 'overview' : 'floor'
|
||
const floorId = scene === 'floor' ? currentFloor.value : ''
|
||
const previousRevision = props.sceneViewport
|
||
&& props.sceneViewport.scene === scene
|
||
&& (scene === 'overview' || props.sceneViewport.floorId === floorId)
|
||
? props.sceneViewport.revision
|
||
: -1
|
||
return {
|
||
scene,
|
||
floorId,
|
||
centerX: controls.target.x,
|
||
centerZ: controls.target.z,
|
||
visibleWorldSpan: 2 * distance * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.aspect,
|
||
revision: previousRevision + 1
|
||
}
|
||
}
|
||
|
||
const emitSceneViewportChange = () => {
|
||
const viewport = getGuideViewportState()
|
||
if (viewport) emit('sceneViewportChange', viewport)
|
||
}
|
||
|
||
const applySharedGuideViewport = (viewport: GuideViewportState | null | undefined) => {
|
||
if (!viewport || !camera || !controls) return
|
||
if (
|
||
!Number.isFinite(viewport.centerX)
|
||
|| !Number.isFinite(viewport.centerZ)
|
||
|| !Number.isFinite(viewport.visibleWorldSpan)
|
||
|| viewport.visibleWorldSpan <= 0
|
||
) return
|
||
|
||
const offset = camera.position.clone().sub(controls.target)
|
||
const nextDistance = THREE.MathUtils.clamp(
|
||
viewport.visibleWorldSpan
|
||
/ Math.max(2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.aspect, 1e-6),
|
||
controls.minDistance || 2,
|
||
controls.maxDistance || SGS_VISUAL_RENDER_CONFIG.controls.maxDistance
|
||
)
|
||
if (!Number.isFinite(nextDistance) || nextDistance <= 0 || offset.lengthSq() === 0) return
|
||
|
||
const nextTarget = controls.target.clone()
|
||
nextTarget.x = viewport.centerX
|
||
nextTarget.z = viewport.centerZ
|
||
offset.setLength(nextDistance)
|
||
controls.target.copy(nextTarget)
|
||
camera.position.copy(nextTarget).add(offset)
|
||
controls.update()
|
||
refreshPoiVisibilityByDistance()
|
||
}
|
||
|
||
const handleWeakNetworkViewportChange = (event: {
|
||
viewport: GuideViewportState
|
||
source: 'initialize' | 'interaction'
|
||
}) => {
|
||
if (event.source === 'interaction') twoDViewportChangedSinceModeEntry = true
|
||
emit('sceneViewportChange', event.viewport)
|
||
}
|
||
|
||
const handleControlChange = () => {
|
||
updatePoiVisibilityByDistance()
|
||
if (hasActiveUserCameraGesture) {
|
||
checkAutoSwitch()
|
||
}
|
||
scheduleModelAdjustIdleReport()
|
||
if (isCameraCalibrationMode.value) {
|
||
cameraCalibrationCopyStatus.value = ''
|
||
syncCameraCalibrationFromCamera()
|
||
}
|
||
if (hasActiveUserCameraGesture) emitSceneViewportChange()
|
||
}
|
||
|
||
const clearProgrammaticCameraTimer = () => {
|
||
if (programmaticCameraTimer) {
|
||
clearTimeout(programmaticCameraTimer)
|
||
programmaticCameraTimer = null
|
||
}
|
||
}
|
||
|
||
const endProgrammaticCameraChangeSoon = (onComplete?: () => void) => {
|
||
clearProgrammaticCameraTimer()
|
||
programmaticCameraTimer = window.setTimeout(() => {
|
||
isProgrammaticCameraChange = false
|
||
programmaticCameraTimer = null
|
||
onComplete?.()
|
||
}, programmaticCameraTailMs)
|
||
}
|
||
|
||
const easeInOutCubic = (t: number) => (
|
||
t < 0.5
|
||
? 4 * t * t * t
|
||
: 1 - ((-2 * t + 2) ** 3) / 2
|
||
)
|
||
|
||
const cancelCameraTween = (options: { manual?: boolean } = {}) => {
|
||
const interruptedCameraTween = Boolean(cameraTween)
|
||
if (interruptedCameraTween) {
|
||
cameraTween = null
|
||
}
|
||
if (options.manual) {
|
||
// 手动手势必须立即接管相机,避免相机动画的保护状态继续拦截自动切换。
|
||
isProgrammaticCameraChange = false
|
||
clearProgrammaticCameraTimer()
|
||
|
||
if (interruptedCameraTween) {
|
||
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
|
||
reason?: 'poi-focus'
|
||
onComplete?: () => void
|
||
} = {}
|
||
) => {
|
||
if (!camera || !controls) return
|
||
|
||
const toPosition = position.clone()
|
||
const toTarget = target.clone()
|
||
if (options.reason === 'poi-focus') {
|
||
poiFocusCameraAnimationCount += 1
|
||
}
|
||
isProgrammaticCameraChange = true
|
||
clearProgrammaticCameraTimer()
|
||
|
||
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,
|
||
reason: options.reason,
|
||
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 = userData.usesDomLabelIcon
|
||
? 0
|
||
: Math.min(1, focused ? 1 : 0.92 + progress * 0.08)
|
||
})
|
||
}
|
||
|
||
const handleControlStart = () => {
|
||
const distance = controls?.getDistance()
|
||
if (!isProgrammaticCameraChange && typeof distance === 'number' && Number.isFinite(distance)) {
|
||
hasActiveUserCameraGesture = true
|
||
overviewGestureStartDistance = activeView.value === 'overview' ? distance : 0
|
||
autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
|
||
}
|
||
if (!isProgrammaticCameraChange) {
|
||
hasPendingManualModelAdjustment = true
|
||
clearModelAdjustReportTimer()
|
||
}
|
||
cancelCameraTween({ manual: true })
|
||
}
|
||
|
||
const handleControlEnd = () => {
|
||
requestSemanticExteriorExitFromGesture()
|
||
hasActiveUserCameraGesture = false
|
||
overviewGestureStartDistance = 0
|
||
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
|
||
weakNetworkFallbackPreloadSeq += 1
|
||
adjacentPreloadScheduler.cancel()
|
||
defaultFloorPreloadScheduler.cancel()
|
||
poiEnrichmentScheduler.cancel()
|
||
pendingRequestedFloorId = ''
|
||
floorSwitchLoadToken = 0
|
||
floorSwitchRequestedFloorId = ''
|
||
isFloorSwitching = false
|
||
}
|
||
|
||
const isCurrentModelLoad = (loadToken: number) => (
|
||
loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
|
||
)
|
||
|
||
const isCurrentSceneInitialization = (initializationVersion: number) => (
|
||
initializationVersion === sceneInitializationVersion && !isDisposed
|
||
)
|
||
|
||
const createStaleModelLoadError = () => new Error(staleModelLoadMessage)
|
||
|
||
const isStaleModelLoadError = (error: unknown) => (
|
||
error instanceof Error && error.message === staleModelLoadMessage
|
||
)
|
||
|
||
const startFloorContextTransaction = (requestedFloorId: string) => {
|
||
// A foreground floor request owns model parsing and label preparation. Stop
|
||
// queued speculative work before it can compete for the main thread.
|
||
adjacentPreloadSeq += 1
|
||
defaultFloorPreloadSeq += 1
|
||
weakNetworkFallbackPreloadSeq += 1
|
||
adjacentPreloadScheduler.cancel()
|
||
defaultFloorPreloadScheduler.cancel()
|
||
poiEnrichmentScheduler.cancel()
|
||
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 () => {
|
||
ensurePoiIconSprite()
|
||
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(getGuideRendererPixelRatio(
|
||
undefined,
|
||
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()
|
||
// 首次进入外观或任意单层前,先建立唯一的外观参考投影。
|
||
applyReferenceOverviewCameraState()
|
||
|
||
ensureModelLoader()
|
||
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)
|
||
|
||
poiDomLabelResizeObserver = new ResizeObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
const handle = poiDomLabelHandlesByElement.get(entry.target as HTMLElement)
|
||
if (handle) updatePoiDomLabelSize(handle)
|
||
})
|
||
})
|
||
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 = () => {
|
||
if (renderLoopRunning || document.visibilityState !== 'visible') return
|
||
renderLoopRunning = true
|
||
|
||
const render = () => {
|
||
if (
|
||
isDisposed
|
||
|| document.visibilityState !== 'visible'
|
||
|| !renderer
|
||
|| !scene
|
||
|| !camera
|
||
) {
|
||
renderLoopRunning = false
|
||
animationId = 0
|
||
return
|
||
}
|
||
|
||
if (cameraTween) {
|
||
updateCameraTween(performance.now())
|
||
} else {
|
||
controls?.update()
|
||
}
|
||
const now = performance.now()
|
||
updatePoiTapFeedback(now)
|
||
updateRouteRoaming(now)
|
||
updateAmbientPoiLabels()
|
||
updateFocusLabelScale()
|
||
renderer.render(scene, camera)
|
||
animationId = window.requestAnimationFrame(render)
|
||
}
|
||
|
||
render()
|
||
}
|
||
|
||
const stopRenderLoop = () => {
|
||
renderLoopRunning = false
|
||
if (!animationId) return
|
||
|
||
window.cancelAnimationFrame(animationId)
|
||
animationId = 0
|
||
}
|
||
|
||
const handleResize = () => {
|
||
const container = getContainerElement()
|
||
if (!container || !camera || !renderer) return
|
||
|
||
const width = container.clientWidth
|
||
const height = container.clientHeight
|
||
// Kept-alive uni-app pages briefly report a zero-sized canvas while a detail page is on top.
|
||
// Treat that as hidden state instead of a real aspect-ratio change, otherwise a return reset
|
||
// would rebuild the floor baseline against a synthetic 1:1 viewport.
|
||
if (width <= 1 || height <= 1) return
|
||
const nextAspect = width / height
|
||
if (Math.abs(camera.aspect - nextAspect) > 1e-6) clearFloorViewBaselines()
|
||
camera.aspect = nextAspect
|
||
camera.updateProjectionMatrix()
|
||
renderer.setSize(width, height)
|
||
updateAmbientPoiLabels()
|
||
}
|
||
|
||
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,
|
||
minimumHitRadius = 0
|
||
) => {
|
||
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 = Math.max(
|
||
isCorePoi ? poiScreenHitRadiusCorePx : poiScreenHitRadiusPx,
|
||
minimumHitRadius
|
||
)
|
||
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 distanceToRect = (x: number, y: number, rect: DOMRect) => {
|
||
const dx = Math.max(rect.left - x, 0, x - rect.right)
|
||
const dy = Math.max(rect.top - y, 0, y - rect.bottom)
|
||
return Math.hypot(dx, dy)
|
||
}
|
||
|
||
// POI 名称使用 DOM 标签绘制,标签层为了不抢占地图拖拽事件而设置了
|
||
// pointer-events:none。因此点击文字时事件仍会落到 canvas,所有选择模式都
|
||
// 必须先用标签实际矩形反查对应 marker,不能只依赖 3D 精灵的深度命中。
|
||
const findPoiMarkerByDomLabel = (event: PointerEvent) => {
|
||
const pointer = { x: event.clientX, y: event.clientY }
|
||
return Array.from(poiDomLabelHandlesByElement.values())
|
||
.filter((handle) => (
|
||
handle.active
|
||
&& handle.kind === 'ambient'
|
||
&& handle.floorId === currentFloor.value
|
||
&& handle.element.style.visibility !== 'hidden'
|
||
&& handle.element.style.opacity !== '0'
|
||
))
|
||
.map((handle) => {
|
||
const labelRect = handle.element.getBoundingClientRect()
|
||
if (!labelRect.width || !labelRect.height) return null
|
||
|
||
const distance = distanceToRect(pointer.x, pointer.y, labelRect)
|
||
if (distance > 10) return null
|
||
|
||
const marker = handle.anchor instanceof THREE.Sprite
|
||
? handle.anchor
|
||
: findPoiSprite(handle.poi.id)
|
||
if (!marker || !marker.visible) return null
|
||
|
||
return {
|
||
marker,
|
||
distance,
|
||
priority: getPoiPriority(handle.poi)
|
||
}
|
||
})
|
||
.filter((candidate): candidate is {
|
||
marker: THREE.Sprite
|
||
distance: number
|
||
priority: number
|
||
} => Boolean(candidate))
|
||
.sort((left, right) => (left.distance - right.distance) || (right.priority - left.priority))[0]?.marker || null
|
||
}
|
||
|
||
const detachPoiMarkerGroups = () => {
|
||
poiMarkerGroupCache.forEach((entry) => {
|
||
entry.group.parent?.remove(entry.group)
|
||
entry.domLabelHandles.forEach((handle) => {
|
||
handle.active = false
|
||
setPoiDomLabelVisible(handle, false)
|
||
})
|
||
})
|
||
|
||
if (poiGroup) {
|
||
poiGroup.userData.floorId = ''
|
||
poiGroup.userData.currentFloor = ''
|
||
}
|
||
}
|
||
|
||
const detachActivePoiLayer = (options: { preserveRouteRoaming?: boolean } = {}) => {
|
||
disposeFocusLabel()
|
||
disposeFocusPulse()
|
||
disposeFocusBase()
|
||
clearFocusHallHighlight()
|
||
clearRoutePreview({ preserveRoaming: options.preserveRouteRoaming })
|
||
detachPoiMarkerGroups()
|
||
}
|
||
|
||
const disposePoiMarkerCache = () => {
|
||
detachPoiMarkerGroups()
|
||
poiMarkerGroupCache.forEach((entry) => {
|
||
entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
|
||
disposeObject(entry.group)
|
||
})
|
||
poiMarkerGroupCache.clear()
|
||
poiDataCache.clear()
|
||
poiDataTierCache.clear()
|
||
poiDataLoadInFlight.clear()
|
||
poiEnrichmentInFlight.clear()
|
||
poiCoordinateDiagnosticsKeys.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(restoreIsolatedFocusMaterials)
|
||
|
||
activeFocusHallMaterialStates = []
|
||
activeFocusModelRootCount = 0
|
||
activeFocusModelRootNames = []
|
||
|
||
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) => {
|
||
const color = getMaterialColor(material)
|
||
const emissive = getMaterialEmissive(material)
|
||
const emissiveIntensity = getMaterialEmissiveIntensity(material)
|
||
const mappedMaterial = material as THREE.Material & {
|
||
map?: THREE.Texture | null
|
||
vertexColors?: boolean
|
||
}
|
||
|
||
const glowColor = new THREE.Color('#f2e600')
|
||
|
||
// Selection uses cloned materials. Removing their color map makes the
|
||
// direct Mesh highlight readable even when the source texture is blue/green;
|
||
// the original texture remains untouched and is restored on deselection.
|
||
if ('map' in mappedMaterial) mappedMaterial.map = null
|
||
if ('vertexColors' in mappedMaterial) mappedMaterial.vertexColors = false
|
||
|
||
if (color) {
|
||
color.copy(glowColor)
|
||
}
|
||
|
||
if (emissive) {
|
||
emissive.copy(glowColor)
|
||
setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.92))
|
||
}
|
||
|
||
material.opacity = 1
|
||
material.transparent = true
|
||
material.depthTest = false
|
||
material.depthWrite = false
|
||
material.needsUpdate = true
|
||
}
|
||
|
||
const showFocusHallHighlight = (poi: RenderPoi) => {
|
||
clearFocusHallHighlight()
|
||
if (!activeModel) return
|
||
|
||
const modelRoots = findPoiModelRoots(poi)
|
||
if (!modelRoots.length) return
|
||
activeFocusModelRootNames = modelRoots.map((root) => root.name).filter(Boolean)
|
||
|
||
const isolatedMeshes = new Set<THREE.Mesh>()
|
||
let matchedMeshCount = 0
|
||
|
||
modelRoots.forEach((modelRoot) => {
|
||
let rootMeshCount = 0
|
||
modelRoot.traverse((child) => {
|
||
if (!(child instanceof THREE.Mesh) || !child.visible || isolatedMeshes.has(child)) return
|
||
|
||
isolatedMeshes.add(child)
|
||
rootMeshCount += 1
|
||
matchedMeshCount += 1
|
||
const materialState = isolateMeshMaterialsForFocus(child)
|
||
activeFocusHallMaterialStates.push(materialState)
|
||
materialState.clonedMaterials.forEach(applyFocusHallMaterial)
|
||
})
|
||
if (rootMeshCount > 0) activeFocusModelRootCount += 1
|
||
})
|
||
|
||
if (!matchedMeshCount) {
|
||
clearFocusHallHighlight()
|
||
}
|
||
}
|
||
|
||
const disposeCachedOverviewModel = () => {
|
||
if (!cachedOverviewModel) return
|
||
|
||
disposeObject(cachedOverviewModel)
|
||
cachedOverviewModel = null
|
||
cachedSharedModelUrl = ''
|
||
}
|
||
|
||
const disposePreparedFloorModelCache = () => {
|
||
preparedFloorModelCache.forEach((entry) => {
|
||
disposeObject(entry.model)
|
||
})
|
||
preparedFloorModelCache.clear()
|
||
}
|
||
|
||
const clearRoutePreview = (options: { preserveRoaming?: boolean } = {}) => {
|
||
activeRoutePreviewSignature = ''
|
||
routeRoamingMarker = null
|
||
if (!options.preserveRoaming) {
|
||
routeRoamingStartedAt = 0
|
||
routeRoamingDurationMs = 0
|
||
routeRoamingPoints = []
|
||
routeRoamingSessionActive = false
|
||
routeRoamingTransitioning = false
|
||
routeRoamingSegmentIndex = 0
|
||
routeRoamingCompletedDistance = 0
|
||
routeRoamingTotalDistance = 0
|
||
lastRouteRoamingRemainingMeters = null
|
||
routeRoamingCameraOffset = null
|
||
routeRoamingLookAhead = 0
|
||
routeRoamingLastCameraMoveAt = 0
|
||
}
|
||
if (!routeGroup) return
|
||
|
||
while (routeGroup.children.length) {
|
||
const child = routeGroup.children[0]
|
||
routeGroup.remove(child)
|
||
disposeObject(child)
|
||
}
|
||
}
|
||
|
||
const getRouteFloorModel = (floorId?: string) => {
|
||
if (!floorId || !activeModel) return null
|
||
const resolvedFloorId = resolveFloorIdFromRequest(floorId) || floorId
|
||
if (activeRouteCompositeModel && activeModel === activeRouteCompositeModel) {
|
||
return activeModel
|
||
}
|
||
if (activeView.value === 'multi') {
|
||
return activeModel.children.find((child) => child.userData.floorId === resolvedFloorId) || null
|
||
}
|
||
return currentFloor.value === resolvedFloorId ? activeModel : null
|
||
}
|
||
|
||
const getCachedRouteFloorSurfaceY = (
|
||
floorModel: THREE.Object3D,
|
||
floorId?: string
|
||
) => routeFloorSurfaceYCache.get(floorModel)?.get(floorId || '')
|
||
|
||
const cacheRouteFloorSurfaceY = (
|
||
floorModel: THREE.Object3D,
|
||
floorId: string | undefined,
|
||
surfaceY: number,
|
||
resolvedFromRoute = false
|
||
) => {
|
||
const floorKey = floorId || ''
|
||
let surfaceByFloor = routeFloorSurfaceYCache.get(floorModel)
|
||
if (!surfaceByFloor) {
|
||
surfaceByFloor = new Map<string, number>()
|
||
routeFloorSurfaceYCache.set(floorModel, surfaceByFloor)
|
||
}
|
||
surfaceByFloor.set(floorKey, surfaceY)
|
||
if (resolvedFromRoute) {
|
||
let resolvedFloors = resolvedRouteFloorSurfaceCache.get(floorModel)
|
||
if (!resolvedFloors) {
|
||
resolvedFloors = new Set<string>()
|
||
resolvedRouteFloorSurfaceCache.set(floorModel, resolvedFloors)
|
||
}
|
||
resolvedFloors.add(floorKey)
|
||
}
|
||
}
|
||
|
||
const getRouteFloorSurfaceY = (floorId?: string) => {
|
||
const floorModel = getRouteFloorModel(floorId)
|
||
if (!floorModel) return 0
|
||
|
||
const cachedSurfaceY = getCachedRouteFloorSurfaceY(floorModel, floorId)
|
||
if (typeof cachedSurfaceY === 'number') return cachedSurfaceY
|
||
|
||
// This is only an emergency fallback. Route segments normally prime the
|
||
// exact walkable plane before any line, marker or roaming point is rendered.
|
||
const surfaceY = getObjectBox(floorModel).getCenter(new THREE.Vector3()).y
|
||
const resolvedSurfaceY = Number.isFinite(surfaceY) ? surfaceY : 0
|
||
cacheRouteFloorSurfaceY(floorModel, floorId, resolvedSurfaceY)
|
||
return resolvedSurfaceY
|
||
}
|
||
|
||
const primeRouteFloorSurfaces = (segments: GuideRouteFloorSegment[]) => {
|
||
const positionsByFloor = new Map<string, Array<[number, number, number]>>()
|
||
segments.forEach((segment) => {
|
||
const positions = positionsByFloor.get(segment.floorId) || []
|
||
positions.push(...segment.points.map((point) => point.position))
|
||
positionsByFloor.set(segment.floorId, positions)
|
||
})
|
||
|
||
positionsByFloor.forEach((positions, floorId) => {
|
||
const floorModel = getRouteFloorModel(floorId)
|
||
if (!floorModel || !positions.length) return
|
||
if (resolvedRouteFloorSurfaceCache.get(floorModel)?.has(floorId)) return
|
||
const modelBounds = getObjectBox(floorModel)
|
||
const surfaceY = resolveDominantRouteSurfaceY(
|
||
floorModel,
|
||
modelBounds,
|
||
positions
|
||
)
|
||
if (surfaceY === null) return
|
||
cacheRouteFloorSurfaceY(floorModel, floorId, surfaceY, true)
|
||
const sourceYs = positions.map((position) => Number(position[1]) || 0)
|
||
logThreeMapDiagnostic('route-surface-resolved', {
|
||
floorId,
|
||
pointCount: positions.length,
|
||
surfaceY: Number(surfaceY.toFixed(4)),
|
||
sourceYMin: Number(Math.min(...sourceYs).toFixed(4)),
|
||
sourceYMax: Number(Math.max(...sourceYs).toFixed(4)),
|
||
modelYMin: Number(modelBounds.min.y.toFixed(4)),
|
||
modelYMax: Number(modelBounds.max.y.toFixed(4))
|
||
})
|
||
})
|
||
}
|
||
|
||
const getRoutePointSurfaceY = (
|
||
position: [number, number, number],
|
||
floorId?: string
|
||
) => {
|
||
const floorModel = getRouteFloorModel(floorId)
|
||
if (!floorModel) return getRouteFloorSurfaceY(floorId)
|
||
|
||
const cacheKey = `${floorId || ''}:${position[0].toFixed(3)},${position[2].toFixed(3)}`
|
||
let surfaceByPoint = routePointSurfaceYCache.get(floorModel)
|
||
if (!surfaceByPoint) {
|
||
surfaceByPoint = new Map<string, number>()
|
||
routePointSurfaceYCache.set(floorModel, surfaceByPoint)
|
||
}
|
||
const cachedSurfaceY = surfaceByPoint.get(cacheKey)
|
||
if (typeof cachedSurfaceY === 'number') return cachedSurfaceY
|
||
|
||
const preferredSurfaceY = getCachedRouteFloorSurfaceY(floorModel, floorId)
|
||
const projected = projectRoutePositionToSurface(
|
||
floorModel,
|
||
getObjectBox(floorModel),
|
||
position,
|
||
preferredSurfaceY,
|
||
0
|
||
)
|
||
const surfaceY = projected?.y ?? getRouteFloorSurfaceY(floorId)
|
||
surfaceByPoint.set(cacheKey, surfaceY)
|
||
return surfaceY
|
||
}
|
||
|
||
const routePointToVector = (
|
||
position: [number, number, number],
|
||
floorId?: string
|
||
) => {
|
||
// WALK routes are a 2D X/Z network. Their legacy Y field is not a GLB world
|
||
// elevation, so every route visual must resolve height from the floor model.
|
||
const floorY = getRoutePointSurfaceY(position, floorId)
|
||
return new THREE.Vector3(
|
||
position[0],
|
||
floorY + 0.24,
|
||
position[2]
|
||
)
|
||
}
|
||
|
||
const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
|
||
if (activeView.value === 'floor') {
|
||
const visibleFloorId = resolveFloorIdFromRequest(currentFloor.value) || currentFloor.value
|
||
return route.floorSegments
|
||
.filter((segment) => (
|
||
(resolveFloorIdFromRequest(segment.floorId) || segment.floorId) === visibleFloorId
|
||
))
|
||
}
|
||
|
||
return route.floorSegments
|
||
}
|
||
|
||
const getNavigationScenePlan = (route = props.routePreview): NavigationScenePlan | null => (
|
||
route
|
||
? compileNavigationScene(route, renderPackage.value?.routeAssets || [])
|
||
: null
|
||
)
|
||
|
||
const getSameGroundCompositeRouteAsset = (route = props.routePreview): GuideModelRouteAsset | null => {
|
||
return getNavigationScenePlan(route)?.compositeAsset || null
|
||
}
|
||
|
||
const createRouteLine = (points: THREE.Vector3[], opacity: number, navigationActive = false) => {
|
||
const geometry = new THREE.BufferGeometry().setFromPoints(points)
|
||
const material = new THREE.LineBasicMaterial({
|
||
color: 0x356ae6,
|
||
transparent: true,
|
||
opacity,
|
||
depthTest: false,
|
||
depthWrite: false
|
||
})
|
||
const line = new THREE.Line(geometry, material)
|
||
line.renderOrder = 18
|
||
if (navigationActive && points.length > 1) {
|
||
const routeCurve = new THREE.CurvePath<THREE.Vector3>()
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
routeCurve.add(new THREE.LineCurve3(points[index - 1], points[index]))
|
||
}
|
||
const tube = new THREE.Mesh(
|
||
new THREE.TubeGeometry(routeCurve, Math.max((points.length - 1) * 10, 20), 0.34, 10, false),
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0x3f6fe6,
|
||
transparent: true,
|
||
opacity: 0.94,
|
||
depthTest: false,
|
||
depthWrite: false
|
||
})
|
||
)
|
||
tube.renderOrder = 17
|
||
routeGroup?.add(tube)
|
||
}
|
||
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, 54, 36, 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, 118)
|
||
context.lineTo(39, 78)
|
||
context.lineTo(89, 78)
|
||
context.closePath()
|
||
context.fillStyle = color
|
||
context.fill()
|
||
context.lineWidth = 6
|
||
context.strokeStyle = '#ffffff'
|
||
context.stroke()
|
||
|
||
context.font = `700 40px ${CANVAS_FONT_FAMILY}`
|
||
context.textAlign = 'center'
|
||
context.textBaseline = 'middle'
|
||
context.fillStyle = '#ffffff'
|
||
context.fillText(label, 64, 54, 56)
|
||
}
|
||
|
||
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 createRouteDirectionSprite = (
|
||
position: THREE.Vector3,
|
||
direction: THREE.Vector3,
|
||
markerSize: number
|
||
) => {
|
||
if (!routeGroup) return
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 96
|
||
canvas.height = 96
|
||
const context = canvas.getContext('2d')
|
||
if (!context) return
|
||
|
||
context.clearRect(0, 0, 96, 96)
|
||
context.beginPath()
|
||
context.moveTo(48, 16)
|
||
context.lineTo(74, 58)
|
||
context.lineTo(48, 47)
|
||
context.lineTo(22, 58)
|
||
context.closePath()
|
||
context.fillStyle = '#ffffff'
|
||
context.shadowColor = 'rgba(21, 50, 135, 0.45)'
|
||
context.shadowBlur = 6
|
||
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
|
||
}))
|
||
sprite.position.copy(position)
|
||
sprite.scale.setScalar(markerSize * 0.55)
|
||
sprite.renderOrder = 30
|
||
orientRouteSpriteToDirection(sprite, position, direction)
|
||
routeGroup.add(sprite)
|
||
}
|
||
|
||
const orientRouteSpriteToDirection = (
|
||
sprite: THREE.Sprite,
|
||
position: THREE.Vector3,
|
||
direction: THREE.Vector3
|
||
) => {
|
||
if (!camera || !sprite.material || direction.lengthSq() < 0.0001) return
|
||
|
||
const screenPosition = position.clone().project(camera)
|
||
const screenAhead = position.clone().add(direction).project(camera)
|
||
const deltaX = screenAhead.x - screenPosition.x
|
||
const deltaY = screenAhead.y - screenPosition.y
|
||
if (Math.abs(deltaX) + Math.abs(deltaY) < 0.00001) return
|
||
|
||
// The arrow artwork points upward by default. Rotate it in the camera plane,
|
||
// rather than by world axes, so it continues to align with the visible route.
|
||
sprite.material.rotation = -Math.atan2(deltaX, deltaY)
|
||
}
|
||
|
||
const createRouteRoamingMarker = (markerSize: number) => {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 128
|
||
canvas.height = 128
|
||
const context = canvas.getContext('2d')
|
||
if (context) {
|
||
context.clearRect(0, 0, 128, 128)
|
||
context.beginPath()
|
||
context.arc(64, 64, 36, 0, Math.PI * 2)
|
||
context.fillStyle = '#1565c0'
|
||
context.shadowColor = 'rgba(21, 101, 192, 0.36)'
|
||
context.shadowBlur = 14
|
||
context.fill()
|
||
context.shadowColor = 'transparent'
|
||
context.lineWidth = 6
|
||
context.strokeStyle = '#ffffff'
|
||
context.stroke()
|
||
context.beginPath()
|
||
context.moveTo(64, 35)
|
||
context.lineTo(86, 78)
|
||
context.lineTo(64, 67)
|
||
context.lineTo(42, 78)
|
||
context.closePath()
|
||
context.fillStyle = '#ffffff'
|
||
context.fill()
|
||
}
|
||
|
||
const texture = new THREE.CanvasTexture(canvas)
|
||
texture.colorSpace = THREE.SRGBColorSpace
|
||
const marker = new THREE.Sprite(new THREE.SpriteMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
depthTest: false,
|
||
depthWrite: false
|
||
}))
|
||
marker.scale.setScalar(markerSize * 1.15)
|
||
marker.renderOrder = 34
|
||
marker.frustumCulled = false
|
||
return marker
|
||
}
|
||
|
||
const createRouteEndpointLabelSprite = (name: string, markerSize: number) => {
|
||
const text = name
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = Math.min(560, Math.max(220, text.length * 38 + 44))
|
||
canvas.height = 76
|
||
const context = canvas.getContext('2d')
|
||
if (context) {
|
||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||
const left = 8
|
||
const top = 6
|
||
const width = canvas.width - 16
|
||
const height = canvas.height - 12
|
||
const radius = 12
|
||
context.beginPath()
|
||
context.moveTo(left + radius, top)
|
||
context.arcTo(left + width, top, left + width, top + height, radius)
|
||
context.arcTo(left + width, top + height, left, top + height, radius)
|
||
context.arcTo(left, top + height, left, top, radius)
|
||
context.arcTo(left, top, left + width, top, radius)
|
||
context.closePath()
|
||
context.fillStyle = 'rgba(255, 255, 255, 0.68)'
|
||
context.shadowColor = 'rgba(26, 35, 126, 0.08)'
|
||
context.shadowBlur = 6
|
||
context.shadowOffsetY = 2
|
||
context.fill()
|
||
context.shadowColor = 'transparent'
|
||
context.lineWidth = 2
|
||
context.strokeStyle = 'rgba(26, 35, 126, 0.16)'
|
||
context.stroke()
|
||
context.fillStyle = '#14205f'
|
||
context.font = `400 32px ${CANVAS_FONT_FAMILY}`
|
||
context.textAlign = 'center'
|
||
context.textBaseline = 'middle'
|
||
context.fillText(text, canvas.width / 2, canvas.height / 2, canvas.width - 24)
|
||
}
|
||
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
|
||
}))
|
||
const width = markerSize * 2.9
|
||
sprite.scale.set(width, width / (canvas.width / canvas.height), 1)
|
||
sprite.renderOrder = 35
|
||
sprite.frustumCulled = false
|
||
return sprite
|
||
}
|
||
|
||
const buildRouteRoamingPoints = (route: GuideRouteResult) => route.floorSegments
|
||
.flatMap((segment) => segment.points.map((point) => routePointToVector(point.position, point.floorId)))
|
||
.filter((point, index, points) => index === 0 || point.distanceToSquared(points[index - 1]) > 0.01)
|
||
|
||
const getRouteDistance = (points: THREE.Vector3[]) => points.reduce((distance, point, index) => (
|
||
index === 0 ? 0 : distance + point.distanceTo(points[index - 1])
|
||
), 0)
|
||
|
||
const getRouteRoamingPosition = (points: THREE.Vector3[], progress: number) => {
|
||
if (points.length < 2) return points[0]?.clone() || new THREE.Vector3()
|
||
const totalDistance = getRouteDistance(points)
|
||
const targetDistance = totalDistance * THREE.MathUtils.clamp(progress, 0, 1)
|
||
let walked = 0
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const start = points[index - 1]
|
||
const end = points[index]
|
||
const segmentDistance = start.distanceTo(end)
|
||
if (walked + segmentDistance >= targetDistance) {
|
||
const segmentProgress = segmentDistance > 0
|
||
? (targetDistance - walked) / segmentDistance
|
||
: 1
|
||
return start.clone().lerp(end, segmentProgress)
|
||
}
|
||
walked += segmentDistance
|
||
}
|
||
return points[points.length - 1].clone()
|
||
}
|
||
|
||
const getRouteRoamingDirection = (points: THREE.Vector3[], progress: number) => {
|
||
if (points.length < 2) return new THREE.Vector3(0, 0, -1)
|
||
const totalDistance = getRouteDistance(points)
|
||
const targetDistance = totalDistance * THREE.MathUtils.clamp(progress, 0, 1)
|
||
let walked = 0
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const start = points[index - 1]
|
||
const end = points[index]
|
||
const segmentDistance = start.distanceTo(end)
|
||
if (walked + segmentDistance >= targetDistance || index === points.length - 1) {
|
||
return end.clone().sub(start).normalize()
|
||
}
|
||
walked += segmentDistance
|
||
}
|
||
return points[points.length - 1].clone().sub(points[points.length - 2]).normalize()
|
||
}
|
||
|
||
const isRoutePositionOutsideCameraSafeArea = (position: THREE.Vector3) => {
|
||
if (!camera || !renderer) return false
|
||
const projected = position.clone().project(camera)
|
||
const x = (projected.x + 1) / 2
|
||
const y = (1 - projected.y) / 2
|
||
return projected.z < -1 || projected.z > 1 || x < 0.16 || x > 0.84 || y < 0.18 || y > 0.76
|
||
}
|
||
|
||
const getRouteArrowPlacements = (points: THREE.Vector3[], spacing: number) => {
|
||
const placements: Array<{ position: THREE.Vector3; direction: THREE.Vector3 }> = []
|
||
if (points.length < 2) return placements
|
||
|
||
let distanceToNext = spacing * 0.65
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const start = points[index - 1]
|
||
const end = points[index]
|
||
const vector = end.clone().sub(start)
|
||
const segmentLength = vector.length()
|
||
if (segmentLength < 0.01) continue
|
||
|
||
const direction = vector.clone().multiplyScalar(1 / segmentLength)
|
||
let walkedOnSegment = 0
|
||
while (walkedOnSegment + distanceToNext < segmentLength) {
|
||
walkedOnSegment += distanceToNext
|
||
placements.push({
|
||
position: start.clone().addScaledVector(direction, walkedOnSegment),
|
||
direction: direction.clone()
|
||
})
|
||
distanceToNext = spacing
|
||
}
|
||
distanceToNext -= segmentLength - walkedOnSegment
|
||
}
|
||
return placements
|
||
}
|
||
|
||
const getRoamingSegments = (route: GuideRouteResult) => {
|
||
// L1 and EXTERIOR share one dedicated ground-composite model. They are
|
||
// spatially continuous, so navigation must not perform a fake floor switch.
|
||
if (getNavigationScenePlan(route)?.kind === 'same-ground-composite') {
|
||
const points = route.floorSegments.flatMap((segment) => segment.points)
|
||
return [{
|
||
floorId: 'EXTERIOR_L1',
|
||
floorLabel: '室外连廊',
|
||
points: points.length === 1 ? [points[0], points[0]] : points
|
||
}]
|
||
}
|
||
|
||
// A zero-length WALK segment is valid at a transfer or a point that is
|
||
// already on the connector. Keep it as a two-point hold so the roaming
|
||
// state advances to the next physical floor instead of losing its index.
|
||
return route.floorSegments
|
||
.filter((segment) => segment.points.length > 0)
|
||
.map((segment) => segment.points.length === 1
|
||
? { ...segment, points: [segment.points[0], segment.points[0]] }
|
||
: segment)
|
||
}
|
||
|
||
const startRouteRoaming = (route: GuideRouteResult, markerSize: number) => {
|
||
if (!routeGroup || routeRoamingTransitioning) return
|
||
|
||
const segments = getRoamingSegments(route)
|
||
const segment = segments[routeRoamingSegmentIndex]
|
||
if (!segment) return
|
||
|
||
const isResumingCurrentSegment = Boolean(
|
||
routeRoamingSessionActive
|
||
&& routeRoamingStartedAt > 0
|
||
&& routeRoamingPoints.length > 0
|
||
)
|
||
|
||
if (!routeRoamingSessionActive) {
|
||
routeRoamingSessionActive = true
|
||
routeRoamingSegmentIndex = 0
|
||
routeRoamingCompletedDistance = 0
|
||
routeRoamingTotalDistance = Math.max(segments.reduce((total, item) => (
|
||
total + getRouteDistance(item.points.map((point) => routePointToVector(point.position, point.floorId)))
|
||
), 0), 0.001)
|
||
lastRouteRoamingRemainingMeters = null
|
||
}
|
||
|
||
const points = isResumingCurrentSegment
|
||
? routeRoamingPoints
|
||
: segment.points.map((point) => routePointToVector(point.position, point.floorId))
|
||
if (!isResumingCurrentSegment) {
|
||
routeRoamingPoints = points
|
||
routeRoamingDurationMs = THREE.MathUtils.clamp(getRouteDistance(points) * 115, 6000, 18000)
|
||
routeRoamingStartedAt = performance.now() + ROUTE_NAVIGATION_CAMERA_ENTRY_MS
|
||
}
|
||
if (routeRoamingMarker) return
|
||
|
||
routeRoamingMarker = createRouteRoamingMarker(markerSize)
|
||
routeRoamingMarker.position.copy(points[0])
|
||
routeGroup.add(routeRoamingMarker)
|
||
|
||
if (camera && controls) {
|
||
const routeDirection = getRouteRoamingDirection(points, 0)
|
||
const cameraDirection = getCameraCalibrationDirection(
|
||
GUIDE_CAMERA_YAW_DEGREES,
|
||
GUIDE_CAMERA_ELEVATION_DEGREES
|
||
)
|
||
controls.minDistance = Math.min(controls.minDistance, ROUTE_NAVIGATION_CAMERA_DISTANCE * 0.6)
|
||
controls.maxDistance = Math.max(controls.maxDistance, ROUTE_PREVIEW_CAMERA_DISTANCE)
|
||
routeRoamingLookAhead = ROUTE_NAVIGATION_LOOK_AHEAD
|
||
const target = points[0].clone().addScaledVector(routeDirection, routeRoamingLookAhead)
|
||
routeRoamingCameraOffset = cameraDirection.multiplyScalar(ROUTE_NAVIGATION_CAMERA_DISTANCE)
|
||
routeRoamingLastCameraMoveAt = performance.now()
|
||
moveCameraTo(target.clone().add(routeRoamingCameraOffset), target, {
|
||
durationMs: ROUTE_NAVIGATION_CAMERA_ENTRY_MS
|
||
})
|
||
}
|
||
}
|
||
|
||
const transferRouteRoamingToNextFloor = async (route: GuideRouteResult) => {
|
||
const segments = getRoamingSegments(route)
|
||
const nextSegment = segments[routeRoamingSegmentIndex + 1]
|
||
if (!nextSegment) return
|
||
|
||
routeRoamingTransitioning = true
|
||
routeRoamingSegmentIndex += 1
|
||
routeRoamingStartedAt = 0
|
||
let shouldResume = false
|
||
const previousSegment = segments[routeRoamingSegmentIndex - 1]
|
||
const transition = route.transitions?.find((item) => (
|
||
item.fromFloorId === previousSegment?.floorId
|
||
&& item.toFloorId === nextSegment.floorId
|
||
))
|
||
emit('routeRoamingTransfer', {
|
||
fromFloorId: previousSegment?.floorId || '',
|
||
toFloorId: nextSegment.floorId,
|
||
transferType: transition?.transferType || 'TRANSFER',
|
||
connectorName: transition?.connectorName
|
||
})
|
||
|
||
try {
|
||
await loadFloor(nextSegment.floorId, {
|
||
preserveCurrentSceneUntilReady: true,
|
||
detachPoiBeforeLoad: true,
|
||
applyFloorBaseline: true,
|
||
preserveRouteRoaming: true
|
||
})
|
||
shouldResume = props.routeNavigationActive && props.routePreview?.id === route.id
|
||
if (!shouldResume) return
|
||
emitFloorChange(nextSegment.floorId)
|
||
} catch (error) {
|
||
if (!props.routeNavigationActive || props.routePreview?.id !== route.id) return
|
||
console.error('[ThreeMap] 导览换层失败:', error)
|
||
clearRoutePreview()
|
||
emit('routeRoamingProgress', { remainingMeters: 0, progress: 1 })
|
||
return
|
||
} finally {
|
||
routeRoamingTransitioning = false
|
||
}
|
||
|
||
if (!shouldResume) return
|
||
clearRoutePreview({ preserveRoaming: true })
|
||
renderRoutePreview()
|
||
}
|
||
|
||
const updateRouteRoaming = (now: number) => {
|
||
if (
|
||
!props.routeNavigationActive
|
||
|| routeRoamingTransitioning
|
||
|| !routeRoamingMarker
|
||
|| !routeRoamingStartedAt
|
||
|| !routeRoamingPoints.length
|
||
) return
|
||
|
||
const progress = THREE.MathUtils.clamp((now - routeRoamingStartedAt) / routeRoamingDurationMs, 0, 1)
|
||
const position = getRouteRoamingPosition(routeRoamingPoints, progress)
|
||
routeRoamingMarker.position.copy(position)
|
||
const direction = getRouteRoamingDirection(routeRoamingPoints, progress)
|
||
const travelledDistance = routeRoamingCompletedDistance + getRouteDistance(routeRoamingPoints) * progress
|
||
const totalProgress = THREE.MathUtils.clamp(travelledDistance / routeRoamingTotalDistance, 0, 1)
|
||
const routeDistance = Number(props.routePreview?.distanceMeters)
|
||
const remainingMeters = Math.max(0, Math.round(
|
||
(Number.isFinite(routeDistance) && routeDistance > 0 ? routeDistance : routeRoamingTotalDistance)
|
||
* (1 - totalProgress)
|
||
))
|
||
if (remainingMeters !== lastRouteRoamingRemainingMeters) {
|
||
lastRouteRoamingRemainingMeters = remainingMeters
|
||
emit('routeRoamingProgress', { remainingMeters, progress: totalProgress })
|
||
}
|
||
|
||
if (
|
||
camera
|
||
&& controls
|
||
&& routeRoamingCameraOffset
|
||
&& now - routeRoamingLastCameraMoveAt >= ROUTE_NAVIGATION_RECENTER_MIN_INTERVAL_MS
|
||
&& isRoutePositionOutsideCameraSafeArea(position)
|
||
) {
|
||
const target = position.clone().addScaledVector(direction, routeRoamingLookAhead)
|
||
routeRoamingLastCameraMoveAt = now
|
||
moveCameraTo(target.clone().add(routeRoamingCameraOffset), target, {
|
||
durationMs: ROUTE_NAVIGATION_RECENTER_MS
|
||
})
|
||
}
|
||
orientRouteSpriteToDirection(routeRoamingMarker, position, direction)
|
||
|
||
if (progress < 1 || !props.routePreview) return
|
||
|
||
routeRoamingCompletedDistance += getRouteDistance(routeRoamingPoints)
|
||
if (routeRoamingSegmentIndex < getRoamingSegments(props.routePreview).length - 1) {
|
||
void transferRouteRoamingToNextFloor(props.routePreview)
|
||
return
|
||
}
|
||
|
||
emit('routeRoamingProgress', { remainingMeters: 0, progress: 1 })
|
||
}
|
||
|
||
const shouldShowRouteEndpoint = (endpoint: GuideRouteEndpoint) => (
|
||
activeView.value !== 'floor' || endpoint.floorId === currentFloor.value
|
||
)
|
||
|
||
const renderRouteEndpoint = (
|
||
endpoint: GuideRouteEndpoint,
|
||
routePosition: [number, number, number],
|
||
label: string,
|
||
color: string,
|
||
markerSize: number
|
||
) => {
|
||
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
|
||
|
||
const marker = createRouteMarkerSprite(label, color, markerSize)
|
||
marker.position.copy(routePointToVector(routePosition, endpoint.floorId))
|
||
routeGroup.add(marker)
|
||
}
|
||
|
||
const renderRouteEndpointLabel = (
|
||
endpoint: GuideRouteEndpoint,
|
||
routePosition: [number, number, number],
|
||
markerSize: number
|
||
) => {
|
||
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
|
||
const endpointPoiSprite = findPoiSprite(endpoint.poiId)
|
||
if (
|
||
activeView.value === 'floor'
|
||
&& endpointPoiSprite
|
||
&& getPoiSpriteUserData(endpointPoiSprite).labelHandle
|
||
) return
|
||
const label = createRouteEndpointLabelSprite(endpoint.name, markerSize)
|
||
label.position.copy(routePointToVector(routePosition, endpoint.floorId))
|
||
label.position.y += markerSize * 0.8
|
||
routeGroup.add(label)
|
||
}
|
||
|
||
const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number) => {
|
||
if (!routeGroup) return
|
||
if (getSameGroundCompositeRouteAsset(route)) 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))
|
||
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,
|
||
endpointMarkerSize: number
|
||
) => JSON.stringify({
|
||
routeId: route.id,
|
||
view: activeView.value,
|
||
navigationActive: props.routeNavigationActive,
|
||
floor: activeView.value === 'floor' ? currentFloor.value : '',
|
||
markerSize: Number(markerSize.toFixed(3)),
|
||
endpointMarkerSize: Number(endpointMarkerSize.toFixed(3)),
|
||
start: {
|
||
poiId: route.start.poiId,
|
||
name: route.start.name,
|
||
floorId: route.start.floorId,
|
||
position: routePositionSignature(route.start.position)
|
||
},
|
||
end: {
|
||
poiId: route.end.poiId,
|
||
name: route.end.name,
|
||
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 getRouteTransitions = (route: GuideRouteResult) => {
|
||
if (route.transitions?.length) return route.transitions
|
||
|
||
return route.floorSegments.flatMap((segment, index) => {
|
||
if (index === 0) return []
|
||
const previous = route.floorSegments[index - 1]
|
||
const fromPoint = previous.points[previous.points.length - 1]
|
||
const toPoint = segment.points[0]
|
||
if (!fromPoint || !toPoint || previous.floorId === segment.floorId) return []
|
||
return [{
|
||
id: `transition-${fromPoint.nodeId}-${toPoint.nodeId}`,
|
||
fromFloorId: previous.floorId,
|
||
toFloorId: segment.floorId,
|
||
fromPosition: fromPoint.position,
|
||
toPosition: toPoint.position
|
||
}]
|
||
})
|
||
}
|
||
|
||
const renderMultiFloorRouteConnectors = (
|
||
route: GuideRouteResult,
|
||
guideStyle: boolean
|
||
) => {
|
||
if (!routeGroup || activeView.value !== 'multi') return
|
||
|
||
getRouteTransitions(route).forEach((transition) => {
|
||
routeGroup?.add(createRouteLine([
|
||
routePointToVector(transition.fromPosition, transition.fromFloorId),
|
||
routePointToVector(transition.toPosition, transition.toFloorId)
|
||
], 0.88, guideStyle))
|
||
})
|
||
}
|
||
|
||
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 endpointMarkerSize = Math.max(getPoiMarkerSize() * 1.12, 3.8)
|
||
const useRouteGuideStyle = true
|
||
primeRouteFloorSurfaces(visibleSegments)
|
||
const signature = getRoutePreviewSignature(route, visibleSegments, markerSize, endpointMarkerSize)
|
||
if (signature === activeRoutePreviewSignature) return
|
||
|
||
// A floor commit rebuilds the route visuals. During roaming that rebuild
|
||
// must keep the compiled segment index, otherwise every transfer restarts
|
||
// at the first floor and appears as an endless loop.
|
||
clearRoutePreview({
|
||
preserveRoaming: props.routeNavigationActive && routeRoamingSessionActive
|
||
})
|
||
activeRoutePreviewSignature = signature
|
||
|
||
visibleSegments.forEach((segment) => {
|
||
if (segment.points.length < 2) return
|
||
|
||
const segmentVectors = segment.points.map((point) => routePointToVector(point.position, point.floorId))
|
||
targetRouteGroup.add(createRouteLine(
|
||
segmentVectors,
|
||
activeView.value === 'floor' ? 1 : 0.88,
|
||
useRouteGuideStyle
|
||
))
|
||
|
||
if (useRouteGuideStyle) {
|
||
const arrowSpacing = activeView.value === 'floor' ? 7.5 : 9
|
||
getRouteArrowPlacements(segmentVectors, arrowSpacing).forEach(({ position, direction }) => {
|
||
createRouteDirectionSprite(position, direction, markerSize)
|
||
})
|
||
}
|
||
})
|
||
|
||
renderMultiFloorRouteConnectors(route, useRouteGuideStyle)
|
||
|
||
const routeStartPosition = route.floorSegments[0]?.points[0]?.position || route.start.position
|
||
const lastRouteSegment = route.floorSegments[route.floorSegments.length - 1]
|
||
const routeEndPosition = lastRouteSegment?.points[lastRouteSegment.points.length - 1]?.position || route.end.position
|
||
renderRouteEndpoint(route.start, routeStartPosition, '起', '#54b86c', endpointMarkerSize)
|
||
renderRouteEndpointLabel(route.start, routeStartPosition, endpointMarkerSize)
|
||
renderRouteEndpoint(route.end, routeEndPosition, '终', '#df6459', endpointMarkerSize)
|
||
renderRouteEndpointLabel(route.end, routeEndPosition, endpointMarkerSize)
|
||
renderRouteConnectorMarkers(route, markerSize)
|
||
if (props.routeNavigationActive) {
|
||
startRouteRoaming(route, markerSize)
|
||
}
|
||
}
|
||
|
||
const clearSceneData = (options: { preserveRouteRoaming?: boolean } = {}) => {
|
||
cancelCameraTween()
|
||
|
||
if (activeModel && scene) {
|
||
clearFocusHallHighlight()
|
||
scene.remove(activeModel)
|
||
if (activeModel === cachedOverviewModel) {
|
||
cachedOverviewModel.visible = false
|
||
} else {
|
||
disposeObject(activeModel, collectReusableModelResources(cachedOverviewModel))
|
||
}
|
||
}
|
||
|
||
activeModel = null
|
||
activeRouteCompositeModel = null
|
||
activeRouteCompositeUrl = ''
|
||
disposeFocusLabel()
|
||
disposeFocusPulse()
|
||
disposeFocusBase()
|
||
disposeOverviewMapLabels()
|
||
selectedPOI.value = null
|
||
clearRoutePreview({ preserveRoaming: options.preserveRouteRoaming })
|
||
detachPoiMarkerGroups()
|
||
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 modelLoadTimeoutCode = 'GUIDE_MODEL_LOAD_TIMEOUT'
|
||
|
||
const createModelLoadTimeoutError = (reason: 'stalled' | 'total') => {
|
||
const error = new Error(reason === 'stalled' ? '模型资源下载长时间无进度' : '模型资源请求超时')
|
||
error.name = modelLoadTimeoutCode
|
||
return error
|
||
}
|
||
|
||
const isModelLoadTimeoutError = (error: unknown) => (
|
||
error instanceof Error && error.name === modelLoadTimeoutCode
|
||
)
|
||
|
||
const getInitialForegroundBudgetRemainingMs = () => (
|
||
foregroundInitialInteractiveBudgetMs
|
||
- Math.max(0, getNow() - (firstModelLoadStartedAt || getNow()))
|
||
)
|
||
|
||
const hasExceededInitialForegroundBudget = () => (
|
||
!initialModelSettled
|
||
&& firstModelLoadStartedAt > 0
|
||
&& getInitialForegroundBudgetRemainingMs() <= 0
|
||
)
|
||
|
||
const isPersistentCachedModelUrl = (url: string) => (
|
||
String(url || '').trim().toLowerCase().startsWith('blob:')
|
||
)
|
||
|
||
const getModelLoadWatchdogTimeouts = (
|
||
url: string,
|
||
options: LoadModelOptions = {}
|
||
) => {
|
||
if (options.suppressProgress || isPersistentCachedModelUrl(url)) {
|
||
return {
|
||
stallMs: backgroundModelLoadStallTimeoutMs,
|
||
totalMs: backgroundModelLoadTotalTimeoutMs
|
||
}
|
||
}
|
||
|
||
const initialBudgetRemainingMs = !initialModelSettled
|
||
? getInitialForegroundBudgetRemainingMs()
|
||
: backgroundModelLoadTotalTimeoutMs
|
||
|
||
return {
|
||
stallMs: foregroundModelLoadStallTimeoutMs,
|
||
totalMs: Math.max(500, initialBudgetRemainingMs)
|
||
}
|
||
}
|
||
|
||
const loadModelOnce = (
|
||
url: string,
|
||
label: string,
|
||
loadToken?: number,
|
||
options: LoadModelOptions = {}
|
||
) => new Promise<GLTF>((resolve, reject) => {
|
||
const activeLoader = ensureModelLoader()
|
||
|
||
const startedAt = getNow()
|
||
const watchdogTimeouts = getModelLoadWatchdogTimeouts(url, options)
|
||
let requestCompletedAt: number | null = null
|
||
let settled = false
|
||
let stallTimer: ReturnType<typeof setTimeout> | null = null
|
||
let totalTimer: ReturnType<typeof setTimeout> | null = null
|
||
const clearWatchdogs = () => {
|
||
if (stallTimer) clearTimeout(stallTimer)
|
||
if (totalTimer) clearTimeout(totalTimer)
|
||
stallTimer = null
|
||
totalTimer = null
|
||
}
|
||
const rejectOnce = (error: unknown) => {
|
||
if (settled) return
|
||
settled = true
|
||
clearWatchdogs()
|
||
reject(error)
|
||
}
|
||
const armStallWatchdog = () => {
|
||
if (stallTimer) clearTimeout(stallTimer)
|
||
stallTimer = setTimeout(() => {
|
||
rejectOnce(createModelLoadTimeoutError('stalled'))
|
||
}, watchdogTimeouts.stallMs)
|
||
}
|
||
armStallWatchdog()
|
||
totalTimer = setTimeout(() => {
|
||
rejectOnce(createModelLoadTimeoutError('total'))
|
||
}, watchdogTimeouts.totalMs)
|
||
logThreeMapDiagnostic('model-load-start', {
|
||
label,
|
||
url,
|
||
foreground: !options.suppressProgress,
|
||
stallTimeoutMs: watchdogTimeouts.stallMs,
|
||
totalTimeoutMs: watchdogTimeouts.totalMs
|
||
})
|
||
logThreeMapDiagnostic('model-request-start', {
|
||
label,
|
||
url
|
||
})
|
||
|
||
activeLoader.load(
|
||
url,
|
||
(gltf) => {
|
||
if (settled || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) {
|
||
clearWatchdogs()
|
||
disposeObject(gltf.scene)
|
||
if (!settled) rejectOnce(createStaleModelLoadError())
|
||
return
|
||
}
|
||
settled = true
|
||
clearWatchdogs()
|
||
const completedAt = getNow()
|
||
if (requestCompletedAt === null) {
|
||
requestCompletedAt = completedAt
|
||
logThreeMapDiagnostic('model-request-complete', {
|
||
label,
|
||
url,
|
||
elapsedMs: Math.round(requestCompletedAt - startedAt),
|
||
timingIncludesParse: true
|
||
})
|
||
}
|
||
logThreeMapDiagnostic('model-parse-complete', {
|
||
label,
|
||
url,
|
||
requestElapsedMs: Math.round(requestCompletedAt - startedAt),
|
||
parseElapsedMs: Math.max(0, Math.round(completedAt - requestCompletedAt)),
|
||
totalElapsedMs: Math.round(completedAt - startedAt)
|
||
})
|
||
logThreeMapDiagnostic('model-load-complete', {
|
||
label,
|
||
url,
|
||
elapsedMs: Math.round(completedAt - startedAt)
|
||
})
|
||
resolve(gltf)
|
||
},
|
||
(event) => {
|
||
if (settled) return
|
||
armStallWatchdog()
|
||
if (event.total > 0 && event.loaded >= event.total && requestCompletedAt === null) {
|
||
requestCompletedAt = getNow()
|
||
logThreeMapDiagnostic('model-request-complete', {
|
||
label,
|
||
url,
|
||
elapsedMs: Math.round(requestCompletedAt - startedAt),
|
||
bytes: event.loaded
|
||
})
|
||
}
|
||
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) => {
|
||
if (settled) return
|
||
logThreeMapDiagnostic('model-load-failed', {
|
||
label,
|
||
url,
|
||
elapsedMs: Math.round(getNow() - startedAt),
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
rejectOnce(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 缺失')
|
||
}
|
||
|
||
// Persistent Cache Storage is used before the network. A corrupt local blob is discarded and
|
||
// the same request continues through the normal network retry path.
|
||
const cachedAsset = await guideModelPersistentCache.get(normalizedUrl, options.modelVersion)
|
||
if (cachedAsset) {
|
||
try {
|
||
return await loadModelOnce(cachedAsset.url, `${label}(本地缓存)`, loadToken, options)
|
||
} catch (error) {
|
||
lastError = error
|
||
await guideModelPersistentCache.remove(normalizedUrl, options.modelVersion)
|
||
} finally {
|
||
cachedAsset.release()
|
||
}
|
||
}
|
||
|
||
for (let attempt = 0; attempt <= modelLoadRetryDelaysMs.length; attempt += 1) {
|
||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
|
||
try {
|
||
const gltf = await loadModelOnce(normalizedUrl, label, loadToken, options)
|
||
void guideModelPersistentCache.store(normalizedUrl, options.modelVersion)
|
||
return gltf
|
||
} catch (error) {
|
||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
|
||
lastError = error
|
||
if (isModelLoadTimeoutError(error) || !options.suppressProgress) break
|
||
const retryDelay = modelLoadRetryDelaysMs[attempt]
|
||
if (retryDelay === undefined) break
|
||
|
||
if (!options.suppressProgress) {
|
||
setProgress(18, `${label} 加载失败,正在重试 ${attempt + 1}/${modelLoadRetryDelaysMs.length}`)
|
||
}
|
||
|
||
await wait(retryDelay)
|
||
}
|
||
}
|
||
|
||
if (isModelLoadTimeoutError(lastError)) throw lastError
|
||
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: (error) => (
|
||
isStaleModelLoadError(error)
|
||
|| isModelLoadTimeoutError(error)
|
||
|| !options.suppressProgress
|
||
)
|
||
})
|
||
if (!options.suppressProgress) {
|
||
setProgress(92, `${label}: 正在准备三维场景...`)
|
||
}
|
||
|
||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||
disposeObject(gltf.scene)
|
||
throw createStaleModelLoadError()
|
||
}
|
||
|
||
return gltf
|
||
}
|
||
|
||
const prepareModel = (model: THREE.Object3D) => {
|
||
applyGuideModelMaterialPolicy(model)
|
||
|
||
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) => {
|
||
// Each GLB floor has its own source elevation. Align its visual centre to
|
||
// an evenly spaced route deck instead of applying one identical delta;
|
||
// otherwise the rendered floor height and the route's vertical connector
|
||
// disagree on MF/3F/4F/5F.
|
||
const sourceCenterY = getObjectBox(item.model).getCenter(new THREE.Vector3()).y
|
||
const targetCenterY = (centerIndex - index) * verticalGap
|
||
const offsetY = targetCenterY - sourceCenterY
|
||
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]
|
||
)
|
||
|
||
type ModelPreloadScope = 'adjacent' | 'default-entry'
|
||
|
||
const isCurrentPreload = (scope: ModelPreloadScope, preloadSeq: number) => (
|
||
scope === 'default-entry'
|
||
? preloadSeq === defaultFloorPreloadSeq
|
||
: preloadSeq === adjacentPreloadSeq
|
||
)
|
||
|
||
const preloadModelUrls = async (
|
||
urls: string[],
|
||
label: string,
|
||
preloadSeq: number,
|
||
scope: ModelPreloadScope = 'adjacent'
|
||
) => {
|
||
const candidates = urls.map((url) => url.trim()).filter(Boolean)
|
||
if (!candidates.length || !isCurrentPreload(scope, preloadSeq) || isDisposed) return
|
||
|
||
const existingCandidate = candidates.find((url) => {
|
||
const isCached = guideModelLoadManager.has(url)
|
||
const isPending = guideModelLoadManager.isInFlight(url)
|
||
return isCached || isPending
|
||
})
|
||
if (existingCandidate) {
|
||
logThreeMapDiagnostic('adjacent-preload-skip', {
|
||
label,
|
||
url: existingCandidate,
|
||
scope,
|
||
reason: guideModelLoadManager.has(existingCandidate) ? 'cache-hit' : 'in-flight-hit',
|
||
diagnostics: guideModelLoadManager.getDiagnostics()
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
await guideModelLoadManager.preload({
|
||
urls: candidates,
|
||
label,
|
||
loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
|
||
})
|
||
} catch (error) {
|
||
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
|
||
urls: candidates,
|
||
label,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
}
|
||
|
||
const getWeakNetworkModelPreloadTarget = (
|
||
view: 'overview' | 'floor',
|
||
floorId: string
|
||
) => {
|
||
const packageData = renderPackage.value
|
||
if (!packageData) return null
|
||
|
||
if (view === 'overview') {
|
||
return {
|
||
urls: getOverviewModelUrls(packageData),
|
||
label: '弱网后台预热建筑外观模型',
|
||
modelVersion: packageData.overviewModelVersion,
|
||
semanticKey: packageData.overviewModelUrl
|
||
}
|
||
}
|
||
|
||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||
if (!floor) return null
|
||
|
||
return {
|
||
urls: getFloorModelUrls(floor),
|
||
label: `弱网后台预热 ${formatFloorLabel(floor.floorId)} 模型`,
|
||
modelVersion: floor.modelVersion,
|
||
semanticKey: floor.modelUrl
|
||
}
|
||
}
|
||
|
||
const preloadWeakNetworkFallbackModelInBackground = (
|
||
view: 'overview' | 'floor',
|
||
floorId: string,
|
||
reason: string
|
||
) => {
|
||
const target = getWeakNetworkModelPreloadTarget(view, floorId)
|
||
const candidates = target?.urls.map((url) => url.trim()).filter(Boolean) || []
|
||
if (!target || !candidates.length || isDisposed) {
|
||
logThreeMapDiagnostic('weak-network-model-preload-skip', {
|
||
view,
|
||
floorId,
|
||
reason: !target ? 'target-missing' : !candidates.length ? 'url-missing' : 'disposed'
|
||
})
|
||
return
|
||
}
|
||
|
||
const preloadSeq = ++weakNetworkFallbackPreloadSeq
|
||
void (async () => {
|
||
try {
|
||
ensureModelLoader()
|
||
if (preloadSeq !== weakNetworkFallbackPreloadSeq || isDisposed) return
|
||
|
||
const startedAt = getNow()
|
||
logThreeMapDiagnostic('weak-network-model-preload-start', {
|
||
view,
|
||
floorId,
|
||
reason,
|
||
urls: candidates
|
||
})
|
||
await guideModelLoadManager.preload({
|
||
urls: candidates,
|
||
label: target.label,
|
||
semanticKey: target.semanticKey,
|
||
loadSource: (url) => loadModelFromNetwork(url, target.label, undefined, {
|
||
suppressProgress: true,
|
||
modelVersion: target.modelVersion
|
||
})
|
||
})
|
||
if (preloadSeq !== weakNetworkFallbackPreloadSeq || isDisposed) return
|
||
logThreeMapDiagnostic('weak-network-model-preload-complete', {
|
||
view,
|
||
floorId,
|
||
reason,
|
||
elapsedMs: Math.round(getNow() - startedAt),
|
||
diagnostics: guideModelLoadManager.getDiagnostics()
|
||
})
|
||
} catch (error) {
|
||
if (preloadSeq !== weakNetworkFallbackPreloadSeq || isDisposed) return
|
||
logThreeMapDiagnostic('weak-network-model-preload-failed', {
|
||
view,
|
||
floorId,
|
||
reason,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
console.warn('[ThreeMap] 弱网 WebP 后台模型预热失败:', {
|
||
view,
|
||
floorId,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
})()
|
||
}
|
||
|
||
const prepareDefaultFloorAssetsInBackground = async (
|
||
floor: FloorIndexItem,
|
||
preloadSeq: number
|
||
) => {
|
||
const existing = preparedFloorModelCache.get(floor.floorId)
|
||
if (existing?.modelUrl === floor.modelUrl) {
|
||
preparedFloorModelCache.forEach((entry, cachedFloorId) => {
|
||
if (cachedFloorId === floor.floorId) return
|
||
disposeObject(entry.model)
|
||
preparedFloorModelCache.delete(cachedFloorId)
|
||
})
|
||
return
|
||
}
|
||
|
||
const gltf = await loadModelWithFallback(
|
||
getFloorModelUrls(floor),
|
||
`准备默认进入楼层 ${formatFloorLabel(floor.floorId)} 场景`,
|
||
undefined,
|
||
{ suppressProgress: true, modelVersion: floor.modelVersion }
|
||
)
|
||
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) {
|
||
disposeObject(gltf.scene)
|
||
return
|
||
}
|
||
|
||
const model = gltf.scene
|
||
model.name = `GuideFloorModel_${floor.floorId}`
|
||
model.userData.floorId = floor.floorId
|
||
prepareModel(model)
|
||
applyIndoorInitialModelTransform(model)
|
||
applyModelVisibilityForView(model, 'floor', floor.floorId)
|
||
|
||
// 只保留一个待进入楼层的完整场景。源 GLTF 缓存负责复用解析结果,
|
||
// 这里的 clone 只服务于下一次室内进入,不能随楼层切换无限累积。
|
||
preparedFloorModelCache.forEach((entry, cachedFloorId) => {
|
||
disposeObject(entry.model)
|
||
preparedFloorModelCache.delete(cachedFloorId)
|
||
})
|
||
preparedFloorModelCache.set(floor.floorId, {
|
||
floorId: floor.floorId,
|
||
modelUrl: floor.modelUrl,
|
||
model
|
||
})
|
||
|
||
if (shouldRenderPoiMarkers.value) {
|
||
await prepareFloorPOIs(floor)
|
||
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) {
|
||
const cached = preparedFloorModelCache.get(floor.floorId)
|
||
if (cached?.model === model) {
|
||
preparedFloorModelCache.delete(floor.floorId)
|
||
disposeObject(cached.model)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
const scheduleDefaultFloorPreload = () => {
|
||
const floorId = getAutoEntryTargetFloorId()
|
||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||
if (!floor) {
|
||
logThreeMapDiagnostic('default-floor-preload-skip', {
|
||
floorId,
|
||
reason: 'floor-metadata-missing'
|
||
})
|
||
return
|
||
}
|
||
|
||
const preloadSeq = defaultFloorPreloadSeq + 1
|
||
defaultFloorPreloadSeq = preloadSeq
|
||
|
||
// 外观首帧提交后只在浏览器空闲时准备默认楼层,避免下载和 GLTF 解析抢占首屏。
|
||
const networkInfo = getBrowserNetworkInfo()
|
||
const policy = getBackgroundPreloadPolicy(networkInfo)
|
||
logThreeMapDiagnostic('default-floor-preload-schedule', {
|
||
floorId,
|
||
delayMs: policy.delayMs,
|
||
reason: policy.reason,
|
||
allowed: policy.allowed,
|
||
effectiveType: networkInfo?.effectiveType || 'unknown'
|
||
})
|
||
const scheduled = defaultFloorPreloadScheduler.schedule(async () => {
|
||
try {
|
||
const startedAt = getNow()
|
||
logThreeMapDiagnostic('default-floor-preload-start', {
|
||
floorId,
|
||
modelUrl: floor.modelUrl,
|
||
network: networkInfo?.effectiveType || 'unknown'
|
||
})
|
||
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed || activeView.value !== 'overview') return
|
||
// loadModelWithFallback 已经通过 GuideModelLoadManager 复用源 GLTF
|
||
// 缓存;无需先做一次只为预热的 preload,再重复命中缓存并 clone。
|
||
await prepareDefaultFloorAssetsInBackground(floor, preloadSeq)
|
||
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) return
|
||
logThreeMapDiagnostic('default-floor-preload-complete', {
|
||
floorId,
|
||
modelUrl: floor.modelUrl,
|
||
scenePrepared: preparedFloorModelCache.has(floorId),
|
||
poiPrepared: poiMarkerGroupCache.has(getPoiMarkerCacheKey(floorId, 'detail')),
|
||
elapsedMs: Math.round(getNow() - startedAt),
|
||
diagnostics: guideModelLoadManager.getDiagnostics()
|
||
})
|
||
} catch (error) {
|
||
console.warn('[ThreeMap] 默认进入楼层后台准备失败:', {
|
||
floorId,
|
||
modelUrl: floor.modelUrl,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
})
|
||
|
||
if (!scheduled) {
|
||
logThreeMapDiagnostic('default-floor-preload-skip', {
|
||
floorId,
|
||
reason: policy.reason,
|
||
effectiveType: networkInfo?.effectiveType || 'unknown'
|
||
})
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
const networkInfo = getBrowserNetworkInfo()
|
||
const policy = getBackgroundPreloadPolicy(networkInfo)
|
||
const scheduled = adjacentPreloadScheduler.schedule(async () => {
|
||
try {
|
||
if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
|
||
|
||
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
|
||
)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
|
||
floorIds: adjacentFloors.map((floor) => floor.floorId),
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
}, { delayMs: 1800 })
|
||
|
||
if (!scheduled) {
|
||
logThreeMapDiagnostic('adjacent-preload-skip', {
|
||
floorId,
|
||
reason: policy.reason,
|
||
effectiveType: networkInfo?.effectiveType || 'unknown'
|
||
})
|
||
}
|
||
}
|
||
|
||
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) => (
|
||
getFloorSortLevel(floor) === 1
|
||
|| 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'
|
||
if (buttonAutoSwitchTimer) {
|
||
clearTimeout(buttonAutoSwitchTimer)
|
||
buttonAutoSwitchTimer = null
|
||
}
|
||
autoSwitchStateMachine.reset(activeView.value)
|
||
}
|
||
|
||
const ensureFloorAutoExitZoomRange = () => {
|
||
if (!controls) return
|
||
|
||
const distance = controls.getDistance()
|
||
if (!Number.isFinite(distance) || distance <= 0) return
|
||
|
||
// Indoor navigation keeps a stable baseline and a small zoom-out inspection
|
||
// band. Only sustained travel beyond the exit threshold returns to exterior.
|
||
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, distance * floorZoomInLimitRatio)
|
||
controls.maxDistance = distance * floorZoomOutLimitRatio
|
||
floorNavigationDistance = distance
|
||
}
|
||
|
||
const getOverviewAutoEntryDistance = () => (
|
||
getReferenceOverviewCameraState().distance * (1 - GUIDE_AUTO_SWITCH_ENTER_RATIO)
|
||
)
|
||
|
||
const scheduleButtonAutoSwitch = (
|
||
direction: GuideAutoSwitchDirection,
|
||
candidateDistance: number
|
||
) => {
|
||
if (buttonAutoSwitchTimer) clearTimeout(buttonAutoSwitchTimer)
|
||
|
||
const candidateStartedAt = getNow()
|
||
buttonAutoSwitchTimer = window.setTimeout(() => {
|
||
buttonAutoSwitchTimer = null
|
||
if (!controls || !canRunAutoSwitch(direction)) return
|
||
|
||
const currentDistance = controls.getDistance()
|
||
const conditionStillMet = direction === 'enter-floor'
|
||
? currentDistance <= getOverviewAutoEntryDistance()
|
||
: floorNavigationDistance > 0
|
||
&& currentDistance >= floorNavigationDistance * (1 + GUIDE_AUTO_SWITCH_EXIT_RATIO)
|
||
if (!conditionStillMet) return
|
||
|
||
autoSwitchStateMachine.reset(activeView.value)
|
||
requestAutoSwitch({
|
||
direction,
|
||
distance: candidateDistance,
|
||
source: 'button',
|
||
candidateStartedAt,
|
||
confirmedAt: getNow()
|
||
})
|
||
// Wait for the button tween to reach the requested distance before confirming.
|
||
// Checking halfway through the animation previously left the user in an
|
||
// over-zoomed exterior/floor state without triggering the transition.
|
||
}, Math.max(autoSwitchEnterHoldMs, buttonZoomTweenDurationMs + programmaticCameraTailMs + 40))
|
||
}
|
||
|
||
const trackButtonZoomForAutoSwitch = (currentDistance: number, nextDistance: number) => {
|
||
activeAutoSwitchInputSource = 'button'
|
||
|
||
if (activeView.value === 'overview') {
|
||
if (nextDistance <= getOverviewAutoEntryDistance()) {
|
||
scheduleButtonAutoSwitch('enter-floor', nextDistance)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (
|
||
activeView.value === 'floor'
|
||
&& floorNavigationDistance > 0
|
||
&& nextDistance >= floorNavigationDistance * (1 + GUIDE_AUTO_SWITCH_EXIT_RATIO)
|
||
) {
|
||
scheduleButtonAutoSwitch('exit-overview', nextDistance)
|
||
}
|
||
}
|
||
|
||
const handleWheelIntent = (event: WheelEvent) => {
|
||
if (!controls || activeView.value !== 'overview' || event.deltaY === 0) return
|
||
|
||
const distance = controls.getDistance()
|
||
if (!Number.isFinite(distance)) return
|
||
|
||
activeAutoSwitchInputSource = 'wheel'
|
||
if (event.deltaY > 0) semanticOutwardZoomIntentAt = Date.now()
|
||
autoSwitchStateMachine.beginInput(distance, 'wheel')
|
||
}
|
||
|
||
const canRequestSemanticExteriorExit = () => (
|
||
props.semanticZoomEnabled
|
||
&& !semanticExteriorExitRequested
|
||
&& !weakNetworkFallbackActive.value
|
||
&& !isLoading.value
|
||
&& !isProgrammaticCameraChange
|
||
&& Boolean(controls)
|
||
&& activeView.value === 'overview'
|
||
&& !props.showRoute
|
||
&& !props.routeNavigationActive
|
||
&& !props.routeStartSelectionActive
|
||
&& !props.targetFocus
|
||
&& !selectedPOI.value
|
||
)
|
||
|
||
const requestSemanticExteriorExit = () => {
|
||
if (!canRequestSemanticExteriorExit()) return false
|
||
semanticExteriorExitRequested = true
|
||
emit('semanticExteriorExit')
|
||
return true
|
||
}
|
||
|
||
const requestSemanticExteriorExitFromGesture = () => {
|
||
if (!canRequestSemanticExteriorExit() || !controls || !overviewGestureStartDistance) return
|
||
|
||
const currentDistance = controls.getDistance()
|
||
const maxDistance = controls.maxDistance
|
||
const outwardGesture = currentDistance > overviewGestureStartDistance * 1.025
|
||
const atExteriorBoundary = hasReachedOutdoorExitBoundary(currentDistance, maxDistance)
|
||
const recentWheelIntent = Date.now() - semanticOutwardZoomIntentAt < OUTDOOR_OUTWARD_INTENT_GRACE_MS
|
||
|
||
if (atExteriorBoundary && (outwardGesture || recentWheelIntent)) {
|
||
requestSemanticExteriorExit()
|
||
}
|
||
}
|
||
|
||
const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
|
||
if (
|
||
!props.autoSwitch
|
||
|| autoSwitchTemporarilyDisabled
|
||
|| isLoading.value
|
||
|| isAutoSwitchLocked
|
||
|| (isProgrammaticCameraChange && activeAutoSwitchInputSource !== 'button')
|
||
|| !controls
|
||
|| !activeModel
|
||
|| props.showRoute
|
||
|| activeView.value === 'multi'
|
||
|| activeRouteCompositeModel === activeModel
|
||
) {
|
||
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
|
||
const thresholdToRequestMs = Math.max(0, request.confirmedAt - request.candidateStartedAt)
|
||
const thresholdReachedAt = getNow() - thresholdToRequestMs
|
||
logThreeMapDiagnostic('auto-switch-request', {
|
||
direction: request.direction,
|
||
source: request.source,
|
||
distance: roundModelAdjustValue(request.distance),
|
||
debounceMs: thresholdToRequestMs
|
||
})
|
||
if (request.direction === 'enter-floor') {
|
||
const targetFloorId = getAutoEntryTargetFloorId()
|
||
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
|
||
void runAutoSwitchLoad(
|
||
{
|
||
from: 'overview',
|
||
to: 'floor',
|
||
trigger: 'zoom-in',
|
||
distance: request.distance,
|
||
sceneRevision: props.sceneRevision
|
||
},
|
||
async () => {
|
||
await loadFloor(targetFloorId, {
|
||
preserveCurrentSceneUntilReady: hasSceneToKeep,
|
||
suppressProgress: hasSceneToKeep,
|
||
detachPoiBeforeLoad: true,
|
||
applyFloorBaseline: true,
|
||
onCameraStable: () => {
|
||
activeAutoSwitchInputSource = 'gesture'
|
||
logThreeMapDiagnostic('auto-switch-stable', {
|
||
direction: request.direction,
|
||
source: request.source,
|
||
floorId: targetFloorId,
|
||
elapsedSinceThresholdMs: Math.round(getNow() - thresholdReachedAt)
|
||
})
|
||
}
|
||
})
|
||
},
|
||
{ showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) }
|
||
)
|
||
return
|
||
}
|
||
|
||
// Do not first restore the floor-local camera onto the exterior model and
|
||
// then run a second zoom tween. That transient state was the main source of
|
||
// the perceived exit hitch. Commit the cached exterior directly at its
|
||
// shared reference composition.
|
||
const overviewCamera = getAutoSwitchExitCamera()
|
||
void runAutoSwitchLoad(
|
||
{
|
||
from: 'floor',
|
||
to: 'overview',
|
||
trigger: 'zoom-out',
|
||
distance: request.distance,
|
||
sceneRevision: props.sceneRevision
|
||
},
|
||
() => loadOverview({
|
||
cameraSnapshot: overviewCamera,
|
||
onCameraStable: () => {
|
||
activeAutoSwitchInputSource = 'gesture'
|
||
logThreeMapDiagnostic('auto-switch-stable', {
|
||
direction: request.direction,
|
||
source: request.source,
|
||
elapsedSinceThresholdMs: Math.round(getNow() - thresholdReachedAt)
|
||
})
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
|
||
enterRatio: GUIDE_AUTO_SWITCH_ENTER_RATIO,
|
||
enterHoldMs: autoSwitchEnterHoldMs,
|
||
exitHoldMs: 400,
|
||
intentTimeoutMs: 2000,
|
||
reverseToleranceRatio: 0.008,
|
||
overviewEntryDistance: getOverviewAutoEntryDistance,
|
||
exitRatio: GUIDE_AUTO_SWITCH_EXIT_RATIO,
|
||
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
|
||
sceneRevision: 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 cloneCameraSnapshot = (snapshot: CameraSnapshot): CameraSnapshot => ({
|
||
position: snapshot.position.clone(),
|
||
target: snapshot.target.clone(),
|
||
up: snapshot.up.clone(),
|
||
quaternion: snapshot.quaternion.clone(),
|
||
fov: snapshot.fov,
|
||
zoom: snapshot.zoom,
|
||
near: snapshot.near,
|
||
far: snapshot.far,
|
||
distance: snapshot.distance
|
||
})
|
||
|
||
const getAutoSwitchExitCamera = () => getReferenceOverviewCameraState()
|
||
|
||
const captureCameraSnapshot = (): CameraSnapshot => {
|
||
if (!camera || !controls) return cloneCameraSnapshot(referenceOverviewCameraState)
|
||
|
||
return {
|
||
position: camera.position.clone(),
|
||
target: controls.target.clone(),
|
||
up: camera.up.clone(),
|
||
quaternion: camera.quaternion.clone(),
|
||
fov: camera.fov,
|
||
zoom: camera.zoom,
|
||
near: camera.near,
|
||
far: camera.far,
|
||
distance: controls.getDistance()
|
||
}
|
||
}
|
||
|
||
const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
|
||
if (!camera || !controls) return
|
||
|
||
cameraSnapshotRestoreCount += 1
|
||
cancelCameraTween()
|
||
clearProgrammaticCameraTimer()
|
||
isProgrammaticCameraChange = true
|
||
|
||
// OrbitControls clamps the camera during update(). Make the immutable snapshot part of the
|
||
// valid range before synchronizing controls, otherwise a large floor baseline is clipped by
|
||
// the previous view's maxDistance and each reset converges to a different camera distance.
|
||
const snapshotDistance = snapshot.position.distanceTo(snapshot.target)
|
||
if (Number.isFinite(snapshotDistance) && snapshotDistance > 0) {
|
||
controls.minDistance = Math.min(controls.minDistance, snapshotDistance)
|
||
controls.maxDistance = Math.max(controls.maxDistance, snapshotDistance)
|
||
}
|
||
|
||
// Consume any gesture delta left in OrbitControls without damping before applying the
|
||
// snapshot. Subsequent render frames must not continue a drag or wheel gesture after reset.
|
||
const dampingEnabled = controls.enableDamping
|
||
controls.enableDamping = false
|
||
try {
|
||
controls.update()
|
||
camera.position.copy(snapshot.position)
|
||
camera.up.copy(snapshot.up)
|
||
camera.fov = snapshot.fov
|
||
camera.zoom = snapshot.zoom
|
||
camera.near = snapshot.near
|
||
camera.far = snapshot.far
|
||
camera.updateProjectionMatrix()
|
||
controls.target.copy(snapshot.target)
|
||
controls.update()
|
||
// OrbitControls 以 position/target 同步内部球坐标后,恢复快照四元数以保存精确观察方向。
|
||
camera.quaternion.copy(snapshot.quaternion)
|
||
camera.updateMatrixWorld()
|
||
} finally {
|
||
controls.enableDamping = dampingEnabled
|
||
isProgrammaticCameraChange = false
|
||
}
|
||
}
|
||
|
||
const captureOrbitControlsSnapshot = (): OrbitControlsSnapshot | null => {
|
||
if (!controls) return null
|
||
return {
|
||
enabled: controls.enabled,
|
||
cursor: controls.cursor.clone(),
|
||
enableDamping: controls.enableDamping,
|
||
dampingFactor: controls.dampingFactor,
|
||
enableZoom: controls.enableZoom,
|
||
zoomSpeed: controls.zoomSpeed,
|
||
zoomToCursor: controls.zoomToCursor,
|
||
minDistance: controls.minDistance,
|
||
maxDistance: controls.maxDistance,
|
||
minZoom: controls.minZoom,
|
||
maxZoom: controls.maxZoom,
|
||
minTargetRadius: controls.minTargetRadius,
|
||
maxTargetRadius: controls.maxTargetRadius,
|
||
enableRotate: controls.enableRotate,
|
||
rotateSpeed: controls.rotateSpeed,
|
||
minPolarAngle: controls.minPolarAngle,
|
||
maxPolarAngle: controls.maxPolarAngle,
|
||
minAzimuthAngle: controls.minAzimuthAngle,
|
||
maxAzimuthAngle: controls.maxAzimuthAngle,
|
||
enablePan: controls.enablePan,
|
||
panSpeed: controls.panSpeed,
|
||
screenSpacePanning: controls.screenSpacePanning,
|
||
keyPanSpeed: controls.keyPanSpeed,
|
||
autoRotate: controls.autoRotate,
|
||
autoRotateSpeed: controls.autoRotateSpeed,
|
||
keys: { ...controls.keys },
|
||
mouseButtons: { ...controls.mouseButtons },
|
||
touches: { ...controls.touches },
|
||
target0: controls.target0.clone(),
|
||
position0: controls.position0.clone(),
|
||
zoom0: controls.zoom0
|
||
}
|
||
}
|
||
|
||
const restoreOrbitControlsSnapshot = (snapshot: OrbitControlsSnapshot) => {
|
||
if (!controls) return
|
||
controls.enabled = snapshot.enabled
|
||
controls.cursor.copy(snapshot.cursor)
|
||
controls.enableDamping = snapshot.enableDamping
|
||
controls.dampingFactor = snapshot.dampingFactor
|
||
controls.enableZoom = snapshot.enableZoom
|
||
controls.zoomSpeed = snapshot.zoomSpeed
|
||
controls.zoomToCursor = snapshot.zoomToCursor
|
||
controls.minDistance = snapshot.minDistance
|
||
controls.maxDistance = snapshot.maxDistance
|
||
controls.minZoom = snapshot.minZoom
|
||
controls.maxZoom = snapshot.maxZoom
|
||
controls.minTargetRadius = snapshot.minTargetRadius
|
||
controls.maxTargetRadius = snapshot.maxTargetRadius
|
||
controls.enableRotate = snapshot.enableRotate
|
||
controls.rotateSpeed = snapshot.rotateSpeed
|
||
controls.minPolarAngle = snapshot.minPolarAngle
|
||
controls.maxPolarAngle = snapshot.maxPolarAngle
|
||
controls.minAzimuthAngle = snapshot.minAzimuthAngle
|
||
controls.maxAzimuthAngle = snapshot.maxAzimuthAngle
|
||
controls.enablePan = snapshot.enablePan
|
||
controls.panSpeed = snapshot.panSpeed
|
||
controls.screenSpacePanning = snapshot.screenSpacePanning
|
||
controls.keyPanSpeed = snapshot.keyPanSpeed
|
||
controls.autoRotate = snapshot.autoRotate
|
||
controls.autoRotateSpeed = snapshot.autoRotateSpeed
|
||
Object.assign(controls.keys, snapshot.keys)
|
||
Object.assign(controls.mouseButtons, snapshot.mouseButtons)
|
||
Object.assign(controls.touches, snapshot.touches)
|
||
controls.target0.copy(snapshot.target0)
|
||
controls.position0.copy(snapshot.position0)
|
||
controls.zoom0 = snapshot.zoom0
|
||
}
|
||
|
||
const applyLiveGlbTopView = (options: {
|
||
captureRestoreState?: boolean
|
||
transitionRevision?: number
|
||
} = {}) => {
|
||
if (
|
||
!scene
|
||
|| !renderer
|
||
|| !camera
|
||
|| !controls
|
||
|| !activeModel
|
||
|| weakNetworkFallbackActive.value
|
||
|| !isTwoDimensionalMode.value
|
||
|| (
|
||
options.transitionRevision !== undefined
|
||
&& options.transitionRevision !== renderModeTransitionRevision
|
||
)
|
||
) return false
|
||
|
||
cancelCameraTween()
|
||
clearProgrammaticCameraTimer()
|
||
const cameraSnapshot = captureCameraSnapshot()
|
||
const controlsSnapshot = captureOrbitControlsSnapshot()
|
||
if (!controlsSnapshot) return false
|
||
|
||
if (options.captureRestoreState !== false) {
|
||
liveGlbThreeDCameraSnapshot = cloneCameraSnapshot(cameraSnapshot)
|
||
liveGlbThreeDControlsSnapshot = controlsSnapshot
|
||
}
|
||
|
||
const pose = createGuideTopViewPose({
|
||
position: cameraSnapshot.position,
|
||
target: cameraSnapshot.target,
|
||
quaternion: cameraSnapshot.quaternion,
|
||
fov: cameraSnapshot.fov,
|
||
aspect: camera.aspect
|
||
})
|
||
const dampingEnabled = controls.enableDamping
|
||
controls.enableDamping = false
|
||
try {
|
||
controls.update()
|
||
controls.target.copy(pose.target)
|
||
camera.position.copy(pose.position)
|
||
camera.up.copy(pose.up)
|
||
camera.lookAt(pose.target)
|
||
camera.updateProjectionMatrix()
|
||
camera.updateMatrixWorld()
|
||
liveGlbTopActive.value = true
|
||
threeRendererRetainedForTwoD.value = false
|
||
syncControlInteractionOptions()
|
||
controls.update()
|
||
} finally {
|
||
controls.enableDamping = dampingEnabled
|
||
}
|
||
|
||
startRenderLoop()
|
||
refreshPoiVisibilityByDistance()
|
||
return true
|
||
}
|
||
|
||
const reapplyLiveGlbTopAfterSceneCommit = () => {
|
||
if (
|
||
!liveGlbTopActive.value
|
||
|| weakNetworkFallbackActive.value
|
||
|| !isTwoDimensionalMode.value
|
||
) return false
|
||
|
||
// The committed camera is the new scene's 3D baseline. Capture it before
|
||
// applying top view so returning to 3D never restores the previous floor.
|
||
liveGlbTopActive.value = false
|
||
syncControlInteractionOptions()
|
||
return applyLiveGlbTopView({ captureRestoreState: true })
|
||
}
|
||
|
||
const restoreLiveGlbThreeDimensionalView = () => {
|
||
if (
|
||
!liveGlbTopActive.value
|
||
|| !camera
|
||
|| !controls
|
||
|| !liveGlbThreeDCameraSnapshot
|
||
|| !liveGlbThreeDControlsSnapshot
|
||
) return false
|
||
|
||
const currentTarget = controls.target.clone()
|
||
const currentDistance = controls.getDistance()
|
||
const snapshot = cloneCameraSnapshot(liveGlbThreeDCameraSnapshot)
|
||
const returnPose = createGuideObliqueReturnPose({
|
||
originalPosition: snapshot.position,
|
||
originalTarget: snapshot.target,
|
||
currentTarget,
|
||
currentDistance
|
||
})
|
||
snapshot.target.copy(returnPose.target)
|
||
snapshot.position.copy(returnPose.position)
|
||
snapshot.distance = returnPose.distance
|
||
|
||
liveGlbTopActive.value = false
|
||
restoreOrbitControlsSnapshot(liveGlbThreeDControlsSnapshot)
|
||
restoreCameraSnapshot(snapshot)
|
||
restoreOrbitControlsSnapshot(liveGlbThreeDControlsSnapshot)
|
||
liveGlbThreeDCameraSnapshot = null
|
||
liveGlbThreeDControlsSnapshot = null
|
||
startRenderLoop()
|
||
refreshPoiVisibilityByDistance()
|
||
return true
|
||
}
|
||
|
||
export type ResetViewBaselineResult = 'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale'
|
||
|
||
const getReferenceOverviewCameraState = () => {
|
||
const snapshot = cloneCameraSnapshot(referenceOverviewCameraState)
|
||
if (!renderer) return snapshot
|
||
|
||
const viewportWidth = renderer.domElement.clientWidth
|
||
const viewportHeight = renderer.domElement.clientHeight
|
||
if (!viewportWidth || !viewportHeight) return snapshot
|
||
|
||
const viewportAspect = viewportWidth / viewportHeight
|
||
const referenceAspect = 430 / 720
|
||
const narrowViewportScale = Math.min(1.35, Math.max(1, referenceAspect / viewportAspect))
|
||
if (narrowViewportScale > 1) {
|
||
const direction = snapshot.position.clone().sub(snapshot.target)
|
||
snapshot.position.copy(snapshot.target).add(direction.multiplyScalar(narrowViewportScale))
|
||
}
|
||
|
||
// Use the same horizontal safe-area composition when returning from an
|
||
// indoor floor. Without this offset the exterior model appears left-biased
|
||
// beside the right-side action controls.
|
||
const viewDirection = snapshot.target.clone().sub(snapshot.position).normalize()
|
||
const cameraRight = new THREE.Vector3().crossVectors(viewDirection, snapshot.up).normalize()
|
||
const fov = snapshot.fov * (Math.PI / 180)
|
||
const viewHeight = 2 * snapshot.distance * Math.tan(fov / 2)
|
||
const viewWidth = viewHeight * viewportAspect
|
||
const horizontalOffset = cameraRight.multiplyScalar(
|
||
SGS_VISUAL_RENDER_CONFIG.framing.overviewScreenOffsetRatio.x * viewWidth
|
||
)
|
||
snapshot.position.add(horizontalOffset)
|
||
snapshot.target.add(horizontalOffset)
|
||
|
||
snapshot.position.y += SGS_VISUAL_RENDER_CONFIG.framing.overviewReferenceVerticalOffset
|
||
snapshot.target.y += SGS_VISUAL_RENDER_CONFIG.framing.overviewReferenceVerticalOffset
|
||
snapshot.distance = snapshot.position.distanceTo(snapshot.target)
|
||
return snapshot
|
||
}
|
||
|
||
const applyReferenceOverviewCameraState = () => {
|
||
const snapshot = getReferenceOverviewCameraState()
|
||
restoreCameraSnapshot(snapshot)
|
||
if (controls) {
|
||
controls.maxDistance = Math.max(
|
||
controls.maxDistance,
|
||
snapshot.distance * SGS_VISUAL_RENDER_CONFIG.framing.overviewMaxDistanceFactor
|
||
)
|
||
}
|
||
}
|
||
|
||
const clearFloorViewBaselines = () => {
|
||
floorViewBaselines.clear()
|
||
}
|
||
|
||
const getBoxSignature = (box: THREE.Box3) => [
|
||
box.min.x,
|
||
box.min.y,
|
||
box.min.z,
|
||
box.max.x,
|
||
box.max.y,
|
||
box.max.z
|
||
].map((value) => value.toFixed(6)).join(':')
|
||
|
||
const createFloorNavigationCamera = (model: THREE.Object3D) => {
|
||
const fallback = cloneCameraSnapshot(referenceFloorCameraState)
|
||
if (!camera || !controls) {
|
||
return {
|
||
camera: fallback,
|
||
minDistance: fallback.distance * floorZoomInLimitRatio,
|
||
maxDistance: fallback.distance * floorZoomOutLimitRatio
|
||
}
|
||
}
|
||
|
||
const box = getObjectBox(model)
|
||
const size = box.getSize(new THREE.Vector3())
|
||
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||
const distance = getIndoorReferenceCameraDistance(camera.fov)
|
||
const exteriorCamera = getReferenceOverviewCameraState()
|
||
const direction = exteriorCamera.position.clone().sub(exteriorCamera.target).normalize()
|
||
const up = exteriorCamera.up.clone()
|
||
const floorId = typeof model.userData.floorId === 'string'
|
||
? model.userData.floorId
|
||
: currentFloor.value
|
||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||
const level = getFloorSortLevel(floor)
|
||
// B2 to 2F share the exterior's world target. MF and the compact upper
|
||
// floors occupy only a corner of that footprint, so they use their own
|
||
// geometric center while preserving the same camera direction/distance and
|
||
// the same screen-safe visual center.
|
||
const useCompactFloorVisualCenter = level === 1.5 || level >= 3
|
||
const target = useCompactFloorVisualCenter
|
||
? box.getCenter(new THREE.Vector3())
|
||
: exteriorCamera.target.clone()
|
||
const position = target.clone().add(direction.multiplyScalar(distance))
|
||
|
||
if (useCompactFloorVisualCenter) {
|
||
const viewDirection = target.clone().sub(position).normalize()
|
||
const cameraRight = new THREE.Vector3().crossVectors(viewDirection, up).normalize()
|
||
const fov = camera.fov * (Math.PI / 180)
|
||
const viewHeight = 2 * distance * Math.tan(fov / 2)
|
||
const viewWidth = viewHeight * camera.aspect
|
||
const screenOffset = cameraRight
|
||
.multiplyScalar(SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio.x * viewWidth)
|
||
.add(up.clone().multiplyScalar(SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio.y * viewHeight))
|
||
position.add(screenOffset)
|
||
target.add(screenOffset)
|
||
}
|
||
|
||
const framingCamera = camera.clone()
|
||
framingCamera.position.copy(position)
|
||
framingCamera.up.copy(up)
|
||
framingCamera.lookAt(target)
|
||
framingCamera.updateProjectionMatrix()
|
||
framingCamera.updateMatrixWorld()
|
||
|
||
return {
|
||
camera: {
|
||
position: framingCamera.position.clone(),
|
||
target,
|
||
up: framingCamera.up.clone(),
|
||
quaternion: framingCamera.quaternion.clone(),
|
||
fov: framingCamera.fov,
|
||
zoom: framingCamera.zoom,
|
||
near: Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1000),
|
||
far: Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20),
|
||
distance
|
||
},
|
||
minDistance: Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, distance * floorZoomInLimitRatio),
|
||
maxDistance: Math.max(distance, distance * floorZoomOutLimitRatio)
|
||
}
|
||
}
|
||
|
||
const applyFloorNavigationRange = (baseline: Pick<FloorViewBaseline, 'camera' | 'minDistance' | 'maxDistance'>) => {
|
||
if (!controls) return
|
||
|
||
controls.minDistance = baseline.minDistance
|
||
controls.maxDistance = baseline.maxDistance
|
||
floorNavigationDistance = baseline.camera.distance
|
||
autoSwitchStateMachine.setFloorInitialDistance(floorNavigationDistance)
|
||
}
|
||
|
||
const createFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string): FloorViewBaseline | null => {
|
||
if (!camera || !controls) return null
|
||
|
||
const box = getObjectBox(model)
|
||
const framing = createFloorNavigationCamera(model)
|
||
|
||
return {
|
||
floorId,
|
||
modelUrl,
|
||
packageEpoch: modelPackageEpoch,
|
||
aspectRatio: camera.aspect,
|
||
boundsSignature: getBoxSignature(box),
|
||
camera: framing.camera,
|
||
minDistance: framing.minDistance,
|
||
maxDistance: framing.maxDistance
|
||
}
|
||
}
|
||
|
||
const getFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string) => {
|
||
const cached = floorViewBaselines.get(floorId)
|
||
const boundsSignature = getBoxSignature(getObjectBox(model))
|
||
if (
|
||
cached
|
||
&& cached.modelUrl === modelUrl
|
||
&& cached.packageEpoch === modelPackageEpoch
|
||
&& cached.boundsSignature === boundsSignature
|
||
&& Math.abs(cached.aspectRatio - (camera?.aspect || 1)) < 1e-6
|
||
) {
|
||
return cached
|
||
}
|
||
|
||
const baseline = createFloorViewBaseline(floorId, model, modelUrl)
|
||
if (baseline) floorViewBaselines.set(floorId, baseline)
|
||
return baseline || null
|
||
}
|
||
|
||
const serializeCameraSnapshot = (snapshot: CameraSnapshot) => ({
|
||
position: { x: snapshot.position.x, y: snapshot.position.y, z: snapshot.position.z },
|
||
target: { x: snapshot.target.x, y: snapshot.target.y, z: snapshot.target.z },
|
||
up: { x: snapshot.up.x, y: snapshot.up.y, z: snapshot.up.z },
|
||
quaternion: {
|
||
x: snapshot.quaternion.x,
|
||
y: snapshot.quaternion.y,
|
||
z: snapshot.quaternion.z,
|
||
w: snapshot.quaternion.w
|
||
},
|
||
fov: snapshot.fov,
|
||
zoom: snapshot.zoom,
|
||
near: snapshot.near,
|
||
far: snapshot.far,
|
||
distance: snapshot.distance
|
||
})
|
||
|
||
const getCameraSnapshotDelta = (
|
||
expected: CameraSnapshot,
|
||
actual: CameraSnapshot = captureCameraSnapshot()
|
||
) => {
|
||
const position = expected.position.distanceTo(actual.position)
|
||
const target = expected.target.distanceTo(actual.target)
|
||
const up = expected.up.distanceTo(actual.up)
|
||
const quaternion = expected.quaternion.angleTo(actual.quaternion)
|
||
const fov = Math.abs(expected.fov - actual.fov)
|
||
const zoom = Math.abs(expected.zoom - actual.zoom)
|
||
const near = Math.abs(expected.near - actual.near)
|
||
const far = Math.abs(expected.far - actual.far)
|
||
const distance = Math.abs(expected.distance - actual.distance)
|
||
|
||
return {
|
||
position,
|
||
target,
|
||
up,
|
||
quaternion,
|
||
fov,
|
||
zoom,
|
||
near,
|
||
far,
|
||
distance,
|
||
max: Math.max(position, target, up, quaternion, fov, zoom, near, far, distance)
|
||
}
|
||
}
|
||
|
||
const getModelRootTransformAudit = (model: THREE.Object3D | null) => {
|
||
if (!model) return null
|
||
|
||
model.updateMatrixWorld(true)
|
||
const positionError = model.position.length()
|
||
const rotationError = Math.max(
|
||
Math.abs(model.rotation.x),
|
||
Math.abs(model.rotation.y),
|
||
Math.abs(model.rotation.z)
|
||
)
|
||
const scaleError = Math.max(
|
||
Math.abs(model.scale.x - 1),
|
||
Math.abs(model.scale.y - 1),
|
||
Math.abs(model.scale.z - 1)
|
||
)
|
||
|
||
return {
|
||
name: model.name,
|
||
position: serializeVector3(model.position),
|
||
rotationDeg: serializeEulerDegrees(model.rotation),
|
||
scale: serializeVector3(model.scale),
|
||
positionError,
|
||
rotationError,
|
||
scaleError,
|
||
maxError: Math.max(positionError, rotationError, scaleError)
|
||
}
|
||
}
|
||
|
||
const updateReferenceBuildingAnchors = (model: THREE.Object3D) => {
|
||
const box = getOverviewBuildingSubjectBox(model)
|
||
if (box.isEmpty()) return
|
||
|
||
const createAnchor = (id: string, x: number, y: number, z: number): BuildingAnchor => ({
|
||
id,
|
||
position: new THREE.Vector3(
|
||
THREE.MathUtils.lerp(box.min.x, box.max.x, x),
|
||
THREE.MathUtils.lerp(box.min.y, box.max.y, y),
|
||
THREE.MathUtils.lerp(box.min.z, box.max.z, z)
|
||
)
|
||
})
|
||
referenceBuildingAnchors = [
|
||
createAnchor('building-anchor-a', 0.25, 0.26, 0.28),
|
||
createAnchor('building-anchor-b', 0.5, 0.56, 0.5),
|
||
createAnchor('building-anchor-c', 0.76, 0.34, 0.72)
|
||
]
|
||
}
|
||
|
||
const getProjectedBuildingAnchors = () => {
|
||
if (!camera || !renderer) return []
|
||
|
||
const rect = renderer.domElement.getBoundingClientRect()
|
||
return referenceBuildingAnchors.map((anchor) => {
|
||
const ndc = anchor.position.clone().project(camera!)
|
||
return {
|
||
id: anchor.id,
|
||
world: { x: anchor.position.x, y: anchor.position.y, z: anchor.position.z },
|
||
screen: {
|
||
x: rect.left + (ndc.x + 1) * rect.width / 2,
|
||
y: rect.top + (1 - ndc.y) * rect.height / 2,
|
||
z: ndc.z,
|
||
visible: ndc.z >= -1 && ndc.z <= 1
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const getVisualFocusReport = () => {
|
||
if (!selectedPOI.value?.positionGltf) return null
|
||
|
||
const marker = findPoiSprite(selectedPOI.value.id)
|
||
return {
|
||
poiId: selectedPOI.value.id,
|
||
floorId: selectedPOI.value.floorId,
|
||
positionGltf: [...selectedPOI.value.positionGltf] as [number, number, number],
|
||
markerPosition: marker
|
||
? { x: marker.position.x, y: marker.position.y, z: marker.position.z }
|
||
: null
|
||
}
|
||
}
|
||
|
||
const getVisiblePoiScreenPositions = () => {
|
||
if (!renderer || !camera) return []
|
||
|
||
const rect = renderer.domElement.getBoundingClientRect()
|
||
return getPoiSprites().flatMap((sprite) => {
|
||
const poi = sprite.userData.poi as RenderPoi | undefined
|
||
const screen = getProjectedScreenPosition(sprite)
|
||
let ancestor: THREE.Object3D | null = sprite
|
||
while (ancestor) {
|
||
if (!ancestor.visible) return []
|
||
ancestor = ancestor.parent
|
||
}
|
||
if (
|
||
!poi
|
||
|| !screen
|
||
|| screen.x < 0
|
||
|| screen.x > rect.width
|
||
|| screen.y < 0
|
||
|| screen.y > rect.height
|
||
) return []
|
||
|
||
// Match the production fallback hit-test so diagnostics only expose coordinates
|
||
// that a tap can resolve to this exact current marker.
|
||
const hit = findNearestPoiMarkerByScreenPoint({
|
||
clientX: rect.left + screen.x,
|
||
clientY: rect.top + screen.y
|
||
} as PointerEvent, rect)
|
||
if (hit !== sprite) return []
|
||
|
||
return [{
|
||
poiId: poi.id,
|
||
floorId: poi.floorId,
|
||
name: poi.name,
|
||
kind: poi.kind,
|
||
primaryCategory: poi.primaryCategory,
|
||
screen: {
|
||
x: rect.left + screen.x,
|
||
y: rect.top + screen.y
|
||
}
|
||
}]
|
||
})
|
||
}
|
||
|
||
const getVisibleVisualMarkerWithoutTextCount = () => getPoiSprites().filter((sprite) => {
|
||
const userData = getPoiSpriteUserData(sprite)
|
||
if (!userData.usesDomLabelIcon || sprite.material.opacity <= 0) return false
|
||
|
||
let ancestor: THREE.Object3D | null = sprite
|
||
while (ancestor) {
|
||
if (!ancestor.visible) return false
|
||
ancestor = ancestor.parent
|
||
}
|
||
return true
|
||
}).length
|
||
|
||
const getVisualStabilityReport = () => ({
|
||
camera: serializeCameraSnapshot(captureCameraSnapshot()),
|
||
actualRendererPath: actualRendererPath.value,
|
||
renderLoopRunning,
|
||
sceneUuid: scene?.uuid || null,
|
||
activeModelUuid: activeModel?.uuid || null,
|
||
controls: controls
|
||
? {
|
||
enableRotate: controls.enableRotate,
|
||
enablePan: controls.enablePan,
|
||
enableZoom: controls.enableZoom,
|
||
minDistance: controls.minDistance,
|
||
maxDistance: controls.maxDistance,
|
||
minPolarAngle: controls.minPolarAngle,
|
||
maxPolarAngle: controls.maxPolarAngle,
|
||
minAzimuthAngle: controls.minAzimuthAngle,
|
||
maxAzimuthAngle: controls.maxAzimuthAngle
|
||
}
|
||
: null,
|
||
modelRoot: getModelRootTransformAudit(activeModel),
|
||
anchors: getProjectedBuildingAnchors(),
|
||
activeView: activeView.value,
|
||
floorId: currentFloor.value,
|
||
activeFocusPoiId: activeFocusPoiId.value,
|
||
hasPendingTargetFocus: Boolean(pendingTargetFocus),
|
||
isCameraTweening: Boolean(cameraTween),
|
||
poiFocusCameraAnimationCount,
|
||
cameraSnapshotRestoreCount,
|
||
visibleVisualMarkerWithoutTextCount: getVisibleVisualMarkerWithoutTextCount(),
|
||
focus: getVisualFocusReport()
|
||
})
|
||
|
||
const getPoiFocusState = (requestedPoiId = activeFocusPoiId.value) => {
|
||
const markerCacheKey = getPoiMarkerCacheKey(currentFloor.value)
|
||
const entry = poiMarkerGroupCache.get(markerCacheKey)
|
||
const poi = entry?.pois.find((candidate) => candidate.id === requestedPoiId)
|
||
|| (selectedPOI.value?.id === requestedPoiId ? selectedPOI.value : null)
|
||
const isActiveFocus = Boolean(requestedPoiId && requestedPoiId === activeFocusPoiId.value)
|
||
|
||
return {
|
||
poiId: requestedPoiId,
|
||
selectedPoiId: selectedPOI.value?.id || '',
|
||
dataTier: entry?.dataTier || poiDataTierCache.get(currentFloor.value) || null,
|
||
focusStartedDataTier: isActiveFocus ? activeFocusStartedDataTier : null,
|
||
markerCount: getPoiSprites().filter((sprite) => (
|
||
(sprite.userData.poi as RenderPoi | undefined)?.id === requestedPoiId
|
||
)).length,
|
||
displayPositionCount: poi ? getPoiDisplayPositions(poi).length : 0,
|
||
baseAffordanceCount: isActiveFocus ? activeFocusBaseSprites.length : 0,
|
||
pulseAffordanceCount: isActiveFocus ? activeFocusPulseSprites.length : 0,
|
||
glowAffordanceCount: isActiveFocus && activeFocusHallGlowMesh ? 1 : 0,
|
||
modelHighlightRootCount: isActiveFocus ? activeFocusModelRootCount : 0,
|
||
modelHighlightRootNames: isActiveFocus ? [...activeFocusModelRootNames] : [],
|
||
modelHighlightMaterials: isActiveFocus
|
||
? activeFocusHallMaterialStates.flatMap((state) => state.clonedMaterials.map((material) => ({
|
||
meshName: state.mesh.name,
|
||
attached: Array.isArray(state.mesh.material)
|
||
? state.mesh.material.includes(material)
|
||
: state.mesh.material === material,
|
||
color: getMaterialColor(material)?.getHexString() || null,
|
||
emissive: getMaterialEmissive(material)?.getHexString() || null,
|
||
hasMap: Boolean((material as THREE.Material & { map?: THREE.Texture | null }).map)
|
||
})))
|
||
: []
|
||
}
|
||
}
|
||
|
||
const getAmbientPoiLabelStates = () => (
|
||
Array.from(poiDomLabelHandlesByElement.values())
|
||
.filter((handle) => handle.kind === 'ambient' && handle.floorId === currentFloor.value)
|
||
.map((handle) => {
|
||
const worldPosition = handle.anchor.getWorldPosition(new THREE.Vector3())
|
||
const screenPosition = camera && renderer
|
||
? getProjectedScreenPosition(handle.anchor)
|
||
: null
|
||
const positionedPoint = screenPosition
|
||
? {
|
||
x: screenPosition.x + handle.layoutOffset.x,
|
||
y: screenPosition.y + handle.layoutOffset.y
|
||
}
|
||
: null
|
||
const inViewport = Boolean(
|
||
positionedPoint
|
||
&& renderer
|
||
&& isDomLabelWithinViewport(
|
||
getDomLabelBounds(positionedPoint, handle.size, handle.anchorMode),
|
||
renderer.domElement.clientWidth,
|
||
renderer.domElement.clientHeight
|
||
)
|
||
)
|
||
return {
|
||
poiId: handle.poi.id,
|
||
floorId: handle.poi.floorId,
|
||
iconType: handle.poi.iconType,
|
||
text: handle.element.textContent || '',
|
||
visible: handle.element.style.visibility === 'visible'
|
||
&& handle.element.style.opacity !== '0',
|
||
inViewport,
|
||
anchor: {
|
||
x: worldPosition.x,
|
||
y: worldPosition.y,
|
||
z: worldPosition.z
|
||
}
|
||
}
|
||
})
|
||
)
|
||
|
||
const installVisualStabilityDiagnostics = () => {
|
||
if (!import.meta.env.DEV || typeof window === 'undefined') return
|
||
|
||
const diagnosticsWindow = window as unknown as {
|
||
__GUIDE_3D_VISUAL_STABILITY__?: Record<string, unknown>
|
||
}
|
||
diagnosticsWindow.__GUIDE_3D_VISUAL_STABILITY__ = {
|
||
getReport: getVisualStabilityReport,
|
||
getPoiFocusState,
|
||
isInitialModelReady: () => initialModelSettled && Boolean(initialGuideState && activeModel),
|
||
getVisiblePoiScreenPositions,
|
||
getAmbientPoiLabelStates,
|
||
getFloors: () => floorIndex.value.map((floor) => ({
|
||
floorId: floor.floorId,
|
||
label: floor.label,
|
||
modelMatchKeys: floor.modelMatchKeys || []
|
||
})),
|
||
getFloorBaseline: (floorId: string) => {
|
||
const baseline = floorViewBaselines.get(floorId)
|
||
return baseline ? serializeCameraSnapshot(baseline.camera) : null
|
||
},
|
||
getCameraCalibration: () => ({ ...cameraCalibration.value }),
|
||
setCameraCalibration: (values: Partial<CameraCalibrationState>) => {
|
||
for (const [key, value] of Object.entries(values) as Array<[CameraCalibrationKey, number | undefined]>) {
|
||
if (typeof value === 'number') {
|
||
updateCameraCalibration(key, value)
|
||
}
|
||
}
|
||
},
|
||
switchFloor: handleFloorChange,
|
||
showOverview,
|
||
showMultiFloor,
|
||
resetCamera,
|
||
zoomCamera,
|
||
getPoiVisibilityTier,
|
||
getFloorPois: async (floorId: string) => {
|
||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||
if (!floor) return []
|
||
const pois = poiDataCache.get(floorId) || await props.modelSource.loadFloorPois(floorId)
|
||
return pois
|
||
.filter((poi) => Boolean(poi.positionGltf))
|
||
.map((poi) => ({
|
||
poiId: poi.id,
|
||
floorId: poi.floorId,
|
||
name: poi.name,
|
||
kind: poi.kind,
|
||
primaryCategory: poi.primaryCategory,
|
||
iconType: poi.iconType,
|
||
sourceObjectName: poi.sourceObjectName,
|
||
mergedSourceObjectNames: poi.mergedSourceObjectNames || [],
|
||
positionGltf: poi.positionGltf!
|
||
}))
|
||
},
|
||
focusTargetPoi: async (request: TargetPoiFocusRequest) => {
|
||
queueTargetFocus(request)
|
||
return targetFocusQueue
|
||
},
|
||
clearTargetFocus,
|
||
resetToViewBaseline,
|
||
resetToInitialState
|
||
}
|
||
}
|
||
|
||
const disposeVisualStabilityDiagnostics = () => {
|
||
if (!import.meta.env.DEV || typeof window === 'undefined') return
|
||
|
||
const diagnosticsWindow = window as unknown as {
|
||
__GUIDE_3D_VISUAL_STABILITY__?: Record<string, unknown>
|
||
}
|
||
delete diagnosticsWindow.__GUIDE_3D_VISUAL_STABILITY__
|
||
}
|
||
|
||
const recordCameraSnapshotRestoration = (
|
||
expected: CameraSnapshot,
|
||
transition: string
|
||
) => {
|
||
const cameraDelta = getCameraSnapshotDelta(expected)
|
||
const modelRoot = getModelRootTransformAudit(activeModel)
|
||
logThreeMapDiagnostic('model-camera-stability', {
|
||
transition,
|
||
cameraDelta,
|
||
modelRoot,
|
||
anchors: getProjectedBuildingAnchors()
|
||
})
|
||
logThreeMapDiagnostic('model-coordinate-audit', {
|
||
transition,
|
||
modelRoot
|
||
})
|
||
}
|
||
|
||
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, {
|
||
durationMs: options.durationMs,
|
||
immediate: options.immediate,
|
||
onComplete: options.onComplete
|
||
})
|
||
}
|
||
|
||
const fitCameraToObject = (
|
||
object: THREE.Object3D,
|
||
preset: CameraPreset = 'oblique',
|
||
transitionOptions: {
|
||
durationMs?: number
|
||
immediate?: boolean
|
||
onComplete?: () => void
|
||
} = {}
|
||
) => {
|
||
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: camera.up.clone()
|
||
}
|
||
: {}
|
||
|
||
overviewFitOptions.durationMs = transitionOptions.durationMs
|
||
overviewFitOptions.immediate = transitionOptions.immediate
|
||
overviewFitOptions.onComplete = () => {
|
||
if (activeView.value === 'floor' && controls) {
|
||
const distance = controls.getDistance()
|
||
if (Number.isFinite(distance)) {
|
||
// 楼层切换或相机复位完成后,以最终稳定距离作为退出基准。
|
||
autoSwitchStateMachine.setFloorInitialDistance(distance)
|
||
}
|
||
}
|
||
|
||
transitionOptions.onComplete?.()
|
||
}
|
||
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 resetCamera = () => {
|
||
if (weakNetworkFallbackActive.value) {
|
||
weakNetworkFallbackRef.value?.resetCamera?.()
|
||
return
|
||
}
|
||
|
||
if (activeView.value === 'floor' && activeModel) {
|
||
const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value)
|
||
const baseline = floor && getFloorViewBaseline(currentFloor.value, activeModel, floor.modelUrl)
|
||
if (baseline) {
|
||
restoreCameraSnapshot(baseline.camera)
|
||
applyFloorNavigationRange(baseline)
|
||
}
|
||
} else {
|
||
applyReferenceOverviewCameraState()
|
||
}
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
refreshPoiVisibilityByDistance()
|
||
}
|
||
|
||
const setCameraPreset = (preset: CameraPreset) => {
|
||
if (activeModel) {
|
||
fitCameraToObject(activeModel, preset)
|
||
}
|
||
}
|
||
|
||
let weakNetworkSceneTransitionRevision = 0
|
||
let weakNetworkSceneTransitionInFlight = false
|
||
|
||
const handleWeakNetworkZoomIntent = async (event: {
|
||
direction: 'in' | 'out'
|
||
source: 'button' | 'gesture'
|
||
boundaryAttempt: boolean
|
||
referenceVisibleWorldSpan: number | null
|
||
thresholdReached: boolean
|
||
outdoorExitReached: boolean
|
||
viewport: GuideViewportState
|
||
}) => {
|
||
if (
|
||
!weakNetworkFallbackActive.value
|
||
|| isLoading.value
|
||
|| weakNetworkSceneTransitionInFlight
|
||
) return
|
||
const from = activeView.value
|
||
|
||
if (
|
||
from === 'overview'
|
||
&& event.direction === 'out'
|
||
&& event.outdoorExitReached
|
||
&& requestWeakNetworkSemanticExteriorExit()
|
||
) return
|
||
|
||
if (!props.autoSwitch) return
|
||
const shouldEnterFloor = from === 'overview' && event.direction === 'in'
|
||
const shouldExitOverview = from === 'floor' && event.direction === 'out'
|
||
if (!shouldEnterFloor && !shouldExitOverview) return
|
||
|
||
const exitBlocked = shouldExitOverview && isGuideFloorAutoExitBlocked({
|
||
disableAutoExit: props.disableAutoExit,
|
||
hasQuery: props.visiblePoiIds !== null || Boolean(props.targetFocus),
|
||
hasSelection: Boolean(selectedPOI.value),
|
||
hasRoute: props.showRoute || Boolean(props.routePreview) || props.routeStartSelectionActive,
|
||
isNavigating: props.routeNavigationActive
|
||
})
|
||
const now = Date.now()
|
||
const hasReferenceSpan = event.referenceVisibleWorldSpan !== null
|
||
&& Number.isFinite(event.referenceVisibleWorldSpan)
|
||
&& event.referenceVisibleWorldSpan > 0
|
||
if (hasReferenceSpan) {
|
||
weakNetworkBoundaryIntentState = {
|
||
...weakNetworkBoundaryIntentState,
|
||
pendingKey: '',
|
||
pendingAt: 0
|
||
}
|
||
if (
|
||
!event.thresholdReached
|
||
|| exitBlocked
|
||
|| now - weakNetworkBoundaryIntentState.lastTransitionAt < props.autoSwitchCooldown
|
||
) return
|
||
} else {
|
||
const boundaryDecision = reduceGuideBoundaryIntent(weakNetworkBoundaryIntentState, {
|
||
scene: from === 'overview' ? 'overview' : 'floor',
|
||
direction: event.direction,
|
||
boundaryAttempt: event.boundaryAttempt,
|
||
blocked: exitBlocked,
|
||
now,
|
||
cooldownMs: props.autoSwitchCooldown
|
||
})
|
||
weakNetworkBoundaryIntentState = boundaryDecision.state
|
||
if (!boundaryDecision.allowed) return
|
||
}
|
||
|
||
weakNetworkSceneTransitionInFlight = true
|
||
const transitionRevision = ++weakNetworkSceneTransitionRevision
|
||
try {
|
||
const didPrepare = shouldEnterFloor
|
||
? await prepareWeakNetworkFallbackFloor(currentFloor.value)
|
||
: await prepareWeakNetworkFallbackOverview()
|
||
if (!didPrepare || transitionRevision !== weakNetworkSceneTransitionRevision) return
|
||
|
||
if (hasReferenceSpan) {
|
||
weakNetworkBoundaryIntentState = {
|
||
pendingKey: '',
|
||
pendingAt: 0,
|
||
lastTransitionAt: now
|
||
}
|
||
}
|
||
const to = shouldEnterFloor ? 'floor' : 'overview'
|
||
emit('autoSwitch', {
|
||
from: from === 'overview' ? 'overview' : 'floor',
|
||
to,
|
||
trigger: shouldEnterFloor ? 'zoom-in' : 'zoom-out',
|
||
distance: event.viewport.visibleWorldSpan,
|
||
sceneRevision: props.sceneRevision
|
||
})
|
||
} finally {
|
||
if (transitionRevision === weakNetworkSceneTransitionRevision) {
|
||
weakNetworkSceneTransitionInFlight = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const canRequestWeakNetworkSemanticExteriorExit = () => (
|
||
props.semanticZoomEnabled
|
||
&& !semanticExteriorExitRequested
|
||
&& weakNetworkFallbackActive.value
|
||
&& !isLoading.value
|
||
&& activeView.value === 'overview'
|
||
&& !props.showRoute
|
||
&& !props.routeNavigationActive
|
||
&& !props.routeStartSelectionActive
|
||
&& !props.targetFocus
|
||
&& !selectedPOI.value
|
||
)
|
||
|
||
const requestWeakNetworkSemanticExteriorExit = () => {
|
||
if (!canRequestWeakNetworkSemanticExteriorExit()) return false
|
||
semanticExteriorExitRequested = true
|
||
emit('semanticExteriorExit')
|
||
return true
|
||
}
|
||
|
||
const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => {
|
||
if (weakNetworkFallbackActive.value) {
|
||
const source = options.source || 'button'
|
||
const renderer = weakNetworkFallbackRef.value
|
||
if (renderer?.zoomCamera) {
|
||
pendingWeakNetworkZoom = null
|
||
return renderer.zoomCamera(direction, source)
|
||
}
|
||
pendingWeakNetworkZoom = { direction, source }
|
||
void nextTick(() => {
|
||
if (!weakNetworkFallbackActive.value || !pendingWeakNetworkZoom) return
|
||
const nextZoom = pendingWeakNetworkZoom
|
||
pendingWeakNetworkZoom = null
|
||
weakNetworkFallbackRef.value?.zoomCamera?.(nextZoom.direction, nextZoom.source)
|
||
})
|
||
return
|
||
}
|
||
|
||
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
|
||
|
||
if (
|
||
direction === 'out'
|
||
&& activeView.value === 'overview'
|
||
&& hasReachedOutdoorExitBoundary(nextDistance, maxDistance)
|
||
&& requestSemanticExteriorExit()
|
||
) return
|
||
|
||
const shouldTrackUserZoom = options.source === 'button'
|
||
if (shouldTrackUserZoom) {
|
||
trackButtonZoomForAutoSwitch(currentDistance, nextDistance)
|
||
}
|
||
|
||
offset.setLength(nextDistance)
|
||
const nextPosition = controls.target.clone().add(offset)
|
||
moveCameraTo(nextPosition, controls.target, {
|
||
durationMs: shouldTrackUserZoom ? buttonZoomTweenDurationMs : undefined,
|
||
onComplete: () => handleControlChange()
|
||
})
|
||
}
|
||
|
||
const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
|
||
if (!shouldRenderPoiMarkers.value) return
|
||
|
||
const expectedView = activeView.value
|
||
const expectedModel = activeModel
|
||
const overviewFloorId = renderPackage.value?.overviewFloorId
|
||
const floor = expectedView === 'overview' && overviewFloorId
|
||
? {
|
||
floorId: overviewFloorId,
|
||
label: '室外',
|
||
order: 0,
|
||
modelUrl: ''
|
||
}
|
||
: 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) {
|
||
if (
|
||
!isCurrentModelLoad(loadToken)
|
||
|| activeView.value !== expectedView
|
||
|| activeModel !== expectedModel
|
||
) return
|
||
|
||
if (
|
||
expectedView === 'overview'
|
||
&& expectedModel
|
||
&& activeRouteCompositeModel !== expectedModel
|
||
&& overviewFloorId === floor.floorId
|
||
) {
|
||
rebuildOverviewMapLabels(expectedModel, entry.rawPois || entry.pois)
|
||
}
|
||
attachPoiMarkerGroup(entry)
|
||
}
|
||
assertCurrentModelLoad(loadToken)
|
||
}
|
||
|
||
const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
|
||
if (!shouldRenderPoiMarkers.value) return
|
||
|
||
const startedAt = getNow()
|
||
const initialFloorId = currentFloor.value
|
||
const finishPerformance = startGuidePerformance('interaction', 'poi-label-first-render', {
|
||
floorId: initialFloorId,
|
||
view: activeView.value
|
||
})
|
||
logThreeMapDiagnostic('poi-background-start', {
|
||
floorId: currentFloor.value,
|
||
view: activeView.value
|
||
})
|
||
|
||
void loadCurrentFloorPoiMarkers(loadToken)
|
||
.then(async () => {
|
||
if (!isCurrentModelLoad(loadToken)) {
|
||
finishPerformance('cancelled', { reason: 'stale-model-load' })
|
||
return
|
||
}
|
||
logThreeMapDiagnostic('poi-background-ready', {
|
||
floorId: currentFloor.value,
|
||
view: activeView.value,
|
||
elapsedMs: Math.round(getNow() - startedAt)
|
||
})
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
await waitForNextVisualFrame()
|
||
if (!isCurrentModelLoad(loadToken)) {
|
||
finishPerformance('cancelled', { reason: 'stale-after-layout' })
|
||
return
|
||
}
|
||
const visibleMarkers = getPoiSprites().filter((sprite) => sprite.visible).length
|
||
const visibleDomLabels = getPoiSprites().filter((sprite) => {
|
||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||
return sprite.visible && labelHandle?.element.style.visibility === 'visible'
|
||
}).length
|
||
finishPerformance('success', {
|
||
floorId: currentFloor.value,
|
||
visibleMarkers,
|
||
visibleDomLabels
|
||
})
|
||
})
|
||
.catch((error) => {
|
||
if (isStaleModelLoadError(error)) {
|
||
finishPerformance('cancelled', { reason: 'stale-model-load' })
|
||
return
|
||
}
|
||
finishPerformance('failure', {
|
||
error: error instanceof Error ? error.name : String(error)
|
||
})
|
||
console.warn('馆内外观点位标记后台加载失败:', error)
|
||
})
|
||
}
|
||
|
||
const loadSameGroundRoutePoiMarkers = (loadToken: number) => {
|
||
if (!shouldRenderPoiMarkers.value || !activeRouteCompositeModel) return
|
||
const l1Floor = floorIndex.value.find((floor) => (
|
||
floor.modelMatchKeys?.some((key) => String(key).trim().toUpperCase() === 'L1')
|
||
))
|
||
if (!l1Floor) return
|
||
|
||
void prepareFloorPOIs(l1Floor, loadToken, activeRouteCompositeModel)
|
||
.then((entry) => {
|
||
if (!entry || !isCurrentModelLoad(loadToken) || activeModel !== activeRouteCompositeModel) return
|
||
attachPoiMarkerGroup(entry)
|
||
refreshPoiVisibilityByDistance()
|
||
})
|
||
.catch((error) => {
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.warn('[ThreeMap] 同地面路线标签加载失败:', error)
|
||
}
|
||
})
|
||
}
|
||
|
||
const loadOverview = async (options: LoadOverviewOptions = {}) => {
|
||
const packageData = renderPackage.value
|
||
if (!packageData || !scene) return
|
||
|
||
const cameraSnapshot = cloneCameraSnapshot(
|
||
options.cameraSnapshot
|
||
|| (liveGlbTopActive.value ? getReferenceOverviewCameraState() : captureCameraSnapshot())
|
||
)
|
||
const previousView = activeView.value
|
||
const previousFloorId = currentFloor.value
|
||
const loadToken = startModelLoad()
|
||
|
||
if (cachedOverviewModel) {
|
||
const targetScene = scene
|
||
if (!targetScene) throw createStaleModelLoadError()
|
||
|
||
clearSceneData()
|
||
activeView.value = 'overview'
|
||
autoSwitchStateMachine.setView('overview')
|
||
syncControlInteractionOptions()
|
||
activeModel = cachedOverviewModel
|
||
activeModel.name = 'GuideOverviewModel'
|
||
activeModel.visible = true
|
||
applyIndoorInitialModelTransform(activeModel)
|
||
applyModelVisibilityForView(activeModel, 'overview')
|
||
targetScene.add(activeModel)
|
||
restoreCameraSnapshot(cameraSnapshot)
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
updateReferenceBuildingAnchors(activeModel)
|
||
rebuildOverviewMapLabels(activeModel)
|
||
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
|
||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||
resetAutoSwitchDistanceTracking()
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
options.onCameraStable?.()
|
||
markFirstModelVisible('overview', {
|
||
source: 'cached-overview',
|
||
modelUrl: cachedSharedModelUrl
|
||
})
|
||
scheduleDefaultFloorPreload()
|
||
return
|
||
}
|
||
|
||
setProgress(18, '正在加载建筑外观模型...')
|
||
|
||
const gltf = await loadModelWithFallback(
|
||
getOverviewModelUrls(packageData),
|
||
'正在加载建筑外观模型',
|
||
loadToken,
|
||
{ modelVersion: packageData.overviewModelVersion }
|
||
)
|
||
assertCurrentModelLoad(loadToken, gltf.scene)
|
||
|
||
const targetScene = scene
|
||
if (!targetScene) {
|
||
disposeObject(gltf.scene)
|
||
throw createStaleModelLoadError()
|
||
}
|
||
|
||
clearSceneData()
|
||
activeView.value = 'overview'
|
||
autoSwitchStateMachine.setView('overview')
|
||
syncControlInteractionOptions()
|
||
activeModel = gltf.scene
|
||
activeModel.name = 'GuideOverviewModel'
|
||
prepareModel(activeModel)
|
||
applyIndoorInitialModelTransform(activeModel)
|
||
applyModelVisibilityForView(activeModel, 'overview')
|
||
cachedOverviewModel = activeModel
|
||
cachedSharedModelUrl = packageData.overviewModelUrl
|
||
targetScene.add(activeModel)
|
||
restoreCameraSnapshot(cameraSnapshot)
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
updateReferenceBuildingAnchors(activeModel)
|
||
rebuildOverviewMapLabels(activeModel)
|
||
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
|
||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||
resetAutoSwitchDistanceTracking()
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
options.onCameraStable?.()
|
||
markFirstModelVisible('overview', {
|
||
source: 'network',
|
||
modelUrl: packageData.overviewModelUrl
|
||
})
|
||
scheduleDefaultFloorPreload()
|
||
}
|
||
|
||
const prepareFloorScene = async (
|
||
floor: FloorIndexItem,
|
||
loadToken: number,
|
||
options: LoadFloorOptions = {}
|
||
): Promise<PreparedFloorScene> => {
|
||
const requestedFloorId = floor.floorId
|
||
if (shouldRenderPoiMarkers.value && !poiDataCache.has(requestedFloorId)) {
|
||
void ensureFloorPoiData(floor).catch((error) => {
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.warn('[ThreeMap] 楼层标签预取失败:', {
|
||
floorId: requestedFloorId,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
})
|
||
}
|
||
let model: THREE.Object3D | null = null
|
||
let ownsModel = true
|
||
let cacheAsSharedModel = false
|
||
let protectedResources: ReusableModelResources | undefined
|
||
let scenePrepareStartedAt = getNow()
|
||
|
||
const cachedPreparedModel = preparedFloorModelCache.get(requestedFloorId)
|
||
if (cachedPreparedModel && cachedPreparedModel.modelUrl !== floor.modelUrl) {
|
||
preparedFloorModelCache.delete(requestedFloorId)
|
||
disposeObject(cachedPreparedModel.model)
|
||
}
|
||
const preparedModel = cachedPreparedModel?.modelUrl === floor.modelUrl
|
||
? cachedPreparedModel
|
||
: null
|
||
|
||
const cachedExactSharedModel = canAttachCachedSharedModel(floor.modelUrl)
|
||
? cachedOverviewModel
|
||
: null
|
||
|
||
if (preparedModel) {
|
||
preparedFloorModelCache.delete(requestedFloorId)
|
||
model = preparedModel.model
|
||
} else 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, modelVersion: floor.modelVersion }
|
||
)
|
||
|
||
try {
|
||
assertCurrentModelLoad(loadToken, gltf.scene)
|
||
} catch (error) {
|
||
disposeObject(gltf.scene)
|
||
throw error
|
||
}
|
||
|
||
scenePrepareStartedAt = getNow()
|
||
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}`)
|
||
}
|
||
|
||
let poiEntry = shouldRenderPoiMarkers.value
|
||
? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floor.floorId)) || null
|
||
: null
|
||
if (!poiEntry && shouldRenderPoiMarkers.value && poiDataCache.has(floor.floorId)) {
|
||
poiEntry = await prepareFloorPOIs(floor, loadToken, model) || null
|
||
}
|
||
|
||
const prepared: PreparedFloorScene = {
|
||
floor,
|
||
model,
|
||
poiEntry: poiEntry || null,
|
||
ownsModel,
|
||
cacheAsSharedModel,
|
||
protectedResources
|
||
}
|
||
assertPreparedFloorScene(prepared, loadToken, requestedFloorId)
|
||
logThreeMapDiagnostic('model-scene-prepare-complete', {
|
||
floorId: requestedFloorId,
|
||
modelUrl: floor.modelUrl,
|
||
source: preparedModel
|
||
? 'prepared-floor-cache'
|
||
: cachedExactSharedModel
|
||
? 'shared-scene'
|
||
: 'model-cache-or-network',
|
||
elapsedMs: Math.round(getNow() - scenePrepareStartedAt)
|
||
})
|
||
|
||
return prepared
|
||
}
|
||
|
||
const commitPreparedFloorScene = (
|
||
prepared: PreparedFloorScene,
|
||
loadToken: number,
|
||
expectedFloorId: string,
|
||
options: LoadFloorOptions = {}
|
||
) => {
|
||
const commitStartedAt = getNow()
|
||
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()
|
||
// Overview labels belong to the exterior scene only. Clear them at the
|
||
// floor commit boundary so an async route-scene update cannot leave them
|
||
// attached while the indoor model is already visible.
|
||
disposeOverviewMapLabels()
|
||
syncControlInteractionOptions()
|
||
currentFloor.value = expectedFloorId
|
||
clearSceneData({ preserveRouteRoaming: options.preserveRouteRoaming })
|
||
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
|
||
const floorBaseline = options.applyFloorBaseline
|
||
? getFloorViewBaseline(expectedFloorId, activeModel, prepared.floor.modelUrl)
|
||
: null
|
||
const cameraSnapshot = floorBaseline?.camera || options.cameraSnapshot || referenceFloorCameraState
|
||
restoreCameraSnapshot(cameraSnapshot)
|
||
if (!referenceBuildingAnchors.length) {
|
||
updateReferenceBuildingAnchors(activeModel)
|
||
}
|
||
recordCameraSnapshotRestoration(cameraSnapshot, `floor:${expectedFloorId}`)
|
||
if (floorBaseline) {
|
||
applyFloorNavigationRange(floorBaseline)
|
||
} else if (controls) {
|
||
ensureFloorAutoExitZoomRange()
|
||
floorNavigationDistance = controls.getDistance()
|
||
autoSwitchStateMachine.setFloorInitialDistance(floorNavigationDistance)
|
||
}
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
options.onCameraStable?.()
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
markFirstModelVisible('floor', {
|
||
floorId: expectedFloorId,
|
||
modelUrl: prepared.floor.modelUrl,
|
||
poiFromCache: Boolean(prepared.poiEntry)
|
||
})
|
||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||
scheduleAdjacentFloorPreload(expectedFloorId)
|
||
logThreeMapDiagnostic('model-commit-complete', {
|
||
floorId: expectedFloorId,
|
||
modelUrl: prepared.floor.modelUrl,
|
||
preserveCameraContinuity: true,
|
||
cameraTransitionMs: 0,
|
||
elapsedMs: Math.round(getNow() - commitStartedAt)
|
||
})
|
||
}
|
||
|
||
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 cameraSnapshot = options.cameraSnapshot
|
||
? cloneCameraSnapshot(options.cameraSnapshot)
|
||
: undefined
|
||
const loadToken = startFloorContextTransaction(requestedFloorId)
|
||
if (options.detachPoiBeforeLoad) {
|
||
detachActivePoiLayer({ preserveRouteRoaming: options.preserveRouteRoaming })
|
||
}
|
||
|
||
try {
|
||
const prepared = await prepareFloorScene(floor, loadToken, options)
|
||
assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
|
||
commitPreparedFloorScene(prepared, loadToken, requestedFloorId, {
|
||
...options,
|
||
...(cameraSnapshot ? { cameraSnapshot } : {})
|
||
})
|
||
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()
|
||
setProgress(18, '正在加载多层展示模型...')
|
||
|
||
const group = new THREE.Group()
|
||
const routeScene = props.showRoute && props.routePreview
|
||
? getNavigationScenePlan(props.routePreview)
|
||
: null
|
||
const routeFloorIds = new Set(routeScene?.floorIds || [])
|
||
const routeFloors = floorIndex.value.filter((floor) => routeFloorIds.has(floor.floorId))
|
||
const useRouteFloorSet = routeScene?.kind === 'multi-floor' && routeFloors.length > 1
|
||
group.name = useRouteFloorSet ? 'GuideRouteMultiFloorModel' : 'GuideMultiFloorModel'
|
||
const orderedFloors = (useRouteFloorSet ? routeFloors : floorIndex.value)
|
||
.slice()
|
||
.sort(compareFloorsTopToBottom)
|
||
const loadedFloors: MultiFloorModelItem[] = []
|
||
const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)
|
||
|
||
if (sharedModelUrl) {
|
||
let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null
|
||
|
||
if (!sourceModel) {
|
||
const sharedModelVersion = orderedFloors.find((floor) => getFloorModelUrls(floor).includes(sharedModelUrl))?.modelVersion
|
||
const gltf = await loadModelWithFallback(
|
||
[sharedModelUrl],
|
||
'正在加载多层共享模型',
|
||
loadToken,
|
||
{ modelVersion: sharedModelVersion }
|
||
)
|
||
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()
|
||
}
|
||
|
||
clearSceneData()
|
||
activeView.value = 'multi'
|
||
autoSwitchStateMachine.setView('multi')
|
||
syncControlInteractionOptions()
|
||
activeModel = group
|
||
targetScene.add(activeModel)
|
||
fitCameraToObject(activeModel, 'oblique', { immediate: liveGlbTopActive.value })
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
resetAutoSwitchDistanceTracking()
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
if (props.showRoute && props.routePreview) {
|
||
focusRouteStartOnScreen()
|
||
}
|
||
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,
|
||
{ modelVersion: floor.modelVersion }
|
||
)
|
||
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()
|
||
}
|
||
|
||
clearSceneData()
|
||
activeView.value = 'multi'
|
||
autoSwitchStateMachine.setView('multi')
|
||
syncControlInteractionOptions()
|
||
activeModel = group
|
||
targetScene.add(activeModel)
|
||
fitCameraToObject(activeModel, 'oblique', { immediate: liveGlbTopActive.value })
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
resetAutoSwitchDistanceTracking()
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
if (props.showRoute && props.routePreview) {
|
||
focusRouteStartOnScreen()
|
||
}
|
||
markFirstModelVisible('multi', {
|
||
source: 'per-floor-models',
|
||
floorCount: orderedFloors.length
|
||
})
|
||
}
|
||
|
||
const createPoiMaterial = (poi: RenderPoi) => {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = 128
|
||
canvas.height = 128
|
||
const context = canvas.getContext('2d')
|
||
|
||
if (context) {
|
||
const color = getPoiColor(poi.primaryCategory)
|
||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||
|
||
// POI 图标保持轻量:白色底盘、分类色描边和短锚点,不再使用大面积实心图钉。
|
||
context.shadowColor = 'rgba(26, 35, 126, 0.16)'
|
||
context.shadowBlur = 8
|
||
context.shadowOffsetY = 3
|
||
context.beginPath()
|
||
context.arc(64, 51, 26, 0, Math.PI * 2)
|
||
context.fillStyle = 'rgba(255, 255, 255, 0.96)'
|
||
context.fill()
|
||
context.shadowColor = 'transparent'
|
||
context.lineWidth = 4
|
||
context.strokeStyle = color
|
||
context.stroke()
|
||
|
||
context.beginPath()
|
||
context.moveTo(64, 77)
|
||
context.lineTo(64, 96)
|
||
context.lineCap = 'round'
|
||
context.lineWidth = 7
|
||
context.strokeStyle = 'rgba(255, 255, 255, 0.96)'
|
||
context.stroke()
|
||
context.beginPath()
|
||
context.moveTo(64, 77)
|
||
context.lineTo(64, 96)
|
||
context.lineWidth = 2.5
|
||
context.strokeStyle = color
|
||
context.stroke()
|
||
|
||
context.beginPath()
|
||
context.arc(64, 99, 4, 0, Math.PI * 2)
|
||
context.fillStyle = 'rgba(255, 255, 255, 0.98)'
|
||
context.fill()
|
||
context.lineWidth = 2
|
||
context.strokeStyle = color
|
||
context.stroke()
|
||
|
||
// 所有普通点位使用同一位置符号;类别由文字标签与详情卡表达,避免“购、餐”等字样堆在模型上。
|
||
context.beginPath()
|
||
context.arc(64, 51, 10, 0, Math.PI * 2)
|
||
context.fillStyle = `${color}1f`
|
||
context.fill()
|
||
context.lineWidth = 2
|
||
context.strokeStyle = color
|
||
context.stroke()
|
||
context.beginPath()
|
||
context.arc(64, 51, 4, 0, Math.PI * 2)
|
||
context.fillStyle = color
|
||
context.fill()
|
||
}
|
||
|
||
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 createPoiDomLabelIcon = (iconKey: PoiIconKey) => {
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||
svg.setAttribute('class', 'three-poi-dom-label__icon')
|
||
svg.setAttribute('viewBox', '0 0 32 32')
|
||
svg.setAttribute('aria-hidden', 'true')
|
||
svg.setAttribute('data-poi-icon', iconKey)
|
||
|
||
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use')
|
||
const iconHref = getPoiIconHref(iconKey)
|
||
use.setAttribute('href', iconHref)
|
||
use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', iconHref)
|
||
svg.appendChild(use)
|
||
return svg
|
||
}
|
||
|
||
const createPoiDomLabel = (
|
||
poi: RenderPoi,
|
||
anchor: THREE.Object3D,
|
||
kind: PoiDomLabelKind
|
||
) => {
|
||
const layer = getPoiDomLabelLayerElement()
|
||
if (!layer) return null
|
||
|
||
const element = document.createElement('div')
|
||
const isLandmark = isIndoorLandmarkPoi(poi)
|
||
element.className = `three-poi-dom-label three-poi-dom-label--${kind} ${isLandmark ? 'three-poi-dom-label--landmark' : 'three-poi-dom-label--service'}`
|
||
element.dataset.poiLabelKind = kind
|
||
element.dataset.poiId = String(poi.id)
|
||
element.dataset.floorId = String(poi.floorId)
|
||
element.setAttribute('aria-hidden', 'true')
|
||
|
||
const iconKey = resolvePoiIconKey({
|
||
primaryCategory: poi.primaryCategory,
|
||
iconType: poi.iconType,
|
||
name: poi.name
|
||
})
|
||
element.dataset.poiIcon = iconKey
|
||
element.appendChild(createPoiDomLabelIcon(iconKey))
|
||
|
||
const title = document.createElement('span')
|
||
title.className = 'three-poi-dom-label__title'
|
||
const isDeviceTerminal = poi.iconType === 'device_terminal'
|
||
const isVisitorService = isServiceFacilityPoi(poi)
|
||
const limit = kind === 'focus'
|
||
? 14
|
||
: (isLandmark || isDeviceTerminal ? 24 : isVisitorService ? 12 : 7)
|
||
const presentation = presentVisitorPoi({
|
||
...poi,
|
||
floorLabel: formatFloorLabel(poi.floorId),
|
||
primaryCategory: { label: poi.primaryCategoryZh }
|
||
})
|
||
const displayName = kind === 'ambient' && (isAmbientFacilityPoi(poi) || isDeviceTerminal)
|
||
? getPoiMapLabelText({ name: poi.name, iconType: poi.iconType })
|
||
: presentation.displayName
|
||
title.textContent = displayName.length > limit
|
||
? `${displayName.slice(0, limit)}…`
|
||
: displayName
|
||
element.appendChild(title)
|
||
|
||
layer.appendChild(element)
|
||
const handle: PoiDomLabelHandle = {
|
||
element,
|
||
anchor,
|
||
poi,
|
||
kind,
|
||
anchorMode: 'bottom',
|
||
floorId: poi.floorId,
|
||
size: { width: 1, height: 1 },
|
||
active: kind === 'focus',
|
||
layoutOffset: { x: 0, y: 0 }
|
||
}
|
||
updatePoiDomLabelSize(handle)
|
||
poiDomLabelHandlesByElement.set(element, handle)
|
||
poiDomLabelResizeObserver?.observe(element)
|
||
setPoiDomLabelVisible(handle, false)
|
||
return handle
|
||
}
|
||
|
||
const showSameGroundRouteOverview = async (asset: GuideModelRouteAsset) => {
|
||
if (!scene || !props.routePreview || !props.showRoute) return
|
||
const signature = `${props.routePreview.id}:${asset.modelUrl}`
|
||
if (activeRouteCompositeModel && activeRouteCompositeUrl === asset.modelUrl && activeModel === activeRouteCompositeModel) {
|
||
activeView.value = 'overview'
|
||
renderRoutePreview()
|
||
return
|
||
}
|
||
if (routeCompositeLoadSignature === signature) return
|
||
|
||
routeCompositeLoadSignature = signature
|
||
const loadToken = startModelLoad()
|
||
try {
|
||
activeView.value = 'overview'
|
||
autoSwitchStateMachine.setView('overview')
|
||
syncControlInteractionOptions()
|
||
clearSceneData()
|
||
setProgress(18, '正在加载路线场景...')
|
||
|
||
const gltf = await loadModelWithFallback(
|
||
[asset.modelUrl],
|
||
'正在加载室外连廊路线场景',
|
||
loadToken,
|
||
{ modelVersion: asset.modelVersion }
|
||
)
|
||
assertCurrentModelLoad(loadToken, gltf.scene)
|
||
if (!scene) {
|
||
disposeObject(gltf.scene)
|
||
throw createStaleModelLoadError()
|
||
}
|
||
|
||
const composite = gltf.scene
|
||
composite.name = 'GuideSameGroundRouteComposite'
|
||
composite.userData.floorId = 'EXTERIOR_L1'
|
||
prepareModel(composite)
|
||
applyIndoorInitialModelTransform(composite)
|
||
applyModelVisibilityForView(composite, 'overview')
|
||
activeModel = composite
|
||
activeRouteCompositeModel = composite
|
||
activeRouteCompositeUrl = asset.modelUrl
|
||
scene.add(composite)
|
||
applyReferenceOverviewCameraState()
|
||
if (controls) {
|
||
const overviewDistance = controls.getDistance()
|
||
controls.minDistance = Math.max(
|
||
SGS_VISUAL_RENDER_CONFIG.controls.minDistance,
|
||
overviewDistance * 0.45
|
||
)
|
||
controls.maxDistance = Math.max(
|
||
overviewDistance * SGS_VISUAL_RENDER_CONFIG.framing.overviewMaxDistanceFactor,
|
||
overviewDistance
|
||
)
|
||
}
|
||
reapplyLiveGlbTopAfterSceneCommit()
|
||
loadSameGroundRoutePoiMarkers(loadToken)
|
||
resetAutoSwitchDistanceTracking()
|
||
renderRoutePreview()
|
||
focusRouteStartOnScreen()
|
||
markFirstModelVisible('overview', {
|
||
source: 'same-ground-route-composite',
|
||
modelUrl: asset.modelUrl
|
||
})
|
||
} finally {
|
||
if (routeCompositeLoadSignature === signature) routeCompositeLoadSignature = ''
|
||
}
|
||
}
|
||
|
||
const syncRouteCompositeView = () => {
|
||
const routeScene = props.showRoute ? getNavigationScenePlan() : null
|
||
const asset = routeScene?.compositeAsset || null
|
||
if (asset) {
|
||
void showSameGroundRouteOverview(asset).catch((error) => {
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.warn('[ThreeMap] 同地面路线场景加载失败,回退为普通路线展示:', error)
|
||
renderRoutePreview()
|
||
}
|
||
})
|
||
return
|
||
}
|
||
|
||
if (routeScene?.kind === 'multi-floor') {
|
||
const requestedRouteFloorId = props.routeNavigationActive
|
||
? resolveFloorIdFromRequest(props.initialFloorId) || currentFloor.value
|
||
: routeScene.floorIds[0]
|
||
const routeFloorId = resolveFloorIdFromRequest(requestedRouteFloorId) || requestedRouteFloorId
|
||
const isRouteFloorReady = Boolean(
|
||
routeFloorId
|
||
&& activeView.value === 'floor'
|
||
&& currentFloor.value === routeFloorId
|
||
&& activeModel?.userData.floorId === routeFloorId
|
||
)
|
||
if (!isRouteFloorReady && routeFloorId) {
|
||
void handleFloorChange(routeFloorId).catch((error) => {
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.warn('[ThreeMap] 跨楼层路线起始楼层加载失败:', error)
|
||
}
|
||
})
|
||
return
|
||
}
|
||
renderRoutePreview()
|
||
return
|
||
}
|
||
|
||
if (activeRouteCompositeModel && activeModel === activeRouteCompositeModel) {
|
||
// Route props and the page's indoorView are committed in separate Vue
|
||
// updates when roaming finishes. Defer one microtask and honor the new
|
||
// floor view if it has arrived; otherwise a stale composite watcher can
|
||
// restore the exterior model over the requested indoor floor.
|
||
const syncSeq = ++routeCompositeExitSyncSeq
|
||
void Promise.resolve().then(() => {
|
||
if (
|
||
syncSeq !== routeCompositeExitSyncSeq
|
||
|| !activeRouteCompositeModel
|
||
|| activeModel !== activeRouteCompositeModel
|
||
) return
|
||
|
||
// The page intentionally keeps initialView='overview' for the home
|
||
// screen. activeFloor is the authoritative post-route target and the
|
||
// page's baseline reset will perform the actual floor commit.
|
||
if (resolveFloorIdFromRequest(props.activeFloor)) {
|
||
return
|
||
}
|
||
|
||
void loadOverview().catch((error) => {
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.warn('[ThreeMap] 路线场景恢复外观模型失败:', error)
|
||
}
|
||
})
|
||
})
|
||
return
|
||
}
|
||
|
||
renderRoutePreview()
|
||
if (!props.routeNavigationActive && props.showRoute && props.routePreview) {
|
||
focusRouteStartOnScreen()
|
||
}
|
||
}
|
||
|
||
const findOverviewLabelPoi = (
|
||
pois: RenderPoi[] | undefined,
|
||
definition: OverviewMapLabelDefinition
|
||
) => {
|
||
if (!pois?.length) return null
|
||
|
||
return pois.find((poi) => (
|
||
poi.positionGltf && getOverviewMapLabelDefinition(poi)?.id === definition.id
|
||
)) || null
|
||
}
|
||
|
||
const findOverviewLabelAnchor = (model: THREE.Object3D, definition: OverviewMapLabelDefinition) => {
|
||
let matched: THREE.Object3D | null = null
|
||
|
||
model.traverse((child) => {
|
||
if (matched || !child.visible) return
|
||
|
||
const names = getModelNodeNames(child)
|
||
if (names.some((name) => isOverviewMapLabelMatch(name, definition))) {
|
||
matched = child
|
||
}
|
||
})
|
||
|
||
if (!matched) return null
|
||
|
||
const box = new THREE.Box3().setFromObject(matched)
|
||
if (box.isEmpty()) return null
|
||
|
||
const center = box.getCenter(new THREE.Vector3())
|
||
center.y = box.max.y + Math.max(box.getSize(new THREE.Vector3()).y * 0.08, 0.8)
|
||
return center
|
||
}
|
||
|
||
const createOverviewLabelPosition = (
|
||
model: THREE.Object3D,
|
||
definition: OverviewMapLabelDefinition,
|
||
pois?: RenderPoi[]
|
||
) => {
|
||
const matchedPoi = findOverviewLabelPoi(pois, definition)
|
||
if (matchedPoi?.positionGltf) {
|
||
const position = new THREE.Vector3(...matchedPoi.positionGltf)
|
||
model.localToWorld(position)
|
||
return {
|
||
position,
|
||
usesPrecomputedPosition: true,
|
||
matchedPoi
|
||
}
|
||
}
|
||
|
||
const position = findOverviewLabelAnchor(model, definition)
|
||
return position
|
||
? {
|
||
position,
|
||
usesPrecomputedPosition: false,
|
||
matchedPoi: null
|
||
}
|
||
: null
|
||
}
|
||
|
||
const disposeOverviewMapLabels = () => {
|
||
while (overviewMapLabelHandles.length) {
|
||
disposePoiDomLabel(overviewMapLabelHandles.pop() || null)
|
||
}
|
||
overviewMapLabelOwner = null
|
||
}
|
||
|
||
const updateOverviewMapLabels = () => {
|
||
if (!overviewMapLabelHandles.length) return
|
||
|
||
if (
|
||
activeView.value !== 'overview'
|
||
|| !activeModel
|
||
|| activeModel !== overviewMapLabelOwner
|
||
|| activeRouteCompositeModel === activeModel
|
||
) {
|
||
disposeOverviewMapLabels()
|
||
return
|
||
}
|
||
|
||
const acceptedBounds: ReturnType<typeof getDomLabelBounds>[] = []
|
||
overviewMapLabelHandles.forEach((handle) => {
|
||
handle.active = true
|
||
const bounds = updatePoiDomLabelPosition(handle)
|
||
if (!bounds) return
|
||
|
||
const overlaps = acceptedBounds.some((acceptedBound) => (
|
||
domLabelBoundsOverlap(bounds, acceptedBound, 8)
|
||
))
|
||
setPoiDomLabelVisible(handle, !overlaps)
|
||
if (!overlaps) acceptedBounds.push(bounds)
|
||
})
|
||
}
|
||
|
||
const rebuildOverviewMapLabels = (model: THREE.Object3D, pois?: RenderPoi[]) => {
|
||
disposeOverviewMapLabels()
|
||
if (
|
||
!scene
|
||
|| activeView.value !== 'overview'
|
||
|| activeModel !== model
|
||
|| activeRouteCompositeModel === model
|
||
) return
|
||
|
||
overviewMapLabelOwner = model
|
||
|
||
OVERVIEW_MAP_LABEL_DEFINITIONS.forEach((definition) => {
|
||
const anchorInfo = createOverviewLabelPosition(model, definition, pois)
|
||
if (!anchorInfo) return
|
||
|
||
const { position, usesPrecomputedPosition } = anchorInfo
|
||
|
||
const anchor = new THREE.Object3D()
|
||
anchor.position.copy(position)
|
||
scene?.add(anchor)
|
||
|
||
const labelPoi: RenderPoi = {
|
||
id: definition.id,
|
||
name: definition.label,
|
||
floorId: 'EXTERIOR',
|
||
primaryCategory: 'overview_label',
|
||
primaryCategoryZh: '外观标签',
|
||
iconType: definition.category,
|
||
kind: 'space',
|
||
positionGltf: [position.x, position.y, position.z]
|
||
}
|
||
|
||
const handle = createPoiDomLabel(labelPoi, anchor, 'ambient')
|
||
if (!handle) return
|
||
|
||
handle.ownsAnchor = true
|
||
handle.element.classList.add(
|
||
'three-poi-dom-label--overview',
|
||
`three-poi-dom-label--overview-${definition.category}`
|
||
)
|
||
handle.active = true
|
||
overviewMapLabelHandles.push(handle)
|
||
|
||
logThreeMapDiagnostic('overview-label-anchor', {
|
||
id: definition.id,
|
||
label: definition.label,
|
||
source: usesPrecomputedPosition ? 'space-label-position' : 'model-node-fallback',
|
||
position: [position.x, position.y, position.z]
|
||
})
|
||
})
|
||
}
|
||
|
||
const disposeFocusLabel = () => {
|
||
disposePoiDomLabel(activeFocusDomLabel)
|
||
activeFocusDomLabel = null
|
||
}
|
||
|
||
const disposeFocusPulse = () => {
|
||
activeFocusPulseSprites.forEach((sprite) => {
|
||
sprite.parent?.remove(sprite)
|
||
disposeObject(sprite)
|
||
})
|
||
activeFocusPulseSprites = []
|
||
}
|
||
|
||
const disposeFocusBase = () => {
|
||
activeFocusBaseSprites.forEach((sprite) => {
|
||
sprite.parent?.remove(sprite)
|
||
disposeObject(sprite)
|
||
})
|
||
activeFocusBaseSprites = []
|
||
}
|
||
|
||
const showFocusPoiLabel = (poi: RenderPoi) => {
|
||
// 选中态沿用环境标签,名称、楼层和操作只在底部详情卡展示。
|
||
void poi
|
||
disposeFocusLabel()
|
||
}
|
||
|
||
const showFocusPoiAffordances = (poi: RenderPoi) => {
|
||
disposeFocusBase()
|
||
disposeFocusPulse()
|
||
showFocusHallHighlight(poi)
|
||
showFocusPoiLabel(poi)
|
||
}
|
||
|
||
const getPoiColor = (category: string) => {
|
||
const colorMap: Record<string, string> = {
|
||
poi: '#1565c0',
|
||
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] || '#1565c0'
|
||
}
|
||
|
||
const getPoiMarkerSize = () => (
|
||
activeModel
|
||
? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
|
||
: 3
|
||
)
|
||
|
||
const updateFocusLabelScale = () => {
|
||
if (!activeFocusDomLabel) return
|
||
const bounds = updatePoiDomLabelPosition(activeFocusDomLabel)
|
||
setPoiDomLabelVisible(activeFocusDomLabel, Boolean(bounds))
|
||
}
|
||
|
||
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 = userData.usesDomLabelIcon
|
||
? 0
|
||
: 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 isCorePoi = getPoiPolicy(poi).allowOverview || 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
|
||
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 }
|
||
}
|
||
|
||
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) : null
|
||
if (!poi || !displayPosition) return null
|
||
|
||
const markerSize = getPoiMarkerSize()
|
||
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
||
sprite.position.copy(displayPosition)
|
||
hitTarget.position.copy(displayPosition)
|
||
poiGroup.add(sprite)
|
||
poiGroup.add(hitTarget)
|
||
|
||
return poi
|
||
}
|
||
|
||
const createPoiMarkerGroup = (
|
||
floorId: string,
|
||
displayMode: PoiDisplayMode,
|
||
pois: RenderPoi[],
|
||
markerSize: number
|
||
) => {
|
||
const group = new THREE.Group()
|
||
const domLabelHandles: PoiDomLabelHandle[] = []
|
||
group.name = `GuideModelPOI_${floorId}_${displayMode}`
|
||
group.userData.floorId = floorId
|
||
group.userData.currentFloor = floorId
|
||
group.userData.displayMode = displayMode
|
||
|
||
pois.forEach((poi) => {
|
||
const usesDomLabelIcon = shouldCreateAmbientPoiLabel(poi)
|
||
getPoiMarkerPositions(poi).forEach((displayPosition, index) => {
|
||
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
||
sprite.position.copy(displayPosition)
|
||
hitTarget.position.copy(displayPosition)
|
||
const labelHandle = index === 0 && usesDomLabelIcon
|
||
? createPoiDomLabel(poi, sprite, 'ambient')
|
||
: null
|
||
sprite.userData.usesDomLabelIcon = usesDomLabelIcon
|
||
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
||
if (labelHandle) {
|
||
sprite.userData.labelHandle = labelHandle
|
||
domLabelHandles.push(labelHandle)
|
||
}
|
||
sprite.visible = isPoiIncludedByVisibleFilter(poi)
|
||
&& (
|
||
visiblePoiIdFilter.value !== null
|
||
|| shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
|
||
)
|
||
hitTarget.visible = sprite.visible
|
||
group.add(sprite)
|
||
group.add(hitTarget)
|
||
})
|
||
})
|
||
|
||
return { group, domLabelHandles }
|
||
}
|
||
|
||
const canAttachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
||
if (!activeModel) return false
|
||
|
||
if (activeView.value === 'floor') {
|
||
return (
|
||
entry.floorId === currentFloor.value
|
||
&& activeModel.userData.floorId === currentFloor.value
|
||
)
|
||
}
|
||
|
||
if (activeView.value === 'overview') {
|
||
if (activeRouteCompositeModel === activeModel) {
|
||
return entry.floorId === currentFloor.value
|
||
}
|
||
return entry.floorId === renderPackage.value?.overviewFloorId
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
||
if (!poiGroup || !canAttachPoiMarkerGroup(entry)) {
|
||
logThreeMapDiagnostic('poi-marker-attach-skipped', {
|
||
entryFloorId: entry.floorId,
|
||
currentFloor: currentFloor.value,
|
||
activeView: activeView.value,
|
||
activeModelFloorId: activeModel?.userData.floorId || '',
|
||
isRouteComposite: activeRouteCompositeModel === activeModel
|
||
})
|
||
return false
|
||
}
|
||
|
||
detachPoiMarkerGroups()
|
||
if (entry.group.parent !== poiGroup) {
|
||
poiGroup.add(entry.group)
|
||
}
|
||
entry.domLabelHandles.forEach((handle) => {
|
||
handle.active = activeView.value === 'floor'
|
||
&& entry.floorId === currentFloor.value
|
||
&& activeModel?.userData.floorId === currentFloor.value
|
||
if (!handle.active) setPoiDomLabelVisible(handle, false)
|
||
})
|
||
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),
|
||
coordinateSpace: 'GLB_METER'
|
||
})
|
||
return true
|
||
}
|
||
|
||
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 createFloorPoiEntry = (
|
||
floor: FloorIndexItem,
|
||
floorPois: RenderPoi[],
|
||
markerModel: THREE.Object3D | null,
|
||
dataTier: 'fast' | 'full'
|
||
) => {
|
||
const displayMode = getPoiDisplayMode()
|
||
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)
|
||
})
|
||
if (markerModel) {
|
||
recordPoiCoordinateDiagnostics(floor.floorId, markerModel, validPois)
|
||
}
|
||
const markerSize = getPoiMarkerSizeForModel(markerModel)
|
||
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
|
||
return {
|
||
floorId: floor.floorId,
|
||
displayMode,
|
||
dataTier,
|
||
pois: validPois,
|
||
rawPois: floorPois,
|
||
group: markerGroup.group,
|
||
domLabelHandles: markerGroup.domLabelHandles,
|
||
markerSize,
|
||
rawPoiCount: floorPois.length,
|
||
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
||
}
|
||
}
|
||
|
||
const disposePoiMarkerEntry = (entry: PoiMarkerCacheEntry) => {
|
||
entry.group.parent?.remove(entry.group)
|
||
entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
|
||
disposeObject(entry.group)
|
||
}
|
||
|
||
const runFloorPoiEnrichment = (
|
||
floor: FloorIndexItem
|
||
) => {
|
||
if (poiEnrichmentInFlight.has(floor.floorId)) return
|
||
|
||
const enrichment = props.modelSource.loadFloorPois(floor.floorId)
|
||
.then((fullPois) => {
|
||
if (isDisposed) return
|
||
|
||
poiDataCache.set(floor.floorId, fullPois)
|
||
poiDataTierCache.set(floor.floorId, 'full')
|
||
|
||
const overviewFloorId = renderPackage.value?.overviewFloorId
|
||
const isPlainOverviewScene = (
|
||
activeView.value === 'overview'
|
||
&& activeModel
|
||
&& activeRouteCompositeModel !== activeModel
|
||
&& overviewFloorId === floor.floorId
|
||
)
|
||
const isCurrentFloorScene = (
|
||
activeView.value === 'floor'
|
||
&& currentFloor.value === floor.floorId
|
||
&& activeModel?.userData.floorId === floor.floorId
|
||
)
|
||
if (!isPlainOverviewScene && !isCurrentFloorScene) return
|
||
|
||
const markerCacheKey = getPoiMarkerCacheKey(floor.floorId)
|
||
const previousEntry = poiMarkerGroupCache.get(markerCacheKey)
|
||
const fullEntry = createFloorPoiEntry(floor, fullPois, activeModel, 'full')
|
||
poiMarkerGroupCache.set(markerCacheKey, fullEntry)
|
||
if (previousEntry && previousEntry !== fullEntry) {
|
||
disposePoiMarkerEntry(previousEntry)
|
||
}
|
||
|
||
if (isPlainOverviewScene && activeModel) {
|
||
rebuildOverviewMapLabels(activeModel, fullPois)
|
||
}
|
||
if (attachPoiMarkerGroup(fullEntry)) {
|
||
const enrichedFocusPoi = activeFocusPoiId.value
|
||
? fullEntry.pois.find((poi) => poi.id === activeFocusPoiId.value)
|
||
: undefined
|
||
if (enrichedFocusPoi) {
|
||
selectedPOI.value = enrichedFocusPoi
|
||
showFocusPoiAffordances(enrichedFocusPoi)
|
||
}
|
||
refreshPoiVisibilityByDistance()
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.warn('[ThreeMap] 楼层标签补充数据加载失败:', {
|
||
floorId: floor.floorId,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
})
|
||
.finally(() => {
|
||
poiEnrichmentInFlight.delete(floor.floorId)
|
||
})
|
||
|
||
poiEnrichmentInFlight.set(floor.floorId, enrichment)
|
||
}
|
||
|
||
const enrichFloorPoiMarkersInBackground = (floor: FloorIndexItem) => {
|
||
if (poiEnrichmentInFlight.has(floor.floorId)) return
|
||
|
||
poiEnrichmentScheduler.schedule(() => {
|
||
if (isDisposed) return
|
||
runFloorPoiEnrichment(floor)
|
||
}, { delayMs: 1200 })
|
||
}
|
||
|
||
const ensureFloorPoiData = async (floor: FloorIndexItem) => {
|
||
const cachedPois = poiDataCache.get(floor.floorId)
|
||
const cachedDataTier = poiDataTierCache.get(floor.floorId)
|
||
if (cachedPois && cachedDataTier) {
|
||
return { floorPois: cachedPois, dataTier: cachedDataTier }
|
||
}
|
||
|
||
const pending = poiDataLoadInFlight.get(floor.floorId)
|
||
if (pending) return pending
|
||
|
||
const request = loadFloorPoisForInitialRender(props.modelSource, floor.floorId)
|
||
.then((loadResult) => {
|
||
if (loadResult.fastLoadError) {
|
||
console.warn('[ThreeMap] 楼层标签快速数据加载失败,已回退完整数据:', {
|
||
floorId: floor.floorId,
|
||
error: loadResult.fastLoadError instanceof Error
|
||
? loadResult.fastLoadError.message
|
||
: String(loadResult.fastLoadError)
|
||
})
|
||
}
|
||
poiDataCache.set(floor.floorId, loadResult.floorPois)
|
||
poiDataTierCache.set(floor.floorId, loadResult.dataTier)
|
||
return {
|
||
floorPois: loadResult.floorPois,
|
||
dataTier: loadResult.dataTier
|
||
}
|
||
})
|
||
.finally(() => {
|
||
poiDataLoadInFlight.delete(floor.floorId)
|
||
})
|
||
|
||
poiDataLoadInFlight.set(floor.floorId, request)
|
||
return request
|
||
}
|
||
|
||
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)
|
||
const cachedPois = poiDataCache.get(floor.floorId)
|
||
const cachedDataTier = poiDataTierCache.get(floor.floorId)
|
||
if (cachedEntry && cachedPois && cachedEntry.dataTier === cachedDataTier) return cachedEntry
|
||
|
||
const loadedData = cachedPois && cachedDataTier
|
||
? { floorPois: cachedPois, dataTier: cachedDataTier }
|
||
: await ensureFloorPoiData(floor)
|
||
const { floorPois, dataTier } = loadedData
|
||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
|
||
|
||
const entry = createFloorPoiEntry(floor, floorPois, markerModel, dataTier)
|
||
|
||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||
entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
|
||
disposeObject(entry.group)
|
||
return
|
||
}
|
||
|
||
poiMarkerGroupCache.set(markerCacheKey, entry)
|
||
if (cachedEntry && cachedEntry !== entry) {
|
||
disposePoiMarkerEntry(cachedEntry)
|
||
}
|
||
if (entry.dataTier === 'fast') {
|
||
enrichFloorPoiMarkersInBackground(floor)
|
||
}
|
||
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 triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
|
||
getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
|
||
}
|
||
|
||
// 空白区域只做近距离容错吸附;业务对象优先命中自身接入点,缺少显式
|
||
// 接入点时保留对象坐标,由路线服务完成正式路网吸附。
|
||
const routeStartSnapMaxDistanceMeters = 8
|
||
|
||
const getRouteSourcePoiIds = (poi: RenderPoi) => [
|
||
poi.id,
|
||
poi.spaceId,
|
||
poi.sourceSpaceId,
|
||
poi.sourcePlaceId
|
||
].filter((value): value is string => Boolean(value))
|
||
|
||
const findPoiForModelHit = (object: THREE.Object3D) => {
|
||
const seen = new Set<string>()
|
||
return getPoiSprites()
|
||
.map((sprite) => sprite.userData.poi as RenderPoi | undefined)
|
||
.filter((poi): poi is RenderPoi => {
|
||
if (!poi || seen.has(poi.id)) return false
|
||
seen.add(poi.id)
|
||
return true
|
||
})
|
||
.find((poi) => isPoiModelNodeMatch(object, poi)) || null
|
||
}
|
||
|
||
const findPoiForModelHits = (hits: THREE.Intersection[]) => {
|
||
for (const hit of hits) {
|
||
const poi = findPoiForModelHit(hit.object)
|
||
if (poi) return { hit, poi }
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const selectRouteStartCandidateNear = (
|
||
floorId: string,
|
||
position: [number, number, number],
|
||
sourceName?: string,
|
||
sourcePoiIds: Array<string | number | null | undefined> = [],
|
||
requireObjectMatch = false
|
||
) => {
|
||
const resolved = resolveRouteStartCandidate({
|
||
floorId,
|
||
position,
|
||
sourceName,
|
||
sourcePoiIds,
|
||
points: props.routeSelectablePoints,
|
||
maxDistanceMeters: routeStartSnapMaxDistanceMeters,
|
||
requireObjectMatch
|
||
})
|
||
|
||
if (!resolved) {
|
||
emit('routeStartCandidateRejected')
|
||
return false
|
||
}
|
||
|
||
emit('routeStartCandidate', toRouteStartCandidatePayload(resolved, sourceName))
|
||
return true
|
||
}
|
||
|
||
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 poiSprites = getPoiSprites()
|
||
const labelMarker = findPoiMarkerByDomLabel(event)
|
||
// The old transparent hit-target sprites are deliberately excluded from
|
||
// authoritative selection. Their 3D depth is unrelated to the label the
|
||
// user touched and can select a POI behind an adjacent label. Use the
|
||
// projected screen position instead, which is deterministic for touch and
|
||
// mouse input alike.
|
||
const fallbackMarker = labelMarker ? null : findNearestPoiMarkerByScreenPoint(
|
||
event,
|
||
rect,
|
||
props.routeStartSelectionActive ? 42 : 0
|
||
)
|
||
const hitMarker = labelMarker
|
||
|| 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: getPoiHitTargets().length,
|
||
rayHitCount: 0,
|
||
labelSelectedPoiId: (labelMarker?.userData.poi as RenderPoi | undefined)?.id || null,
|
||
raySelectedPoiId: 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' && activeRouteCompositeModel !== activeModel) {
|
||
disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
|
||
try {
|
||
await loadFloor(selectedPoi.floorId, {
|
||
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
|
||
suppressProgress: true,
|
||
detachPoiBeforeLoad: true
|
||
})
|
||
emitFloorChange(selectedPoi.floorId)
|
||
} catch (error) {
|
||
if (isStaleModelLoadError(error)) return
|
||
console.error('展厅点位楼层切换失败:', error)
|
||
}
|
||
}
|
||
|
||
const poi = findLoadedPoi(selectedPoi.id) || selectedPoi
|
||
if (props.routeStartSelectionActive) {
|
||
if (poi.positionGltf) {
|
||
selectRouteStartCandidateNear(
|
||
poi.floorId,
|
||
poi.positionGltf,
|
||
poi.name,
|
||
getRouteSourcePoiIds(poi),
|
||
true
|
||
)
|
||
} else {
|
||
emit('routeStartCandidateRejected')
|
||
}
|
||
return
|
||
}
|
||
const focusMarker = findPoiSprite(poi.id) || hitMarker
|
||
selectedPOI.value = poi
|
||
activeFocusPoiId.value = poi.id
|
||
activeFocusStartedDataTier = poiDataTierCache.get(poi.floorId) || null
|
||
triggerPoiTapFeedback(focusMarker)
|
||
updatePoiMarkerFocus()
|
||
showFocusPoiAffordances(poi)
|
||
focusCameraOnPoi(poi)
|
||
refreshPoiVisibilityByDistance()
|
||
emit('poiClick', poi)
|
||
return
|
||
}
|
||
}
|
||
|
||
const modelHits = activeModel
|
||
? raycaster.intersectObject(activeModel, true)
|
||
: []
|
||
const matchedModelPoi = findPoiForModelHits(modelHits)
|
||
|
||
if (props.routeStartSelectionActive) {
|
||
if (matchedModelPoi?.poi.positionGltf) {
|
||
selectRouteStartCandidateNear(
|
||
matchedModelPoi.poi.floorId,
|
||
matchedModelPoi.poi.positionGltf,
|
||
matchedModelPoi.poi.name,
|
||
getRouteSourcePoiIds(matchedModelPoi.poi),
|
||
true
|
||
)
|
||
} else if (modelHits[0]) {
|
||
selectRouteStartCandidateNear(currentFloor.value, [
|
||
modelHits[0].point.x,
|
||
modelHits[0].point.y,
|
||
modelHits[0].point.z
|
||
])
|
||
} else {
|
||
emit('routeStartCandidateRejected')
|
||
}
|
||
return
|
||
}
|
||
|
||
// A direct tap on a POI mesh is a second, exact selection path. It is
|
||
// intentionally evaluated only after marker/label hit testing so a model
|
||
// surface cannot override an explicitly touched label.
|
||
if (matchedModelPoi?.poi) {
|
||
const poi = matchedModelPoi.poi
|
||
const focusMarker = findPoiSprite(poi.id)
|
||
selectedPOI.value = poi
|
||
activeFocusPoiId.value = poi.id
|
||
activeFocusStartedDataTier = poiDataTierCache.get(poi.floorId) || null
|
||
if (focusMarker) 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()
|
||
stopRenderLoop()
|
||
return
|
||
}
|
||
|
||
startRenderLoop()
|
||
}
|
||
|
||
const emitTargetFocus = (
|
||
request: TargetPoiFocusRequest,
|
||
status: TargetPoiFocusResult['status'],
|
||
message?: string
|
||
) => {
|
||
emit('targetFocus', {
|
||
requestId: request.requestId,
|
||
poiId: request.poiId,
|
||
floorId: request.floorId,
|
||
status,
|
||
message
|
||
})
|
||
}
|
||
|
||
const clearTargetFocus = () => {
|
||
targetFocusGeneration += 1
|
||
pendingTargetFocus = null
|
||
clearMapSelection(false)
|
||
}
|
||
|
||
const isSceneReadyForTargetFocus = () => (
|
||
weakNetworkFallbackActive.value
|
||
|| (
|
||
Boolean(scene && camera && controls && loader && floorIndex.value.length)
|
||
&& !isLoading.value
|
||
&& !loadError.value
|
||
)
|
||
)
|
||
|
||
const focusCameraOnPoi = (poi: RenderPoi) => {
|
||
const target = getPoiDisplayPosition(poi)
|
||
if (!camera || !controls || !target) return
|
||
|
||
const currentOffset = camera.position.clone().sub(controls.target)
|
||
const currentDistance = currentOffset.length()
|
||
const direction = currentDistance > 1e-6
|
||
? currentOffset.multiplyScalar(1 / currentDistance)
|
||
: new THREE.Vector3(0.72, 0.58, 1).normalize()
|
||
const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
|
||
const minimumDistance = Math.max(controls.minDistance, 1)
|
||
const maximumDistance = Math.max(minimumDistance, controls.maxDistance)
|
||
const distance = THREE.MathUtils.clamp(
|
||
currentDistance * focusDistanceFactor,
|
||
minimumDistance,
|
||
maximumDistance
|
||
)
|
||
const nextPosition = target.clone().add(direction.multiplyScalar(distance))
|
||
|
||
moveCameraTo(nextPosition, target, { reason: 'poi-focus' })
|
||
}
|
||
|
||
const focusRouteStartOnScreen = () => {
|
||
const start = props.routePreview?.start.position
|
||
if (!start || !camera || !controls) return
|
||
|
||
const routePoints = props.routePreview ? buildRouteRoamingPoints(props.routePreview) : []
|
||
const routeDirection = getRouteRoamingDirection(routePoints, 0)
|
||
const referenceDirection = liveGlbTopActive.value
|
||
? camera.position.clone().sub(controls.target).normalize()
|
||
: getCameraCalibrationDirection(
|
||
GUIDE_CAMERA_YAW_DEGREES,
|
||
GUIDE_CAMERA_ELEVATION_DEGREES
|
||
)
|
||
controls.minDistance = Math.min(controls.minDistance, ROUTE_NAVIGATION_CAMERA_DISTANCE * 0.6)
|
||
controls.maxDistance = Math.max(controls.maxDistance, ROUTE_PREVIEW_CAMERA_DISTANCE)
|
||
const target = routePointToVector(start, props.routePreview?.start.floorId)
|
||
.addScaledVector(routeDirection, ROUTE_PREVIEW_LOOK_AHEAD)
|
||
const position = target.clone().addScaledVector(referenceDirection, ROUTE_PREVIEW_CAMERA_DISTANCE)
|
||
|
||
moveCameraTo(position, target, { durationMs: 460 })
|
||
}
|
||
|
||
const isCurrentTargetFocus = (generation: number) => generation === targetFocusGeneration
|
||
|
||
const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number) => {
|
||
if (!isCurrentTargetFocus(generation)) return false
|
||
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
|
||
})
|
||
if (!isCurrentTargetFocus(generation)) return false
|
||
isLoading.value = false
|
||
emitFloorChange(request.floorId)
|
||
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
|
||
await loadFloorPOIs(floor, modelLoadVersion)
|
||
if (!isCurrentTargetFocus(generation)) return false
|
||
}
|
||
|
||
if (!isCurrentTargetFocus(generation)) return false
|
||
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
|
||
activeFocusStartedDataTier = poiDataTierCache.get(request.floorId) || null
|
||
updatePoiMarkerFocus()
|
||
showFocusPoiAffordances(poi)
|
||
focusCameraOnPoi(poi)
|
||
refreshPoiVisibilityByDistance()
|
||
emitTargetFocus(request, 'focused')
|
||
return true
|
||
} catch (error) {
|
||
if (isStaleModelLoadError(error)) return false
|
||
|
||
console.error('目标 POI 聚焦失败:', error)
|
||
invalidateModelLoads()
|
||
if (await activateWeakNetworkFallback(
|
||
request.floorId,
|
||
'weak-network',
|
||
'floor',
|
||
isModelLoadTimeoutError(error) ? 'model-load-timeout' : 'model-load-failed'
|
||
)) {
|
||
return focusWeakNetworkTarget(request, generation)
|
||
}
|
||
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
|
||
const requestGeneration = targetFocusGeneration
|
||
if (!nextRequest) return
|
||
|
||
pendingTargetFocus = null
|
||
targetFocusQueue = targetFocusQueue
|
||
.then(() => (
|
||
weakNetworkFallbackActive.value
|
||
? focusWeakNetworkTarget(nextRequest, requestGeneration)
|
||
: focusTargetPoi(nextRequest, requestGeneration)
|
||
))
|
||
.finally(() => {
|
||
if (pendingTargetFocus) {
|
||
queueTargetFocus(pendingTargetFocus)
|
||
}
|
||
})
|
||
}
|
||
|
||
const loadModelPackage = async (initializationVersion = sceneInitializationVersion) => {
|
||
setProgress(8, '正在读取馆内导览资源...')
|
||
const packageData = await props.modelSource.loadPackage()
|
||
if (!isCurrentSceneInitialization(initializationVersion)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
modelPackageEpoch += 1
|
||
clearFloorViewBaselines()
|
||
renderPackage.value = packageData
|
||
|
||
setProgress(14, '正在读取楼层索引...')
|
||
floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom)
|
||
logThreeMapDiagnostic('package-ready', {
|
||
initialView: requestedSceneView.value,
|
||
requestedInitialFloorId: props.initialFloorId,
|
||
overviewModelUrl: packageData.overviewModelUrl,
|
||
overviewFloorId: packageData.overviewFloorId,
|
||
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 (!isTwoDimensionalMode.value && hasExceededInitialForegroundBudget()) {
|
||
const didActivate = await activateWeakNetworkFallback(
|
||
currentFloor.value,
|
||
'weak-network',
|
||
requestedSceneView.value === 'overview' ? 'overview' : 'floor',
|
||
'initial-interactive-budget',
|
||
initializationVersion
|
||
)
|
||
if (didActivate) return
|
||
}
|
||
|
||
if (isTwoDimensionalMode.value && !scene) {
|
||
const didActivate = await activateWeakNetworkFallback(
|
||
currentFloor.value,
|
||
'two-dimensional',
|
||
requestedSceneView.value === 'overview' ? 'overview' : 'floor',
|
||
'model-load-failed',
|
||
initializationVersion
|
||
)
|
||
if (!didActivate) throw new Error('二维底图预览初始化失败')
|
||
return
|
||
}
|
||
|
||
if (requestedSceneView.value === 'floor') {
|
||
const didCommit = await loadFloor(currentFloor.value)
|
||
if (!isCurrentSceneInitialization(initializationVersion)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
if (didCommit) {
|
||
emitFloorChange(currentFloor.value)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (requestedSceneView.value === 'multi') {
|
||
await loadMultiFloor()
|
||
if (!isCurrentSceneInitialization(initializationVersion)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
return
|
||
}
|
||
|
||
await loadOverview()
|
||
if (!isCurrentSceneInitialization(initializationVersion)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
}
|
||
|
||
const prepareWeakNetworkFallbackFloor = async (
|
||
requestedFloorId?: string,
|
||
initializationVersion = sceneInitializationVersion,
|
||
transitionRevision?: number
|
||
) => {
|
||
const isRequestCurrent = () => (
|
||
isCurrentSceneInitialization(initializationVersion)
|
||
&& (
|
||
transitionRevision === undefined
|
||
|| transitionRevision === renderModeTransitionRevision
|
||
)
|
||
)
|
||
if (!isRequestCurrent()) return false
|
||
const floorId = resolveFloorIdFromRequest(requestedFloorId)
|
||
|| floorIndex.value.find((item) => item.floorId === requestedFloorId)?.floorId
|
||
|| currentFloor.value
|
||
|| getDefaultFloorId()
|
||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||
if (!floor) return false
|
||
|
||
const requestRevision = ++weakNetworkFallbackPoiRequestRevision
|
||
const cacheKey = `floor:${floor.floorId}`
|
||
currentFloor.value = floor.floorId
|
||
activeView.value = 'floor'
|
||
autoSwitchStateMachine.setView('floor')
|
||
weakNetworkFallbackPois.value = []
|
||
const cached = weakNetworkFallbackPoiCache.get(cacheKey)
|
||
if (cached) {
|
||
weakNetworkFallbackPois.value = cached
|
||
return true
|
||
}
|
||
try {
|
||
const result = await ensureFloorPoiData(floor)
|
||
if (
|
||
requestRevision !== weakNetworkFallbackPoiRequestRevision
|
||
|| !isRequestCurrent()
|
||
) return false
|
||
const floorKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
|
||
const floorPois = result.floorPois.filter((poi) => (
|
||
floorKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
|
||
))
|
||
weakNetworkFallbackPoiCache.set(cacheKey, floorPois)
|
||
weakNetworkFallbackPois.value = floorPois
|
||
} catch (error) {
|
||
if (
|
||
requestRevision !== weakNetworkFallbackPoiRequestRevision
|
||
|| !isRequestCurrent()
|
||
) return false
|
||
const floorKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
|
||
const floorPois = (poiDataCache.get(floor.floorId) || []).filter((poi) => (
|
||
floorKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
|
||
))
|
||
weakNetworkFallbackPoiCache.set(cacheKey, floorPois)
|
||
weakNetworkFallbackPois.value = floorPois
|
||
console.warn('[ThreeMap] 弱网简图 POI 数据加载失败:', {
|
||
floorId: floor.floorId,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
|
||
const prepareWeakNetworkFallbackOverview = async (
|
||
initializationVersion = sceneInitializationVersion,
|
||
transitionRevision?: number
|
||
) => {
|
||
const isRequestCurrent = () => (
|
||
isCurrentSceneInitialization(initializationVersion)
|
||
&& (
|
||
transitionRevision === undefined
|
||
|| transitionRevision === renderModeTransitionRevision
|
||
)
|
||
)
|
||
if (!isRequestCurrent()) return false
|
||
const floor = getWeakNetworkOverviewFloor()
|
||
if (!floor) return false
|
||
|
||
const requestRevision = ++weakNetworkFallbackPoiRequestRevision
|
||
const cacheKey = `overview:${floor.floorId}`
|
||
activeView.value = 'overview'
|
||
autoSwitchStateMachine.setView('overview')
|
||
weakNetworkFallbackPois.value = []
|
||
const cached = weakNetworkFallbackPoiCache.get(cacheKey)
|
||
if (cached) {
|
||
weakNetworkFallbackPois.value = cached
|
||
return true
|
||
}
|
||
try {
|
||
const result = await ensureFloorPoiData(floor)
|
||
if (
|
||
requestRevision !== weakNetworkFallbackPoiRequestRevision
|
||
|| !isRequestCurrent()
|
||
) return false
|
||
const overviewKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
|
||
const overviewPois = result.floorPois.filter((poi) => (
|
||
overviewKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
|
||
))
|
||
weakNetworkFallbackPoiCache.set(cacheKey, overviewPois)
|
||
weakNetworkFallbackPois.value = overviewPois
|
||
} catch (error) {
|
||
if (
|
||
requestRevision !== weakNetworkFallbackPoiRequestRevision
|
||
|| !isRequestCurrent()
|
||
) return false
|
||
const overviewKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
|
||
const overviewPois = (poiDataCache.get(floor.floorId) || []).filter((poi) => (
|
||
overviewKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
|
||
))
|
||
weakNetworkFallbackPoiCache.set(cacheKey, overviewPois)
|
||
weakNetworkFallbackPois.value = overviewPois
|
||
console.warn('[ThreeMap] 弱网室外底图 POI 数据加载失败:', {
|
||
floorId: floor.floorId,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
|
||
const activateWeakNetworkFallback = async (
|
||
requestedFloorId?: string,
|
||
presentation: FallbackPresentation = 'weak-network',
|
||
view: 'overview' | 'floor' = 'floor',
|
||
reason = 'model-load-failed',
|
||
initializationVersion = sceneInitializationVersion,
|
||
transitionRevision?: number
|
||
) => {
|
||
if (!floorIndex.value.length) return false
|
||
const isRequestCurrent = () => (
|
||
isCurrentSceneInitialization(initializationVersion)
|
||
&& (
|
||
transitionRevision === undefined
|
||
|| transitionRevision === renderModeTransitionRevision
|
||
)
|
||
)
|
||
if (!isRequestCurrent()) return false
|
||
const requestSceneRevision = props.sceneRevision
|
||
|
||
const isReady = view === 'overview'
|
||
? await prepareWeakNetworkFallbackOverview(initializationVersion, transitionRevision)
|
||
: await prepareWeakNetworkFallbackFloor(requestedFloorId, initializationVersion, transitionRevision)
|
||
if (!isReady || !isRequestCurrent()) return false
|
||
|
||
liveGlbTopActive.value = false
|
||
liveGlbThreeDCameraSnapshot = null
|
||
liveGlbThreeDControlsSnapshot = null
|
||
threeRendererRetainedForTwoD.value = Boolean(scene && renderer && activeModel)
|
||
if (threeRendererRetainedForTwoD.value) stopRenderLoop()
|
||
weakNetworkFallbackActive.value = true
|
||
fallbackPresentation.value = presentation
|
||
loadError.value = false
|
||
isLoading.value = false
|
||
setProgress(100, presentation === 'two-dimensional' ? '二维导览已就绪' : '网络较弱,已切换为简图导览')
|
||
if (presentation === 'weak-network') {
|
||
emit('renderModeFallback', {
|
||
mode: 'two-d',
|
||
view: activeView.value === 'overview' ? 'overview' : 'floor',
|
||
floorId: currentFloor.value,
|
||
reason,
|
||
sceneRevision: requestSceneRevision
|
||
})
|
||
preloadWeakNetworkFallbackModelInBackground(
|
||
activeView.value === 'overview' ? 'overview' : 'floor',
|
||
currentFloor.value,
|
||
reason
|
||
)
|
||
}
|
||
return true
|
||
}
|
||
|
||
const handleWeakNetworkPoiClick = (poi: GuideRenderPoi) => {
|
||
selectedPOI.value = poi
|
||
activeFocusPoiId.value = poi.id
|
||
emit('poiClick', poi)
|
||
}
|
||
|
||
const focusWeakNetworkTarget = async (request: TargetPoiFocusRequest, generation: number) => {
|
||
if (!isCurrentTargetFocus(generation)) return false
|
||
if (!request.poiId || !request.floorId) {
|
||
emitTargetFocus(request, 'missing', '目标位置数据不完整')
|
||
return false
|
||
}
|
||
|
||
const didPrepare = await prepareWeakNetworkFallbackFloor(request.floorId)
|
||
if (!didPrepare || !isCurrentTargetFocus(generation)) {
|
||
emitTargetFocus(request, 'missing', '目标楼层暂无简图数据')
|
||
return false
|
||
}
|
||
|
||
const poi = weakNetworkFallbackPois.value.find((item) => item.id === request.poiId)
|
||
|| poiDataCache.get(request.floorId)?.find((item) => item.id === request.poiId)
|
||
|| createPoiFromFocusRequest(request)
|
||
if (!poi?.positionGltf) {
|
||
emitTargetFocus(request, 'missing', '目标暂无可用坐标')
|
||
return false
|
||
}
|
||
|
||
selectedPOI.value = poi
|
||
activeFocusPoiId.value = poi.id
|
||
emitFloorChange(request.floorId)
|
||
emitTargetFocus(request, 'focused')
|
||
return true
|
||
}
|
||
|
||
const init3DScene = async (options: {
|
||
preserveSelectedPoi?: RenderPoi | null
|
||
restoreSceneViewport?: boolean
|
||
forceThreeRenderer?: boolean
|
||
} = {}) => {
|
||
const initializationVersion = ++sceneInitializationVersion
|
||
const preservedSelectedPoi = options.preserveSelectedPoi || null
|
||
pendingWeakNetworkZoom = null
|
||
try {
|
||
disposeScene()
|
||
clearFloorViewBaselines()
|
||
isDisposed = false
|
||
firstModelLoadStartedAt = getNow()
|
||
firstModelVisibleReported = false
|
||
initialModelSettled = false
|
||
weakNetworkFallbackActive.value = false
|
||
weakNetworkFallbackPois.value = []
|
||
fallbackPresentation.value = null
|
||
poiFocusCameraAnimationCount = 0
|
||
cameraSnapshotRestoreCount = 0
|
||
isLoading.value = true
|
||
loadError.value = false
|
||
selectedPOI.value = null
|
||
hasLoadedFloorViewOnce = false
|
||
setProgress(0, '正在初始化三维场景...')
|
||
logThreeMapDiagnostic('init-start', {
|
||
initialView: requestedSceneView.value,
|
||
initialFloorId: props.initialFloorId
|
||
})
|
||
|
||
if (!isTwoDimensionalMode.value || options.forceThreeRenderer) {
|
||
await initThree()
|
||
installVisualStabilityDiagnostics()
|
||
}
|
||
await loadModelPackage(initializationVersion)
|
||
if (!isCurrentSceneInitialization(initializationVersion)) {
|
||
throw createStaleModelLoadError()
|
||
}
|
||
|
||
if (options.restoreSceneViewport && !weakNetworkFallbackActive.value) {
|
||
applySharedGuideViewport(props.sceneViewport)
|
||
}
|
||
|
||
if (preservedSelectedPoi) {
|
||
const restoredPoi = findLoadedPoi(preservedSelectedPoi.id) || preservedSelectedPoi
|
||
selectedPOI.value = restoredPoi
|
||
activeFocusPoiId.value = restoredPoi.id
|
||
if (!weakNetworkFallbackActive.value) {
|
||
updatePoiMarkerFocus()
|
||
showFocusPoiAffordances(restoredPoi)
|
||
}
|
||
}
|
||
|
||
// The package, not a hard-coded floor, determines the stable first-ready baseline.
|
||
initialGuideState = {
|
||
floorId: currentFloor.value,
|
||
camera: captureCameraSnapshot()
|
||
}
|
||
if (isTwoDimensionalMode.value && !weakNetworkFallbackActive.value) {
|
||
applyLiveGlbTopView({ captureRestoreState: true })
|
||
}
|
||
if (isCameraCalibrationMode.value) {
|
||
syncCameraCalibrationFromCamera()
|
||
}
|
||
|
||
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)
|
||
const message = error instanceof Error ? error.message : '请检查模型资源或网络状态后重试'
|
||
invalidateModelLoads()
|
||
const didFallback = await activateWeakNetworkFallback(
|
||
currentFloor.value,
|
||
'weak-network',
|
||
activeView.value === 'overview' ? 'overview' : 'floor',
|
||
isModelLoadTimeoutError(error) ? 'model-load-timeout' : 'model-load-failed',
|
||
initializationVersion
|
||
)
|
||
if (!didFallback) {
|
||
loadError.value = true
|
||
isLoading.value = false
|
||
setFriendlyModelLoadError()
|
||
} else {
|
||
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
|
||
}
|
||
initialModelSettled = true
|
||
emit('initialModelFailed', {
|
||
view: activeView.value,
|
||
floorId: currentFloor.value,
|
||
message,
|
||
elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined,
|
||
fallbackAvailable: didFallback,
|
||
actualRenderMode: didFallback ? 'two-d' : undefined
|
||
})
|
||
}
|
||
}
|
||
|
||
const handleFloorChange = async (floorId: string) => {
|
||
const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId
|
||
const requestSceneRevision = props.sceneRevision
|
||
if (weakNetworkFallbackActive.value) {
|
||
const didPrepare = await prepareWeakNetworkFallbackFloor(requestedFloorId)
|
||
if (didPrepare) {
|
||
selectedPOI.value = null
|
||
activeFocusPoiId.value = ''
|
||
emitFloorChange(requestedFloorId, requestSceneRevision)
|
||
}
|
||
return
|
||
}
|
||
const isCommittedSameFloor = (
|
||
activeView.value === 'floor'
|
||
&& currentFloor.value === requestedFloorId
|
||
&& activeModel?.userData.floorId === requestedFloorId
|
||
&& !isFloorSwitching
|
||
)
|
||
if (isCommittedSameFloor) {
|
||
emitFloorChange(requestedFloorId, requestSceneRevision)
|
||
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,
|
||
applyFloorBaseline: true
|
||
})
|
||
updatePoiVisibilityByDistance()
|
||
if (didCommit) {
|
||
emitFloorChange(requestedFloorId, requestSceneRevision)
|
||
}
|
||
} catch (error) {
|
||
if (isStaleModelLoadError(error)) return
|
||
|
||
console.error('楼层模型加载失败:', error)
|
||
invalidateModelLoads()
|
||
if (await activateWeakNetworkFallback(
|
||
requestedFloorId,
|
||
'weak-network',
|
||
'floor',
|
||
isModelLoadTimeoutError(error) ? 'model-load-timeout' : 'model-load-failed'
|
||
)) {
|
||
emitFloorChange(requestedFloorId, requestSceneRevision)
|
||
} else {
|
||
loadError.value = true
|
||
restoreCommittedFloorPoiLayer()
|
||
setFriendlyModelLoadError()
|
||
}
|
||
} finally {
|
||
const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1
|
||
if (!requestWasSuperseded || floorSwitchRequestedFloorId === requestedFloorId) {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const showOverview = async () => {
|
||
if (weakNetworkFallbackActive.value) {
|
||
selectedPOI.value = null
|
||
activeFocusPoiId.value = ''
|
||
return prepareWeakNetworkFallbackOverview()
|
||
}
|
||
const routeCompositeAsset = props.showRoute ? getSameGroundCompositeRouteAsset() : null
|
||
if (routeCompositeAsset) {
|
||
await showSameGroundRouteOverview(routeCompositeAsset)
|
||
return true
|
||
}
|
||
|
||
if (activeView.value === 'overview') {
|
||
resetCamera()
|
||
return true
|
||
}
|
||
|
||
const previousLoadToken = modelLoadVersion
|
||
try {
|
||
isLoading.value = true
|
||
loadError.value = false
|
||
clearMapSelection(false)
|
||
clearRoutePreview()
|
||
// Manual return from an indoor floor must use the same exterior handoff
|
||
// snapshot as automatic zoom-out. Reusing the current indoor camera makes
|
||
// the exterior model appear several times larger than the entry view.
|
||
await loadOverview({
|
||
cameraSnapshot: getAutoSwitchExitCamera()
|
||
})
|
||
return true
|
||
} 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 () => {
|
||
if (weakNetworkFallbackActive.value) return false
|
||
if (props.showRoute) {
|
||
// During an active simulation, the route session owns the current floor.
|
||
// Never send it back to the route start merely because a multi-floor
|
||
// command was received from the ordinary browse UI.
|
||
if (props.routeNavigationActive) return false
|
||
|
||
const initialRouteFloorId = getNavigationScenePlan()?.floorIds[0]
|
||
if (initialRouteFloorId && (activeView.value !== 'floor' || currentFloor.value !== initialRouteFloorId)) {
|
||
await handleFloorChange(initialRouteFloorId)
|
||
}
|
||
return false
|
||
}
|
||
|
||
try {
|
||
isLoading.value = true
|
||
loadError.value = false
|
||
activeFocusPoiId.value = ''
|
||
await loadMultiFloor()
|
||
isLoading.value = false
|
||
return activeView.value === 'multi'
|
||
} catch (error) {
|
||
if (isStaleModelLoadError(error)) return false
|
||
|
||
console.error('多层展示模型加载失败:', error)
|
||
loadError.value = true
|
||
isLoading.value = false
|
||
setFriendlyModelLoadError()
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Restores business and camera state on the existing scene. It never initializes WebGL or
|
||
// reloads the model package, so closing a detail page cannot remount ThreeMap.
|
||
const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
|
||
if (fallbackPresentation.value === 'two-dimensional') {
|
||
const didPrepare = options.view === 'overview'
|
||
? await prepareWeakNetworkFallbackOverview()
|
||
: options.floorId
|
||
? await prepareWeakNetworkFallbackFloor(options.floorId)
|
||
: false
|
||
if (!didPrepare) return 'invalid-target' as const
|
||
|
||
clearTargetFocus()
|
||
clearRoutePreview()
|
||
selectedPOI.value = null
|
||
activeFocusPoiId.value = ''
|
||
weakNetworkFallbackRef.value?.resetCamera?.()
|
||
if (options.view === 'floor' && options.floorId) {
|
||
emitFloorChange(options.floorId)
|
||
}
|
||
return 'applied' as const
|
||
}
|
||
|
||
// Do not invalidate the first model load while the renderer is still being initialized.
|
||
// The page-level guide model state will replay only its latest not-ready request.
|
||
const baseline = initialGuideState
|
||
if (!baseline || !scene || !camera || !controls) return 'not-ready' as const
|
||
if (loadError.value) return 'failed' as const
|
||
|
||
if (options.view === 'floor') {
|
||
if (!options.floorId || !floorIndex.value.some((item) => item.floorId === options.floorId)) {
|
||
return 'invalid-target' as const
|
||
}
|
||
}
|
||
|
||
clearTargetFocus()
|
||
invalidateModelLoads()
|
||
adjacentPreloadSeq += 1
|
||
defaultFloorPreloadSeq += 1
|
||
cancelCameraTween()
|
||
clearProgrammaticCameraTimer()
|
||
clearRoutePreview()
|
||
selectedPOI.value = null
|
||
activeFocusPoiId.value = ''
|
||
disposeFocusLabel()
|
||
disposeFocusPulse()
|
||
disposeFocusBase()
|
||
clearFocusHallHighlight()
|
||
updatePoiMarkerFocus()
|
||
autoSwitchStateMachine.reset(options.view)
|
||
resetInteractionGateState()
|
||
|
||
isLoading.value = true
|
||
try {
|
||
let cameraRequiresTopReapply = false
|
||
if (options.view === 'floor') {
|
||
const floorId = options.floorId
|
||
if (!floorId) return 'invalid-target' as const
|
||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||
if (!floor) return 'invalid-target' as const
|
||
|
||
if (activeView.value !== 'floor' || currentFloor.value !== floorId || activeModel?.userData.floorId !== floorId) {
|
||
await loadFloor(floorId, { applyFloorBaseline: true, detachPoiBeforeLoad: true })
|
||
} else {
|
||
const floorBaseline = getFloorViewBaseline(floorId, activeModel, floor.modelUrl)
|
||
if (!floorBaseline) return 'failed' as const
|
||
restoreCameraSnapshot(floorBaseline.camera)
|
||
applyFloorNavigationRange(floorBaseline)
|
||
cameraRequiresTopReapply = true
|
||
}
|
||
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return 'stale' as const
|
||
activeView.value = 'floor'
|
||
currentFloor.value = floorId
|
||
autoSwitchStateMachine.reset('floor')
|
||
} else {
|
||
currentFloor.value = baseline.floorId
|
||
if (activeView.value !== 'overview') {
|
||
await loadOverview({ cameraSnapshot: baseline.camera })
|
||
}
|
||
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return 'stale' as const
|
||
restoreCameraSnapshot(baseline.camera)
|
||
cameraRequiresTopReapply = true
|
||
currentFloor.value = baseline.floorId
|
||
activeView.value = 'overview'
|
||
autoSwitchStateMachine.reset('overview')
|
||
}
|
||
if (controls && activeView.value === 'floor' && !floorNavigationDistance) {
|
||
ensureFloorAutoExitZoomRange()
|
||
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
|
||
}
|
||
if (cameraRequiresTopReapply) reapplyLiveGlbTopAfterSceneCommit()
|
||
refreshPoiVisibilityByDistance()
|
||
return 'applied' as const
|
||
} catch (error) {
|
||
if (isStaleModelLoadError(error)) return 'stale' as const
|
||
if (!isStaleModelLoadError(error)) {
|
||
console.error('恢复馆内导览初始状态失败:', error)
|
||
}
|
||
return 'failed' as const
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const resetToInitialState = () => resetToViewBaseline({
|
||
view: 'overview',
|
||
reason: 'manual-reset'
|
||
})
|
||
|
||
const retryLoad = () => {
|
||
weakNetworkFallbackActive.value = false
|
||
threeRendererRetainedForTwoD.value = false
|
||
weakNetworkFallbackPois.value = []
|
||
init3DScene({ forceThreeRenderer: isTwoDimensionalMode.value })
|
||
}
|
||
|
||
const disposeScene = () => {
|
||
isDisposed = true
|
||
threeRendererRetainedForTwoD.value = false
|
||
liveGlbTopActive.value = false
|
||
liveGlbThreeDCameraSnapshot = null
|
||
liveGlbThreeDControlsSnapshot = null
|
||
pendingWeakNetworkZoom = null
|
||
adjacentPreloadSeq += 1
|
||
defaultFloorPreloadSeq += 1
|
||
referenceBuildingAnchors = []
|
||
invalidateModelLoads()
|
||
|
||
stopRenderLoop()
|
||
|
||
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
|
||
poiDomLabelResizeObserver?.disconnect()
|
||
poiDomLabelResizeObserver = null
|
||
poiDomLabelHandlesByElement.clear()
|
||
|
||
// 清理自动切换相关资源
|
||
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()
|
||
disposeVisualStabilityDiagnostics()
|
||
clearProgrammaticCameraTimer()
|
||
clearModelAdjustReportTimer()
|
||
hasPendingManualModelAdjustment = false
|
||
cameraTween = null
|
||
poiFocusCameraAnimationCount = 0
|
||
cameraSnapshotRestoreCount = 0
|
||
floorNavigationDistance = 0
|
||
isProgrammaticCameraChange = false
|
||
hasActiveUserCameraGesture = false
|
||
activeAutoSwitchInputSource = 'gesture'
|
||
resetInteractionGateState()
|
||
|
||
clearSceneData()
|
||
disposePoiMarkerCache()
|
||
disposePreparedFloorModelCache()
|
||
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,
|
||
resetToViewBaseline,
|
||
resetToInitialState,
|
||
clearNavigation: () => clearMapSelection(false),
|
||
disableAutoSwitchTemporarily,
|
||
getGuideViewportState
|
||
})
|
||
|
||
watch(() => props.modelSource, () => {
|
||
init3DScene({ forceThreeRenderer: liveGlbTopActive.value })
|
||
})
|
||
|
||
const enterTwoDimensionalMode = async (transitionRevision: number) => {
|
||
const hasRetainableThreeDScene = Boolean(scene && renderer && camera && controls && activeModel)
|
||
twoDViewportChangedSinceModeEntry = false
|
||
weakNetworkBoundaryIntentState = createGuideBoundaryIntentState()
|
||
weakNetworkSceneTransitionRevision += 1
|
||
weakNetworkSceneTransitionInFlight = false
|
||
|
||
if (hasRetainableThreeDScene) {
|
||
if (transitionRevision !== renderModeTransitionRevision) return
|
||
const viewport = getGuideViewportState()
|
||
if (viewport) emit('sceneViewportChange', viewport)
|
||
weakNetworkFallbackActive.value = false
|
||
weakNetworkFallbackPois.value = []
|
||
fallbackPresentation.value = null
|
||
applyLiveGlbTopView({
|
||
captureRestoreState: true,
|
||
transitionRevision
|
||
})
|
||
return
|
||
}
|
||
|
||
if (transitionRevision !== renderModeTransitionRevision) return
|
||
const targetView = requestedSceneView.value === 'overview' ? 'overview' : 'floor'
|
||
const didActivate = floorIndex.value.length
|
||
? await activateWeakNetworkFallback(
|
||
currentFloor.value,
|
||
'two-dimensional',
|
||
targetView,
|
||
'explicit-two-dimensional',
|
||
sceneInitializationVersion,
|
||
transitionRevision
|
||
)
|
||
: false
|
||
if (transitionRevision !== renderModeTransitionRevision) return
|
||
if (didActivate) return
|
||
|
||
await init3DScene()
|
||
}
|
||
|
||
const restoreThreeDimensionalMode = async (transitionRevision: number) => {
|
||
if (transitionRevision !== renderModeTransitionRevision) return
|
||
if (restoreLiveGlbThreeDimensionalView()) {
|
||
weakNetworkFallbackActive.value = false
|
||
threeRendererRetainedForTwoD.value = false
|
||
weakNetworkFallbackPois.value = []
|
||
fallbackPresentation.value = null
|
||
loadError.value = false
|
||
isLoading.value = false
|
||
twoDViewportChangedSinceModeEntry = false
|
||
renderRoutePreview()
|
||
return
|
||
}
|
||
|
||
const preservedSelectedPoi = selectedPOI.value
|
||
if (weakNetworkFallbackActive.value || !scene || !renderer || !camera || !controls || !activeModel) {
|
||
await init3DScene({
|
||
preserveSelectedPoi: preservedSelectedPoi,
|
||
restoreSceneViewport: true
|
||
})
|
||
return
|
||
}
|
||
if (transitionRevision !== renderModeTransitionRevision) return
|
||
|
||
weakNetworkFallbackActive.value = false
|
||
threeRendererRetainedForTwoD.value = false
|
||
weakNetworkFallbackPois.value = []
|
||
fallbackPresentation.value = null
|
||
loadError.value = false
|
||
isLoading.value = false
|
||
twoDViewportChangedSinceModeEntry = false
|
||
startRenderLoop()
|
||
refreshPoiVisibilityByDistance()
|
||
renderRoutePreview()
|
||
}
|
||
|
||
watch(() => props.renderMode, (renderMode, previousRenderMode) => {
|
||
if (renderMode === previousRenderMode) return
|
||
const transitionRevision = ++renderModeTransitionRevision
|
||
if (renderMode === 'two-d') {
|
||
void enterTwoDimensionalMode(transitionRevision)
|
||
return
|
||
}
|
||
void restoreThreeDimensionalMode(transitionRevision)
|
||
})
|
||
|
||
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.routeStartSelectionActive, (isSelectingStart) => {
|
||
if (isSelectingStart) {
|
||
clearMapSelection(false)
|
||
}
|
||
}, {
|
||
immediate: true
|
||
})
|
||
|
||
watch(() => [props.routePreview, props.showRoute, props.routeNavigationActive], () => {
|
||
syncRouteCompositeView()
|
||
}, {
|
||
deep: true
|
||
})
|
||
|
||
const syncRequestedIndoorView = async () => {
|
||
if (weakNetworkFallbackActive.value) {
|
||
if (floorIndex.value.length) {
|
||
const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId) || currentFloor.value
|
||
await activateWeakNetworkFallback(
|
||
requestedFloorId,
|
||
'two-dimensional',
|
||
requestedSceneView.value === 'overview' ? 'overview' : 'floor'
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!scene || !floorIndex.value.length || isLoading.value) return
|
||
|
||
// Route guidance never inherits a stale ordinary-browse multi-floor view.
|
||
// It presents only the active WALK segment on its physical floor.
|
||
const routeScene = props.showRoute ? getNavigationScenePlan() : null
|
||
if (routeScene?.kind === 'multi-floor') {
|
||
if (props.routeNavigationActive) return
|
||
|
||
const initialRouteFloorId = routeScene.floorIds[0]
|
||
if (initialRouteFloorId && (activeView.value !== 'floor' || currentFloor.value !== initialRouteFloorId)) {
|
||
await handleFloorChange(initialRouteFloorId)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (requestedSceneView.value === 'multi') {
|
||
if (activeView.value !== 'multi') {
|
||
await showMultiFloor()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (requestedSceneView.value === 'floor') {
|
||
const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId)
|
||
|| currentFloor.value
|
||
if (requestedFloorId && (activeView.value !== 'floor' || currentFloor.value !== requestedFloorId)) {
|
||
await handleFloorChange(requestedFloorId)
|
||
}
|
||
}
|
||
}
|
||
|
||
watch(() => [props.sceneView, 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;
|
||
}
|
||
}
|
||
|
||
.three-poi-dom-label-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 4;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label) {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 22px;
|
||
gap: 3px;
|
||
padding: 2px 5px;
|
||
box-sizing: border-box;
|
||
max-width: min(190px, calc(100% - 20px));
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
color: #14205f;
|
||
background: rgba(255, 255, 255, 0.56);
|
||
border: 1px solid rgba(26, 35, 126, 0.12);
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 7px rgba(26, 35, 126, 0.1);
|
||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.86);
|
||
font-size: 12px;
|
||
line-height: 15px;
|
||
font-weight: 400;
|
||
transition: opacity 120ms ease;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label::after) {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: -5px;
|
||
width: 8px;
|
||
height: 8px;
|
||
box-sizing: border-box;
|
||
content: '';
|
||
background: rgba(255, 255, 255, 0.56);
|
||
border-right: 1px solid rgba(26, 35, 126, 0.12);
|
||
border-bottom: 1px solid rgba(26, 35, 126, 0.12);
|
||
transform: translateX(-50%) rotate(45deg);
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--ambient) {
|
||
color: #14205f;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--selected) {
|
||
color: #0f4f9d;
|
||
background: rgba(238, 246, 255, 0.72);
|
||
border-color: rgba(21, 101, 192, 0.48);
|
||
box-shadow: 0 2px 8px rgba(21, 101, 192, 0.16);
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--selected::after) {
|
||
background: rgba(238, 246, 255, 0.72);
|
||
border-color: rgba(21, 101, 192, 0.48);
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--overview) {
|
||
color: #14205f;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--overview::after) {
|
||
background: rgba(255, 255, 255, 0.56);
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--focus) {
|
||
color: #0f4f9d;
|
||
background: rgba(238, 246, 255, 0.82);
|
||
border-color: rgba(21, 101, 192, 0.48);
|
||
box-shadow: 0 2px 8px rgba(21, 101, 192, 0.16);
|
||
}
|
||
|
||
:deep(.three-poi-dom-label__title) {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
max-width: 100%;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label__icon) {
|
||
display: block;
|
||
flex: 0 0 16px;
|
||
width: 16px;
|
||
height: 16px;
|
||
box-sizing: border-box;
|
||
padding: 2px;
|
||
color: var(--poi-icon-color, #1565c0);
|
||
background: rgba(255, 255, 255, 0.74);
|
||
border: 1px solid currentColor;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='exhibition-hall']) {
|
||
--poi-icon-color: #356f9c;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='cinema']) {
|
||
--poi-icon-color: #78649f;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='ticket-office']) {
|
||
--poi-icon-color: #a7752f;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='dining']) {
|
||
--poi-icon-color: #b76545;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='shopping']) {
|
||
--poi-icon-color: #4c8c7d;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='service-center']),
|
||
:deep(.three-poi-dom-label[data-poi-icon='elevator']),
|
||
:deep(.three-poi-dom-label[data-poi-icon='escalator']),
|
||
:deep(.three-poi-dom-label[data-poi-icon='stairs']) {
|
||
--poi-icon-color: #39739b;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='restroom']),
|
||
:deep(.three-poi-dom-label[data-poi-icon='nursing-room']) {
|
||
--poi-icon-color: #6b77a8;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='entrance']) {
|
||
--poi-icon-color: #356f9c;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='parking']) {
|
||
--poi-icon-color: #7b6a9d;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='place']) {
|
||
--poi-icon-color: #4d8a66;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='road']) {
|
||
--poi-icon-color: #71808b;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label[data-poi-icon='transport']) {
|
||
--poi-icon-color: #b16c32;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label--focus .three-poi-dom-label__title) {
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
:deep(.three-poi-dom-label__meta) {
|
||
margin-top: 1px;
|
||
color: #626b75;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.camera-calibration-panel {
|
||
position: absolute;
|
||
right: 10px;
|
||
top: 10px;
|
||
width: min(282px, calc(100% - 20px));
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 9px;
|
||
box-sizing: border-box;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
border: 1px solid rgba(26, 35, 126, 0.18);
|
||
border-radius: 6px;
|
||
box-shadow: 0 6px 18px rgba(31, 35, 41, 0.16);
|
||
z-index: 90;
|
||
pointer-events: auto;
|
||
transform: translateX(calc(100% + 12px));
|
||
transition: transform 180ms ease;
|
||
}
|
||
|
||
.camera-calibration-panel.expanded {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.camera-calibration-toggle {
|
||
position: absolute;
|
||
right: 10px;
|
||
top: 14px;
|
||
width: 42px;
|
||
height: 58px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: rgba(255, 255, 255, 0.94);
|
||
border: 1px solid rgba(26, 35, 126, 0.2);
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 12px rgba(31, 35, 41, 0.12);
|
||
z-index: 90;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.camera-calibration-toggle text {
|
||
color: #1a237e;
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
font-weight: 700;
|
||
writing-mode: vertical-rl;
|
||
}
|
||
|
||
.camera-calibration-header,
|
||
.camera-calibration-actions,
|
||
.camera-calibration-input-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.camera-calibration-header {
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.camera-calibration-title,
|
||
.camera-calibration-subtitle,
|
||
.camera-calibration-row text,
|
||
.camera-calibration-center-title,
|
||
.camera-calibration-input-row text,
|
||
.camera-calibration-output,
|
||
.camera-calibration-actions text {
|
||
display: block;
|
||
}
|
||
|
||
.camera-calibration-title {
|
||
color: #1a237e;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.camera-calibration-subtitle {
|
||
color: #5f666d;
|
||
font-size: 11px;
|
||
line-height: 15px;
|
||
}
|
||
|
||
.camera-calibration-close {
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #4d5560;
|
||
font-size: 20px;
|
||
line-height: 24px;
|
||
}
|
||
|
||
.camera-calibration-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.camera-calibration-row text,
|
||
.camera-calibration-center-title,
|
||
.camera-calibration-input-row text {
|
||
color: #2f3741;
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
}
|
||
|
||
.camera-calibration-row slider {
|
||
width: 100%;
|
||
margin: 0;
|
||
}
|
||
|
||
.camera-calibration-center {
|
||
padding-top: 7px;
|
||
border-top: 1px solid rgba(31, 35, 41, 0.1);
|
||
}
|
||
|
||
.camera-calibration-center-title {
|
||
margin-bottom: 4px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.camera-calibration-input-row {
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.camera-calibration-input-row text {
|
||
width: 12px;
|
||
color: #5f666d;
|
||
text-align: center;
|
||
}
|
||
|
||
.camera-calibration-input-row input {
|
||
height: 28px;
|
||
flex: 1;
|
||
padding: 0 8px;
|
||
box-sizing: border-box;
|
||
color: #1f2329;
|
||
background: #f7f8f4;
|
||
border: 1px solid rgba(31, 35, 41, 0.16);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
line-height: 28px;
|
||
}
|
||
|
||
.camera-calibration-output {
|
||
min-height: 32px;
|
||
padding: 6px 8px;
|
||
box-sizing: border-box;
|
||
color: #4d5560;
|
||
background: #f4f7fb;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
line-height: 16px;
|
||
}
|
||
|
||
.camera-calibration-actions {
|
||
gap: 8px;
|
||
}
|
||
|
||
.camera-calibration-actions view {
|
||
height: 30px;
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: #1565c0;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.camera-calibration-actions view:first-child {
|
||
background: #eef2f6;
|
||
border: 1px solid rgba(31, 35, 41, 0.14);
|
||
}
|
||
|
||
.camera-calibration-actions text {
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.camera-calibration-actions view:first-child text {
|
||
color: #2f3741;
|
||
}
|
||
|
||
.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>
|