diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index e09470e..f80019a 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -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 } interface PoiYDistribution { @@ -374,6 +381,8 @@ let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | 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 +) => { + if (typeof window === 'undefined') return + + const diagnosticsWindow = window as unknown as { + __GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{ + source: string + event: string + payload: Record + }> + } + diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= [] + diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({ + source: 'ThreeMap', + event, + payload + }) +} + +const stringifyDiagnosticPayload = (payload: Record) => { + try { + return JSON.stringify(payload) + } catch { + return '[unserializable]' + } +} + +const logThreeMapDiagnostic = ( + event: ThreeMapDiagnosticEvent, + payload: Record = {} +) => { + 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 = { 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 = { + 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>((counts, poi) => { + const key = poi.primaryCategory || '' + 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 = {} +) => { + 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() 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 = { + 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 { diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts index fcc6fe7..fda5490 100644 --- a/src/data/adapters/sgsSdkGuideAdapter.ts +++ b/src/data/adapters/sgsSdkGuideAdapter.ts @@ -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 = { - 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] } diff --git a/src/data/providers/staticNavAssetsProvider.ts b/src/data/providers/staticNavAssetsProvider.ts index d77a1b9..1dbee25 100644 --- a/src/data/providers/staticNavAssetsProvider.ts +++ b/src/data/providers/staticNavAssetsProvider.ts @@ -168,24 +168,75 @@ const parseJsonPayload = (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 +) => { + if (!isStaticNavDiagnosticsEnabled()) return + console.debug(`[StaticNavAssetsProvider] ${event}`, payload) +} + const requestJson = (url: string): Promise => 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(response.data)) + const data = parseJsonPayload(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) + } }) }) diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index 0149846..249e4d7 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -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 = ( + items: T[], + selector: (item: T) => string | number | null | undefined +) => items.reduce>((counts, item) => { + const key = String(selector(item) || '') + 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) => { + try { + return JSON.stringify(payload) + } catch { + return '[unserializable]' + } +} + +const logSgsGuideModelDiagnostics = ( + message: string, + payload: Record +) => { + 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 + }> + } + 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 { + 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, diff --git a/src/services/model/GuideModelLoadManager.ts b/src/services/model/GuideModelLoadManager.ts index 1770868..597c7d2 100644 --- a/src/services/model/GuideModelLoadManager.ts +++ b/src/services/model/GuideModelLoadManager.ts @@ -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 +) => { + if (!isModelDiagnosticsEnabled()) return + console.debug(`[GuideModelLoadManager] ${event}`, payload) +} + const collectMaterialTextures = (material: THREE.Material) => { const textures = new Set() const materialRecord = material as unknown as Record @@ -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 }