Files
frontend-miniapp/src/components/map/ThreeMap.vue
lyf 540bb8627f
Some checks failed
CI / verify (push) Has been cancelled
同步移动端源码并修复小程序嵌入标题
2026-07-28 18:37:40 +08:00

10051 lines
319 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="three-map-container">
<view
ref="containerRef"
class="three-canvas-wrapper"
: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>