修复馆内三维点位显示异常
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-04 23:42:05 +08:00
parent f03783201d
commit 39f1fc1198
5 changed files with 629 additions and 47 deletions

View File

@@ -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 {

View File

@@ -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]
}

View File

@@ -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)
}
})
})

View File

@@ -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,

View File

@@ -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
}