@@ -117,6 +117,10 @@ const SGS_VISUAL_RENDER_CONFIG = {
|
||||
minPolarAngle: 0,
|
||||
maxPolarAngle: Math.PI / 2 - 0.05,
|
||||
dampingFactor: 0.06
|
||||
},
|
||||
framing: {
|
||||
overviewScreenOffsetRatio: new THREE.Vector2(-0.045, -0.095),
|
||||
floorScreenOffsetRatio: new THREE.Vector2(0, -0.055)
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -224,6 +228,9 @@ interface PoiMarkerCacheEntry {
|
||||
group: THREE.Group
|
||||
markerSize: number
|
||||
displayOffsetY: number
|
||||
rawPoiCount?: number
|
||||
rawPositionedPoiCount?: number
|
||||
filteredCategoryCounts?: Record<string, number>
|
||||
}
|
||||
|
||||
interface PoiYDistribution {
|
||||
@@ -374,6 +381,8 @@ let isProgrammaticCameraChange = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let cameraTween: CameraTweenState | null = null
|
||||
let adjacentPreloadSeq = 0
|
||||
let firstModelLoadStartedAt = 0
|
||||
let firstModelVisibleReported = false
|
||||
|
||||
const cameraTweenEnabled = true
|
||||
const cameraTweenDurationMs = 480
|
||||
@@ -399,6 +408,79 @@ const floorExitAccumulatedZoomOutRatio = 0.26
|
||||
const floorExitCandidateHoldMs = 900
|
||||
const modelLoadRetryDelaysMs = [300, 900]
|
||||
const adjacentPreloadDelayMs = 900
|
||||
const poiMarkerVisualLiftFactor = 0.92
|
||||
const poiAmbientLabelLiftFactor = 2.1
|
||||
const poiFocusLabelLiftFactor = 2.72
|
||||
const poiFocusBaseLiftFactor = 0.18
|
||||
const poiFocusPulseLiftFactor = 0.34
|
||||
|
||||
type ThreeMapDiagnosticEvent =
|
||||
| 'init-start'
|
||||
| 'package-ready'
|
||||
| 'model-load-start'
|
||||
| 'model-load-complete'
|
||||
| 'model-load-failed'
|
||||
| 'first-model-visible'
|
||||
| 'poi-background-start'
|
||||
| 'poi-background-ready'
|
||||
| 'poi-filter-diagnostics'
|
||||
| 'poi-marker-render-diagnostics'
|
||||
| 'poi-hit-diagnostics'
|
||||
| 'adjacent-preload-skip'
|
||||
|
||||
const getNow = () => (
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
)
|
||||
|
||||
const isThreeMapDiagnosticsEnabled = () => {
|
||||
if (!import.meta.env.DEV) return false
|
||||
if (typeof window === 'undefined') return true
|
||||
|
||||
const diagnosticsWindow = window as unknown as {
|
||||
__GUIDE_3D_MODEL_DIAGNOSTICS__?: boolean
|
||||
}
|
||||
return diagnosticsWindow.__GUIDE_3D_MODEL_DIAGNOSTICS__ !== false
|
||||
}
|
||||
|
||||
const recordThreeMapDiagnostic = (
|
||||
event: ThreeMapDiagnosticEvent,
|
||||
payload: Record<string, unknown>
|
||||
) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const diagnosticsWindow = window as unknown as {
|
||||
__GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{
|
||||
source: string
|
||||
event: string
|
||||
payload: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= []
|
||||
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({
|
||||
source: 'ThreeMap',
|
||||
event,
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
const stringifyDiagnosticPayload = (payload: Record<string, unknown>) => {
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
const logThreeMapDiagnostic = (
|
||||
event: ThreeMapDiagnosticEvent,
|
||||
payload: Record<string, unknown> = {}
|
||||
) => {
|
||||
if (!isThreeMapDiagnosticsEnabled()) return
|
||||
recordThreeMapDiagnostic(event, payload)
|
||||
console.debug(`[ThreeMap] ${event} ${stringifyDiagnosticPayload(payload)}`)
|
||||
}
|
||||
|
||||
const syncControlInteractionOptions = () => {
|
||||
if (!controls) return
|
||||
@@ -411,6 +493,7 @@ const syncControlInteractionOptions = () => {
|
||||
}
|
||||
|
||||
const corePoiCategories = new Set([
|
||||
'poi',
|
||||
'touring_poi',
|
||||
'exhibition_hall',
|
||||
'exhibition_hall_entrance',
|
||||
@@ -424,6 +507,7 @@ const poiCategoryPriority: Record<string, number> = {
|
||||
exhibition_hall: 96,
|
||||
exhibition_hall_entrance: 92,
|
||||
touring_poi: 90,
|
||||
poi: 86,
|
||||
accessibility_special_service: 88,
|
||||
basic_service_facility: 82,
|
||||
transport_circulation: 76,
|
||||
@@ -444,6 +528,7 @@ const isServiceFacilityPoi = (poi: RenderPoi) => (
|
||||
const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'
|
||||
|
||||
const poiGlyphMap: Record<string, string> = {
|
||||
poi: '点',
|
||||
accessible_restroom: '无',
|
||||
accessibility: '无',
|
||||
restroom: '卫',
|
||||
@@ -701,6 +786,52 @@ const getPoiDisplayMode = (): PoiDisplayMode => {
|
||||
|
||||
const getPoiMarkerCacheKey = (floorId: string, displayMode = getPoiDisplayMode()) => `${floorId}:${displayMode}`
|
||||
|
||||
const countPoisByCategory = (pois: RenderPoi[]) => pois.reduce<Record<string, number>>((counts, poi) => {
|
||||
const key = poi.primaryCategory || '<missing>'
|
||||
counts[key] = (counts[key] || 0) + 1
|
||||
return counts
|
||||
}, {})
|
||||
|
||||
const summarizeSelectedPoiForDiagnostics = (poi: RenderPoi | null) => (
|
||||
poi
|
||||
? {
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
primaryCategory: poi.primaryCategory
|
||||
}
|
||||
: null
|
||||
)
|
||||
|
||||
const countPoiMarkerObjects = (group: THREE.Group) => {
|
||||
const counts = {
|
||||
markerCount: 0,
|
||||
hitTargetCount: 0,
|
||||
labelCount: 0
|
||||
}
|
||||
|
||||
group.traverse((child) => {
|
||||
if (!(child instanceof THREE.Sprite)) return
|
||||
|
||||
const userData = child.userData as PoiSpriteUserData
|
||||
if (userData.isPoiHitTarget) {
|
||||
counts.hitTargetCount += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (userData.isPoiLabel) {
|
||||
counts.labelCount += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (userData.poi) {
|
||||
counts.markerCount += 1
|
||||
}
|
||||
})
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
const summarizePoiYDistribution = (pois: RenderPoi[]): PoiYDistribution | null => {
|
||||
const values = pois
|
||||
.map((poi) => poi.positionGltf?.[1])
|
||||
@@ -749,6 +880,24 @@ const getPoiDisplayPosition = (poi: RenderPoi, floorId = poi.floorId) => {
|
||||
return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z)
|
||||
}
|
||||
|
||||
const getPoiLiftedPosition = (
|
||||
displayPosition: THREE.Vector3,
|
||||
markerSize: number,
|
||||
liftFactor: number
|
||||
) => new THREE.Vector3(
|
||||
displayPosition.x,
|
||||
displayPosition.y + markerSize * liftFactor,
|
||||
displayPosition.z
|
||||
)
|
||||
|
||||
const getPoiMarkerPosition = (displayPosition: THREE.Vector3, markerSize: number) => (
|
||||
getPoiLiftedPosition(displayPosition, markerSize, poiMarkerVisualLiftFactor)
|
||||
)
|
||||
|
||||
const getPoiAmbientLabelPosition = (displayPosition: THREE.Vector3, markerSize: number) => (
|
||||
getPoiLiftedPosition(displayPosition, markerSize, poiAmbientLabelLiftFactor)
|
||||
)
|
||||
|
||||
const resolvePoiDisplayOffsetY = (
|
||||
floorId: string,
|
||||
model: THREE.Object3D,
|
||||
@@ -1037,7 +1186,7 @@ const updateAmbientPoiLabels = () => {
|
||||
|
||||
updateAmbientLabelScale(labelSprite, poi)
|
||||
|
||||
const baseOffset = markerSize * (isHallPoi(poi) ? 1.82 : 1.5)
|
||||
const baseOffset = markerSize * (isHallPoi(poi) ? poiAmbientLabelLiftFactor : 1.82)
|
||||
const offsetAttempts = [
|
||||
0,
|
||||
markerSize * 0.82,
|
||||
@@ -1295,6 +1444,23 @@ const setProgress = (progress: number, message: string) => {
|
||||
loadingMessage.value = message
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
@@ -2303,20 +2469,46 @@ const loadModelOnce = (
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = getNow()
|
||||
logThreeMapDiagnostic('model-load-start', {
|
||||
label,
|
||||
url
|
||||
})
|
||||
|
||||
loader.load(
|
||||
url,
|
||||
resolve,
|
||||
(gltf) => {
|
||||
logThreeMapDiagnostic('model-load-complete', {
|
||||
label,
|
||||
url,
|
||||
elapsedMs: Math.round(getNow() - startedAt)
|
||||
})
|
||||
resolve(gltf)
|
||||
},
|
||||
(event) => {
|
||||
if (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return
|
||||
|
||||
if (event.total > 0) {
|
||||
const modelProgress = Math.round((event.loaded / event.total) * 70)
|
||||
setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
|
||||
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)
|
||||
setProgress(45, `${label}: 正在下载模型...`)
|
||||
}
|
||||
},
|
||||
reject
|
||||
(error) => {
|
||||
logThreeMapDiagnostic('model-load-failed', {
|
||||
label,
|
||||
url,
|
||||
elapsedMs: Math.round(getNow() - startedAt),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2377,6 +2569,9 @@ const loadModelWithFallback = async (
|
||||
loadSource: (url) => loadModelFromNetwork(url, label, loadToken, options),
|
||||
shouldStopOnError: isStaleModelLoadError
|
||||
})
|
||||
if (!options.suppressProgress) {
|
||||
setProgress(92, `${label}: 正在准备三维场景...`)
|
||||
}
|
||||
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||||
disposeObject(gltf.scene)
|
||||
@@ -2460,15 +2655,32 @@ const preloadModelUrls = async (urls: string[], label: string, preloadSeq: numbe
|
||||
const candidates = urls.map((url) => url.trim()).filter(Boolean)
|
||||
if (!candidates.length || preloadSeq !== adjacentPreloadSeq || isDisposed) return
|
||||
|
||||
const availableCandidates = candidates.filter((url) => {
|
||||
const isCached = guideModelLoadManager.has(url)
|
||||
const isPending = guideModelLoadManager.isInFlight(url)
|
||||
if (isCached || isPending) {
|
||||
logThreeMapDiagnostic('adjacent-preload-skip', {
|
||||
label,
|
||||
url,
|
||||
reason: isCached ? 'cache-hit' : 'in-flight-hit',
|
||||
diagnostics: guideModelLoadManager.getDiagnostics()
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
if (!availableCandidates.length) return
|
||||
|
||||
try {
|
||||
await guideModelLoadManager.preload({
|
||||
urls: candidates,
|
||||
urls: availableCandidates,
|
||||
label,
|
||||
loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
|
||||
urls: candidates,
|
||||
urls: availableCandidates,
|
||||
label,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
@@ -2497,10 +2709,25 @@ const scheduleAdjacentFloorPreload = (floorId: string) => {
|
||||
if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
|
||||
|
||||
void (async () => {
|
||||
const seenModelKeys = new Set<string>()
|
||||
for (const floor of adjacentFloors) {
|
||||
if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
|
||||
const uniqueUrls = getFloorModelUrls(floor).filter((url) => {
|
||||
const key = guideModelLoadManager.createCacheKey(url)
|
||||
if (!key) return false
|
||||
if (seenModelKeys.has(key)) {
|
||||
logThreeMapDiagnostic('adjacent-preload-skip', {
|
||||
floorId: floor.floorId,
|
||||
url,
|
||||
reason: 'duplicate-shared-url'
|
||||
})
|
||||
return false
|
||||
}
|
||||
seenModelKeys.add(key)
|
||||
return true
|
||||
})
|
||||
await preloadModelUrls(
|
||||
getFloorModelUrls(floor),
|
||||
uniqueUrls,
|
||||
`预加载 ${formatFloorLabel(floor.floorId)} 模型`,
|
||||
preloadSeq
|
||||
)
|
||||
@@ -2980,12 +3207,13 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
|
||||
const overviewFitOptions: CameraFitOptions = activeView.value === 'overview'
|
||||
? {
|
||||
distanceFactor: 0.9,
|
||||
screenOffsetRatio: new THREE.Vector2(-0.045, 0.02),
|
||||
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.overviewScreenOffsetRatio,
|
||||
up: new THREE.Vector3(0, 1, 0)
|
||||
}
|
||||
: activeView.value === 'floor'
|
||||
? {
|
||||
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
|
||||
screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
|
||||
up: new THREE.Vector3(0, 1, 0)
|
||||
}
|
||||
: {}
|
||||
@@ -3069,9 +3297,20 @@ const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
|
||||
const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
|
||||
if (!shouldRenderPoiMarkers.value) return
|
||||
|
||||
const startedAt = getNow()
|
||||
logThreeMapDiagnostic('poi-background-start', {
|
||||
floorId: currentFloor.value,
|
||||
view: activeView.value
|
||||
})
|
||||
|
||||
void loadCurrentFloorPoiMarkers(loadToken)
|
||||
.then(() => {
|
||||
if (!isCurrentModelLoad(loadToken)) return
|
||||
logThreeMapDiagnostic('poi-background-ready', {
|
||||
floorId: currentFloor.value,
|
||||
view: activeView.value,
|
||||
elapsedMs: Math.round(getNow() - startedAt)
|
||||
})
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
})
|
||||
@@ -3103,6 +3342,10 @@ const loadOverview = async () => {
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
markFirstModelVisible('overview', {
|
||||
source: 'cached-overview',
|
||||
modelUrl: cachedSharedModelUrl
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3130,6 +3373,10 @@ const loadOverview = async () => {
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
markFirstModelVisible('overview', {
|
||||
source: 'network',
|
||||
modelUrl: packageData.overviewModelUrl
|
||||
})
|
||||
}
|
||||
|
||||
const prepareFloorScene = async (
|
||||
@@ -3190,17 +3437,9 @@ const prepareFloorScene = async (
|
||||
throw new Error(`楼层模型加载失败:${requestedFloorId}`)
|
||||
}
|
||||
|
||||
let poiEntry: PoiMarkerCacheEntry | undefined | null = null
|
||||
try {
|
||||
poiEntry = shouldRenderPoiMarkers.value
|
||||
? await prepareFloorPOIs(floor, loadToken, model)
|
||||
: null
|
||||
} catch (error) {
|
||||
if (ownsModel) {
|
||||
disposeObject(model, protectedResources)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const poiEntry = shouldRenderPoiMarkers.value
|
||||
? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floor.floorId)) || null
|
||||
: null
|
||||
|
||||
const prepared: PreparedFloorScene = {
|
||||
floor,
|
||||
@@ -3260,6 +3499,12 @@ const commitPreparedFloorScene = (
|
||||
markFloorAutoSwitchEntry()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
markFirstModelVisible('floor', {
|
||||
floorId: expectedFloorId,
|
||||
modelUrl: prepared.floor.modelUrl,
|
||||
poiFromCache: Boolean(prepared.poiEntry)
|
||||
})
|
||||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||||
scheduleAdjacentFloorPreload(expectedFloorId)
|
||||
}
|
||||
|
||||
@@ -3370,6 +3615,11 @@ const loadMultiFloor = async () => {
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
markFirstModelVisible('multi', {
|
||||
source: 'shared-model',
|
||||
modelUrl: sharedModelUrl,
|
||||
floorCount: orderedFloors.length
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
disposeObject(group, protectedResources)
|
||||
@@ -3441,6 +3691,10 @@ const loadMultiFloor = async () => {
|
||||
resetAutoSwitchDistanceTracking()
|
||||
refreshPoiVisibilityByDistance()
|
||||
renderRoutePreview()
|
||||
markFirstModelVisible('multi', {
|
||||
source: 'per-floor-models',
|
||||
floorCount: orderedFloors.length
|
||||
})
|
||||
}
|
||||
|
||||
const createPoiMaterial = (poi: RenderPoi) => {
|
||||
@@ -3586,6 +3840,7 @@ const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
sprite.userData.labelBaseScaleX = markerSize * 5.6
|
||||
sprite.userData.labelBaseScaleY = markerSize * 1.68
|
||||
sprite.renderOrder = 30
|
||||
sprite.frustumCulled = false
|
||||
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
|
||||
|
||||
return sprite
|
||||
@@ -3661,6 +3916,7 @@ const createAmbientPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
sprite.userData.labelBaseScaleX = markerSize * (isHallPoi(poi) ? 3.9 : 3.05)
|
||||
sprite.userData.labelBaseScaleY = markerSize * (isHallPoi(poi) ? 1.22 : 0.95)
|
||||
sprite.renderOrder = isHallPoi(poi) ? 18 : 15
|
||||
sprite.frustumCulled = false
|
||||
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
|
||||
sprite.visible = false
|
||||
|
||||
@@ -3697,6 +3953,7 @@ const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
sprite.userData.isPoiPulse = true
|
||||
sprite.userData.baseScale = markerSize * 1.9
|
||||
sprite.renderOrder = 9
|
||||
sprite.frustumCulled = false
|
||||
sprite.scale.set(markerSize * 1.9, markerSize * 1.9, markerSize * 1.9)
|
||||
|
||||
return sprite
|
||||
@@ -3734,6 +3991,7 @@ const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
sprite.userData.isPoiBase = true
|
||||
sprite.userData.baseScale = markerSize * 2.15
|
||||
sprite.renderOrder = 8
|
||||
sprite.frustumCulled = false
|
||||
sprite.scale.set(markerSize * 2.15, markerSize * 2.15, markerSize * 2.15)
|
||||
|
||||
return sprite
|
||||
@@ -3771,7 +4029,8 @@ const showFocusPoiLabel = (poi: RenderPoi) => {
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
||||
activeFocusLabelSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 2.35, displayPosition.z)
|
||||
activeFocusLabelSprite.frustumCulled = false
|
||||
activeFocusLabelSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusLabelLiftFactor))
|
||||
updateFocusLabelScale()
|
||||
poiGroup.add(activeFocusLabelSprite)
|
||||
}
|
||||
@@ -3784,7 +4043,8 @@ const showFocusPoiBase = (poi: RenderPoi) => {
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
|
||||
activeFocusBaseSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.04, displayPosition.z)
|
||||
activeFocusBaseSprite.frustumCulled = false
|
||||
activeFocusBaseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusBaseLiftFactor))
|
||||
poiGroup.add(activeFocusBaseSprite)
|
||||
}
|
||||
|
||||
@@ -3796,7 +4056,8 @@ const showFocusPoiPulse = (poi: RenderPoi) => {
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
|
||||
activeFocusPulseSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.15, displayPosition.z)
|
||||
activeFocusPulseSprite.frustumCulled = false
|
||||
activeFocusPulseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusPulseLiftFactor))
|
||||
poiGroup.add(activeFocusPulseSprite)
|
||||
}
|
||||
|
||||
@@ -3809,6 +4070,7 @@ const showFocusPoiAffordances = (poi: RenderPoi) => {
|
||||
|
||||
const getPoiColor = (category: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
poi: '#56606b',
|
||||
touring_poi: '#2f6fed',
|
||||
exhibition_hall: '#2f6fed',
|
||||
exhibition_hall_entrance: '#4659d8',
|
||||
@@ -3900,6 +4162,7 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
||||
sprite.userData.poi = poi
|
||||
sprite.userData.isCorePoi = isCorePoi
|
||||
sprite.userData.hitTarget = hitTarget
|
||||
sprite.frustumCulled = false
|
||||
if (labelSprite) {
|
||||
sprite.userData.labelSprite = labelSprite
|
||||
}
|
||||
@@ -3910,6 +4173,7 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
||||
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)
|
||||
@@ -3941,7 +4205,7 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
||||
sprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.5, displayPosition.z)
|
||||
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
|
||||
hitTarget.position.copy(sprite.position)
|
||||
poiGroup.add(sprite)
|
||||
poiGroup.add(hitTarget)
|
||||
@@ -3966,10 +4230,10 @@ const createPoiMarkerGroup = (
|
||||
if (!displayPosition) return
|
||||
|
||||
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
|
||||
sprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.5, displayPosition.z)
|
||||
sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
|
||||
hitTarget.position.copy(sprite.position)
|
||||
if (labelSprite) {
|
||||
labelSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 1.82, displayPosition.z)
|
||||
labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
|
||||
labelSprite.visible = false
|
||||
}
|
||||
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
|
||||
@@ -3994,6 +4258,24 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
||||
poiGroup.userData.floorId = entry.floorId
|
||||
poiGroup.userData.currentFloor = entry.floorId
|
||||
updatePoiMarkerFocus()
|
||||
logThreeMapDiagnostic('poi-marker-render-diagnostics', {
|
||||
floorId: entry.floorId,
|
||||
currentFloor: currentFloor.value,
|
||||
displayMode: entry.displayMode,
|
||||
rawPoiCount: entry.rawPoiCount ?? entry.pois.length,
|
||||
rawPositionedPoiCount: entry.rawPositionedPoiCount ?? entry.pois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||
renderPoiCount: entry.pois.length,
|
||||
renderPositionedPoiCount: entry.pois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||
renderCategoryCounts: countPoisByCategory(entry.pois),
|
||||
filteredCategoryCounts: entry.filteredCategoryCounts || {},
|
||||
markerObjects: countPoiMarkerObjects(entry.group),
|
||||
poiGroupFloorId: poiGroup.userData.floorId,
|
||||
poiGroupCurrentFloor: poiGroup.userData.currentFloor,
|
||||
selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value),
|
||||
markerSize: roundDiagnosticNumber(entry.markerSize),
|
||||
displayOffsetY: roundDiagnosticNumber(entry.displayOffsetY),
|
||||
visualLiftY: roundDiagnosticNumber(entry.markerSize * poiMarkerVisualLiftFactor)
|
||||
})
|
||||
}
|
||||
|
||||
const restoreCommittedFloorPoiLayer = () => {
|
||||
@@ -4044,6 +4326,21 @@ const prepareFloorPOIs = async (
|
||||
|
||||
poiDataCache.set(floor.floorId, floorPois)
|
||||
const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi))
|
||||
const filteredPois = floorPois.filter((poi) => !validPois.some((validPoi) => validPoi.id === poi.id))
|
||||
logThreeMapDiagnostic('poi-filter-diagnostics', {
|
||||
floorId: floor.floorId,
|
||||
currentFloor: currentFloor.value,
|
||||
displayMode,
|
||||
rawPoiCount: floorPois.length,
|
||||
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||
rawCategoryCounts: countPoisByCategory(floorPois),
|
||||
poiCategoryCount: floorPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
||||
renderPoiCount: validPois.length,
|
||||
renderPositionedPoiCount: validPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||
renderCategoryCounts: countPoisByCategory(validPois),
|
||||
renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
||||
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
||||
})
|
||||
const displayOffsetY = markerModel
|
||||
? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois)
|
||||
: getCachedPoiDisplayOffsetY(floor.floorId)
|
||||
@@ -4055,7 +4352,10 @@ const prepareFloorPOIs = async (
|
||||
pois: validPois,
|
||||
group: markerGroup,
|
||||
markerSize,
|
||||
displayOffsetY
|
||||
displayOffsetY,
|
||||
rawPoiCount: floorPois.length,
|
||||
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||
filteredCategoryCounts: countPoisByCategory(filteredPois)
|
||||
}
|
||||
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||||
@@ -4140,13 +4440,31 @@ const handleSceneTap = async (event: PointerEvent) => {
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
|
||||
if ((activeView.value === 'floor' || activeView.value === 'overview') && poiGroup) {
|
||||
const poiHits = raycaster.intersectObjects([...getPoiHitTargets(), ...getPoiSprites()], false)
|
||||
const hitTargets = getPoiHitTargets()
|
||||
const poiSprites = getPoiSprites()
|
||||
const poiHits = raycaster.intersectObjects([...hitTargets, ...poiSprites], false)
|
||||
const hit = poiHits.find((item) => (
|
||||
item.object.visible
|
||||
&& item.object instanceof THREE.Sprite
|
||||
&& getPoiMarkerFromHit(item.object)
|
||||
))
|
||||
const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : findNearestPoiMarkerByScreenPoint(event, rect)
|
||||
const fallbackMarker = hit ? null : findNearestPoiMarkerByScreenPoint(event, rect)
|
||||
const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : fallbackMarker
|
||||
|
||||
logThreeMapDiagnostic('poi-hit-diagnostics', {
|
||||
view: activeView.value,
|
||||
currentFloor: currentFloor.value,
|
||||
poiGroupFloorId: poiGroup.userData.floorId,
|
||||
pointer: {
|
||||
x: Math.round(event.clientX - rect.left),
|
||||
y: Math.round(event.clientY - rect.top)
|
||||
},
|
||||
visibleMarkerCount: poiSprites.filter((sprite) => sprite.visible).length,
|
||||
hitTargetCount: hitTargets.length,
|
||||
rayHitCount: poiHits.length,
|
||||
raySelectedPoiId: hit ? (getPoiMarkerFromHit(hit.object)?.userData.poi as RenderPoi | undefined)?.id : null,
|
||||
fallbackSelectedPoiId: (fallbackMarker?.userData.poi as RenderPoi | undefined)?.id || null
|
||||
})
|
||||
|
||||
if (hitMarker?.userData.poi) {
|
||||
const selectedPoi = hitMarker.userData.poi as RenderPoi
|
||||
@@ -4352,6 +4670,17 @@ const loadModelPackage = async () => {
|
||||
|
||||
setProgress(14, '正在读取楼层索引...')
|
||||
floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom)
|
||||
logThreeMapDiagnostic('package-ready', {
|
||||
initialView: props.initialView,
|
||||
requestedInitialFloorId: props.initialFloorId,
|
||||
overviewModelUrl: packageData.overviewModelUrl,
|
||||
floorCount: floorIndex.value.length,
|
||||
floorModels: floorIndex.value.map((floor) => ({
|
||||
floorId: floor.floorId,
|
||||
modelUrl: floor.modelUrl,
|
||||
sharedModelAsset: floor.sharedModelAsset
|
||||
}))
|
||||
})
|
||||
|
||||
const requestedInitialFloorId = resolveFloorIdFromRequest(props.initialFloorId)
|
||||
if (requestedInitialFloorId) {
|
||||
@@ -4380,11 +4709,17 @@ const init3DScene = async () => {
|
||||
try {
|
||||
disposeScene()
|
||||
isDisposed = false
|
||||
firstModelLoadStartedAt = getNow()
|
||||
firstModelVisibleReported = false
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
selectedPOI.value = null
|
||||
hasLoadedFloorViewOnce = false
|
||||
setProgress(0, '正在初始化三维场景...')
|
||||
logThreeMapDiagnostic('init-start', {
|
||||
initialView: props.initialView,
|
||||
initialFloorId: props.initialFloorId
|
||||
})
|
||||
|
||||
await initThree()
|
||||
await loadModelPackage()
|
||||
@@ -4780,7 +5115,8 @@ onUnmounted(() => {
|
||||
border: 1px solid rgba(31, 35, 41, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(31, 35, 41, 0.16);
|
||||
z-index: 14;
|
||||
z-index: 60;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.poi-content {
|
||||
|
||||
@@ -31,8 +31,8 @@ import type {
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
|
||||
const defaultCategory: MuseumCategory = {
|
||||
id: 'operation_experience',
|
||||
label: '导览点位',
|
||||
id: 'poi',
|
||||
label: '点位',
|
||||
iconType: 'poi'
|
||||
}
|
||||
|
||||
@@ -55,9 +55,24 @@ const hallEntranceCategory: MuseumCategory = {
|
||||
}
|
||||
|
||||
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
||||
toilet: {
|
||||
id: 'basic_service_facility',
|
||||
label: '卫生间',
|
||||
poi: {
|
||||
id: 'poi',
|
||||
label: '点位',
|
||||
iconType: 'poi'
|
||||
},
|
||||
device_terminal: {
|
||||
id: 'poi',
|
||||
label: '点位',
|
||||
iconType: 'poi'
|
||||
},
|
||||
operation_experience: {
|
||||
id: 'operation_experience',
|
||||
label: '导览点位',
|
||||
iconType: 'poi'
|
||||
},
|
||||
toilet: {
|
||||
id: 'basic_service_facility',
|
||||
label: '卫生间',
|
||||
iconType: 'toilet'
|
||||
},
|
||||
accessible_toilet: {
|
||||
@@ -412,6 +427,10 @@ const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolea
|
||||
}
|
||||
}
|
||||
|
||||
if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') {
|
||||
return categoryBySgsType[normalizedType || ''] || defaultCategory
|
||||
}
|
||||
|
||||
if (normalizedType && categoryBySgsType[normalizedType]) {
|
||||
return categoryBySgsType[normalizedType]
|
||||
}
|
||||
|
||||
@@ -168,24 +168,75 @@ const parseJsonPayload = <T>(payload: unknown): T => {
|
||||
return payload as T
|
||||
}
|
||||
|
||||
const getNow = () => (
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
)
|
||||
|
||||
const isStaticNavDiagnosticsEnabled = () => {
|
||||
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 logStaticNavDiagnostic = (
|
||||
event: 'request-json' | 'request-json-complete' | 'request-json-failed',
|
||||
payload: Record<string, unknown>
|
||||
) => {
|
||||
if (!isStaticNavDiagnosticsEnabled()) return
|
||||
console.debug(`[StaticNavAssetsProvider] ${event}`, payload)
|
||||
}
|
||||
|
||||
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
|
||||
const startedAt = getNow()
|
||||
logStaticNavDiagnostic('request-json', { url })
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
method: 'GET',
|
||||
success: (response) => {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
logStaticNavDiagnostic('request-json-failed', {
|
||||
url,
|
||||
statusCode,
|
||||
elapsedMs: Math.round(getNow() - startedAt)
|
||||
})
|
||||
reject(new Error(`导览资源读取失败: ${statusCode} ${url}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseJsonPayload<T>(response.data))
|
||||
const data = parseJsonPayload<T>(response.data)
|
||||
logStaticNavDiagnostic('request-json-complete', {
|
||||
url,
|
||||
statusCode,
|
||||
elapsedMs: Math.round(getNow() - startedAt)
|
||||
})
|
||||
resolve(data)
|
||||
} catch (error) {
|
||||
logStaticNavDiagnostic('request-json-failed', {
|
||||
url,
|
||||
statusCode,
|
||||
elapsedMs: Math.round(getNow() - startedAt),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
fail: (error) => {
|
||||
logStaticNavDiagnostic('request-json-failed', {
|
||||
url,
|
||||
elapsedMs: Math.round(getNow() - startedAt),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
|
||||
const SDK_FLOOR_BUNDLE_CONCURRENCY = 2
|
||||
|
||||
const getNow = () => (
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
)
|
||||
|
||||
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
@@ -77,6 +83,26 @@ const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
const countByValue = <T>(
|
||||
items: T[],
|
||||
selector: (item: T) => string | number | null | undefined
|
||||
) => items.reduce<Record<string, number>>((counts, item) => {
|
||||
const key = String(selector(item) || '<missing>')
|
||||
counts[key] = (counts[key] || 0) + 1
|
||||
return counts
|
||||
}, {})
|
||||
|
||||
const countDroppedRenderPoiCategories = (
|
||||
sourcePois: GuideRenderPoi[],
|
||||
keptPois: GuideRenderPoi[]
|
||||
) => {
|
||||
const keptIds = new Set(keptPois.map((poi) => poi.id))
|
||||
return countByValue(
|
||||
sourcePois.filter((poi) => !keptIds.has(poi.id)),
|
||||
(poi) => poi.primaryCategory
|
||||
)
|
||||
}
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
|
||||
|
||||
const resolveSgsAssetUrl = (url?: string | null) => {
|
||||
@@ -135,6 +161,37 @@ const warnSgsGuideModelDiagnostics = (
|
||||
console.warn(`[SGS guide model] ${message}`, payload)
|
||||
}
|
||||
|
||||
const stringifyDiagnosticPayload = (payload: Record<string, unknown>) => {
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
const logSgsGuideModelDiagnostics = (
|
||||
message: string,
|
||||
payload: Record<string, unknown>
|
||||
) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
if (typeof window !== 'undefined') {
|
||||
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: 'SGS guide model',
|
||||
event: message,
|
||||
payload
|
||||
})
|
||||
}
|
||||
console.debug(`[SGS guide model] ${message} ${stringifyDiagnosticPayload(payload)}`)
|
||||
}
|
||||
|
||||
const summarizeYValues = (pois: GuideRenderPoi[]) => {
|
||||
const values = pois
|
||||
.map((poi) => poi.positionGltf?.[1])
|
||||
@@ -228,9 +285,22 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
||||
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
|
||||
|
||||
async loadPackage(): Promise<GuideModelRenderPackage> {
|
||||
const startedAt = getNow()
|
||||
const manifest = await this.provider.getManifest()
|
||||
const allFloors = sortSgsFloors(manifest.floors)
|
||||
const floors = allFloors.filter(isIndoorNavigableFloor)
|
||||
const overviewFloor = allFloors.find((floor) => !isIndoorNavigableFloor(floor))
|
||||
const overviewBundleRequest = overviewFloor
|
||||
? this.provider.getFloorBundle(String(overviewFloor.floorId)).catch((error) => {
|
||||
warnSgsGuideModelDiagnostics('overview bundle request failed', {
|
||||
floorId: String(overviewFloor.floorId),
|
||||
floorCode: overviewFloor.floorCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return null
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
|
||||
this.floorsCache = floors
|
||||
|
||||
const bundleResults = await mapWithConcurrency(
|
||||
@@ -291,10 +361,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
||||
})
|
||||
.filter((asset): asset is GuideModelFloorAsset => Boolean(asset?.modelUrl))
|
||||
|
||||
const overviewFloor = allFloors.find((floor) => !isIndoorNavigableFloor(floor))
|
||||
const overviewBundle = overviewFloor
|
||||
? await this.provider.getFloorBundle(String(overviewFloor.floorId)).catch(() => null)
|
||||
: null
|
||||
const overviewBundle = await overviewBundleRequest
|
||||
const overviewModelUrls = uniqueModelUrls([
|
||||
overviewBundle?.model?.modelUrl,
|
||||
overviewBundle?.model?.fallbackModelUrl,
|
||||
@@ -306,6 +373,15 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
||||
...asset,
|
||||
sharedModelAsset: isSharedSgsModelAsset(asset.modelUrl, overviewModelUrl, floorModelUrls)
|
||||
}))
|
||||
logSgsGuideModelDiagnostics('render package ready', {
|
||||
elapsedMs: Math.round(getNow() - startedAt),
|
||||
mapId: manifest.mapId,
|
||||
mode: dataSourceConfig.mode,
|
||||
overviewFloorId: overviewFloor ? String(overviewFloor.floorId) : '',
|
||||
overviewModelUrl,
|
||||
floorCount: floorAssets.length,
|
||||
floorBundleCount: bundleResults.filter((result) => Boolean(result.bundle)).length
|
||||
})
|
||||
|
||||
return {
|
||||
overviewModelUrl,
|
||||
@@ -377,14 +453,28 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
||||
}
|
||||
|
||||
const hallPois = museumHallPois.map(toSgsRenderPoi)
|
||||
const renderPois = dedupeRenderPoisById([
|
||||
const adaptedPois = dedupeRenderPoisById([
|
||||
...hallPois,
|
||||
...ordinaryPois
|
||||
])
|
||||
.filter((poi) => poi.floorId === resolvedFloorId)
|
||||
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
|
||||
const renderPois = floorMatchedPois
|
||||
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
|
||||
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
|
||||
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
|
||||
floorId: resolvedFloorId,
|
||||
floorCode: matchedFloor.floorCode,
|
||||
rawPoiCount: pois.length,
|
||||
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
|
||||
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
|
||||
adaptedPoiCount: adaptedPois.length,
|
||||
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
|
||||
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
||||
renderPoiCount: renderPois.length,
|
||||
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
||||
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
|
||||
})
|
||||
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
|
||||
floorId: resolvedFloorId,
|
||||
floorCode: matchedFloor.floorCode,
|
||||
|
||||
@@ -31,6 +31,15 @@ interface CachedModelEntry {
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
type GuideModelDiagnosticEvent =
|
||||
| 'cache-hit'
|
||||
| 'cache-miss'
|
||||
| 'in-flight-hit'
|
||||
| 'load-start'
|
||||
| 'load-complete'
|
||||
| 'load-failed'
|
||||
| 'preload-skip'
|
||||
|
||||
// 当前 SDK 数据包含 8 个可导览楼层;缓存需覆盖多层展开、全楼模型和相邻预加载余量,避免加载中途淘汰刚加载过的单层模型。
|
||||
const defaultMaxModelSourceEntries = 10
|
||||
|
||||
@@ -53,6 +62,30 @@ const emptyResources = (): GuideModelResourceSet => ({
|
||||
textures: new Set()
|
||||
})
|
||||
|
||||
const getNow = () => (
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
)
|
||||
|
||||
const isModelDiagnosticsEnabled = () => {
|
||||
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 logModelDiagnostic = (
|
||||
event: GuideModelDiagnosticEvent,
|
||||
payload: Record<string, unknown>
|
||||
) => {
|
||||
if (!isModelDiagnosticsEnabled()) return
|
||||
console.debug(`[GuideModelLoadManager] ${event}`, payload)
|
||||
}
|
||||
|
||||
const collectMaterialTextures = (material: THREE.Material) => {
|
||||
const textures = new Set<THREE.Texture>()
|
||||
const materialRecord = material as unknown as Record<string, unknown>
|
||||
@@ -295,7 +328,16 @@ export class GuideModelLoadManager {
|
||||
const candidates = options.urls.map((url) => url.trim()).filter(Boolean)
|
||||
for (const url of candidates) {
|
||||
const key = this.createCacheKey(url, options.semanticKey)
|
||||
if (!key || this.sourceCache.has(key) || this.inFlightCache.has(key)) return
|
||||
if (!key || this.sourceCache.has(key) || this.inFlightCache.has(key)) {
|
||||
logModelDiagnostic('preload-skip', {
|
||||
label: options.label,
|
||||
url,
|
||||
key,
|
||||
reason: !key ? 'empty-key' : this.sourceCache.has(key) ? 'cache-hit' : 'in-flight-hit',
|
||||
diagnostics: this.getDiagnostics()
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.loadSource(url, options)
|
||||
return
|
||||
}
|
||||
@@ -315,23 +357,60 @@ export class GuideModelLoadManager {
|
||||
if (cached) {
|
||||
cached.lastUsedAt = Date.now()
|
||||
this.diagnostics.hits += 1
|
||||
logModelDiagnostic('cache-hit', {
|
||||
label: options.label,
|
||||
url,
|
||||
key,
|
||||
diagnostics: this.getDiagnostics()
|
||||
})
|
||||
return cached
|
||||
}
|
||||
|
||||
this.diagnostics.misses += 1
|
||||
logModelDiagnostic('cache-miss', {
|
||||
label: options.label,
|
||||
url,
|
||||
key,
|
||||
diagnostics: this.getDiagnostics()
|
||||
})
|
||||
|
||||
let inFlight = this.inFlightCache.get(key)
|
||||
const startedAt = getNow()
|
||||
if (inFlight) {
|
||||
this.diagnostics.inFlightHits += 1
|
||||
logModelDiagnostic('in-flight-hit', {
|
||||
label: options.label,
|
||||
url,
|
||||
key,
|
||||
diagnostics: this.getDiagnostics()
|
||||
})
|
||||
} else {
|
||||
logModelDiagnostic('load-start', {
|
||||
label: options.label,
|
||||
url,
|
||||
key
|
||||
})
|
||||
inFlight = options.loadSource(url)
|
||||
this.inFlightCache.set(key, inFlight)
|
||||
}
|
||||
|
||||
const gltf = await inFlight.finally(() => {
|
||||
let gltf: GLTF
|
||||
try {
|
||||
gltf = await inFlight
|
||||
} catch (error) {
|
||||
logModelDiagnostic('load-failed', {
|
||||
label: options.label,
|
||||
url,
|
||||
key,
|
||||
elapsedMs: Math.round(getNow() - startedAt),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
if (this.inFlightCache.get(key) === inFlight) {
|
||||
this.inFlightCache.delete(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const entry: CachedModelEntry = {
|
||||
key,
|
||||
@@ -341,6 +420,13 @@ export class GuideModelLoadManager {
|
||||
}
|
||||
this.sourceCache.set(key, entry)
|
||||
this.trim()
|
||||
logModelDiagnostic('load-complete', {
|
||||
label: options.label,
|
||||
url,
|
||||
key,
|
||||
elapsedMs: Math.round(getNow() - startedAt),
|
||||
diagnostics: this.getDiagnostics()
|
||||
})
|
||||
return entry
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user