From 9b1f855515ad7b052773dafe8e869e9f47f5df0b Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Thu, 2 Jul 2026 16:42:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=82=B9=E4=BD=8D=E6=A5=BC?= =?UTF-8?q?=E5=B1=82=E8=B7=B3=E8=BD=AC=E5=B9=B6=E4=BC=98=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8A=A0=E8=BD=BD=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...l-cache-network-verification-2026-07-02.md | 76 ++++ src/components/map/ThreeMap.vue | 192 ++++++++- src/domain/guideModel.ts | 2 + src/pages/facility/detail.vue | 54 ++- src/pages/search/index.vue | 13 +- src/repositories/GuideModelRepository.ts | 61 ++- src/services/model/GuideModelLoadManager.ts | 373 ++++++++++++++++++ src/utils/concurrency.ts | 20 + 8 files changed, 750 insertions(+), 41 deletions(-) create mode 100644 docs/QA/h5-model-cache-network-verification-2026-07-02.md create mode 100644 src/services/model/GuideModelLoadManager.ts create mode 100644 src/utils/concurrency.ts diff --git a/docs/QA/h5-model-cache-network-verification-2026-07-02.md b/docs/QA/h5-model-cache-network-verification-2026-07-02.md new file mode 100644 index 0000000..2823076 --- /dev/null +++ b/docs/QA/h5-model-cache-network-verification-2026-07-02.md @@ -0,0 +1,76 @@ +# H5 模型加载缓存 Network 验证记录 + +日期:2026-07-02 + +## 验证范围 + +- 项目:`frontend-miniapp` +- 数据源模式:`VITE_DATA_SOURCE_MODE=sdk` +- 渲染方式:H5 现有 ThreeMap 本地渲染 +- 验证工具:Playwright + 本机 Chrome Network 请求监听 +- 启动命令:`pnpm dev:h5` +- 访问地址:`http://localhost:5174/`(`5173` 被占用后由 Vite 自动切换) + +本次验证未启用 SGS SDK iframe 渲染,SDK 仍只作为数据/API 来源。 + +## 操作路径 + +1. 首次进入馆内 3D,加载默认全楼/外观模型。 +2. 点击“馆内”进入默认 1F 单楼层模型。 +3. 从单楼层切换到多楼层。 +4. 再切回 1F 单楼层。 +5. 打开搜索页,选择“服务台”结果,进入设施详情并触发点位 focus。 +6. 离开到“讲解”tab,再回到“馆内”地图。 + +## 问题复现 + +在 `GuideModelLoadManager` 默认 `maxEntries=4` 时,单层进入后再切换多层,已加载过的模型会被 LRU 提前淘汰,导致同一 GLB 再次请求: + +| 模型 | 切多层前请求次数 | 切多层后请求次数 | +| --- | ---: | ---: | +| `L1_draco.glb` | 1 | 2 | +| `L1.5_draco.glb` | 1 | 2 | +| `L-1_draco.glb` | 1 | 2 | + +根因分类:LRU source cache 容量过小。当前 SDK 数据包含 8 个可导览楼层,多层加载会顺序触达多个 GLB;`maxEntries=4` 无法覆盖单层、相邻预加载、多层展开和全楼模型的运行期复用需求。 + +## 修复 + +已在 `src/services/model/GuideModelLoadManager.ts` 将默认 source cache 容量调整为 `10`,用于覆盖: + +- 当前 8 个可导览楼层模型; +- 全楼/外观模型; +- 相邻楼层预加载余量。 + +`ThreeMap.vue` 普通 `onUnmounted` / `disposeScene` 未调用 `guideModelLoadManager.clearAll()`,运行期组件重建不会清空全局 source cache。 + +## 修复后 Network 统计 + +修复后重新执行同一路径,关键 GLB 请求次数如下: + +| 模型 URL | 进入单层后 | 切多层后 | 切回单层后 | 点位/详情 focus 后 | 讲解 tab 返回馆内后 | +| --- | ---: | ---: | ---: | ---: | ---: | +| `/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb` | 1 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L1_draco.glb` | 1 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L1.5_draco.glb` | 1 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L-1_draco.glb` | 1 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L5_draco.glb` | 0 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L4_draco.glb` | 0 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L3_draco.glb` | 0 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L2_draco.glb` | 0 | 1 | 1 | 1 | 1 | +| `/app-api/gis/sdk/minio/museum-assets/sgs-map/glb/1/V20260627093736_e73e3/models/L-2_draco.glb` | 0 | 1 | 1 | 1 | 1 | + +结论:同一个实际 GLB 在运行期全局缓存有效范围内不再重复发起网络请求。 + +## 质量验证 + +- `pnpm type-check`:通过 +- `pnpm lint`:通过 +- `pnpm build:h5`:通过,仅有既有 Sass deprecation warnings + +## 剩余风险 + +- 运行期内存缓存无法跨页面刷新;刷新页面后重新请求 GLB 属于预期行为。 +- `maxEntries=10` 会比 `4` 保留更多 GLB source,移动端内存需在真机上继续观察。 +- 如果 `/app-api/gis/sdk/minio/...` 或后端 `/gis/sdk/minio` 偶发 500,仍需继续排查 `smart-navigation-system` 的 MinIO 代理层。 +- 本轮未新增 Service Worker / Cache-Control 持久缓存策略;如需跨刷新复用,应作为后续专项处理。 diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 9f332f8..e5682d0 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -65,6 +65,10 @@ import type { import { compareFloorsTopToBottom } from '@/domain/guideFloor' +import { + guideModelLoadManager, + type GuideModelResourceSet +} from '@/services/model/GuideModelLoadManager' type ViewMode = 'overview' | 'floor' | 'multi' type CameraPreset = 'top' | 'oblique' @@ -327,6 +331,7 @@ let overviewWheelAutoSwitchTimer: ReturnType | null = null let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | null = null let cameraTween: CameraTweenState | null = null +let adjacentPreloadSeq = 0 const cameraTweenEnabled = true const cameraTweenDurationMs = 480 @@ -351,6 +356,7 @@ const floorExitZoomOutIntentMinDeltaRatio = 0.015 const floorExitAccumulatedZoomOutRatio = 0.26 const floorExitCandidateHoldMs = 900 const modelLoadRetryDelaysMs = [300, 900] +const adjacentPreloadDelayMs = 900 const syncControlInteractionOptions = () => { if (!controls) return @@ -1594,10 +1600,40 @@ const collectReusableModelResources = (object: THREE.Object3D | null): ReusableM return { geometries, materials, textures } } +const mergeReusableResources = (...resources: ReusableModelResources[]) => { + const merged: ReusableModelResources = { + geometries: new Set(), + materials: new Set(), + textures: new Set() + } + + resources.forEach((resource) => { + resource.geometries.forEach((geometry) => merged.geometries.add(geometry)) + resource.materials.forEach((material) => merged.materials.add(material)) + resource.textures.forEach((texture) => merged.textures.add(texture)) + }) + + return merged +} + +const toReusableModelResources = (resources: GuideModelResourceSet): ReusableModelResources => ({ + geometries: resources.geometries, + materials: resources.materials, + textures: resources.textures +}) + +const collectCachedModelResources = () => { + return toReusableModelResources(guideModelLoadManager.collectProtectedResources()) +} + const disposeObject = ( object: THREE.Object3D, - protectedResources = collectReusableModelResources(null) + protectedResources = collectReusableModelResources(null), + protectCachedModelResources = true ) => { + const effectiveProtectedResources = protectCachedModelResources + ? mergeReusableResources(protectedResources, collectCachedModelResources()) + : protectedResources const disposedGeometries = new Set() const disposedMaterials = new Set() const disposedTextures = new Set() @@ -1606,7 +1642,7 @@ const disposeObject = ( if (child instanceof THREE.Mesh) { if ( child.geometry - && !protectedResources.geometries.has(child.geometry) + && !effectiveProtectedResources.geometries.has(child.geometry) && !disposedGeometries.has(child.geometry) ) { child.geometry.dispose() @@ -1614,8 +1650,8 @@ const disposeObject = ( } const materials = Array.isArray(child.material) ? child.material : [child.material] materials.forEach((material) => { - if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) { - disposeMaterialTextures(material, protectedResources, disposedTextures) + if (material && !effectiveProtectedResources.materials.has(material) && !disposedMaterials.has(material)) { + disposeMaterialTextures(material, effectiveProtectedResources, disposedTextures) material.dispose() disposedMaterials.add(material) } @@ -1623,8 +1659,8 @@ const disposeObject = ( } if (child instanceof THREE.Sprite) { - if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) { - disposeMaterialTextures(child.material, protectedResources, disposedTextures) + if (!effectiveProtectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) { + disposeMaterialTextures(child.material, effectiveProtectedResources, disposedTextures) child.material.dispose() disposedMaterials.add(child.material) } @@ -1633,7 +1669,7 @@ const disposeObject = ( if (child instanceof THREE.Line) { if ( child.geometry - && !protectedResources.geometries.has(child.geometry) + && !effectiveProtectedResources.geometries.has(child.geometry) && !disposedGeometries.has(child.geometry) ) { child.geometry.dispose() @@ -1641,8 +1677,8 @@ const disposeObject = ( } const materials = Array.isArray(child.material) ? child.material : [child.material] materials.forEach((material) => { - if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) { - disposeMaterialTextures(material, protectedResources, disposedTextures) + if (material && !effectiveProtectedResources.materials.has(material) && !disposedMaterials.has(material)) { + disposeMaterialTextures(material, effectiveProtectedResources, disposedTextures) material.dispose() disposedMaterials.add(material) } @@ -2170,6 +2206,34 @@ const wait = (delayMs: number) => new Promise((resolve) => { window.setTimeout(resolve, delayMs) }) +const getErrorStatus = (error: unknown) => { + const candidate = error as { + status?: unknown + target?: { status?: unknown } + currentTarget?: { status?: unknown } + } | null + const status = candidate?.status ?? candidate?.target?.status ?? candidate?.currentTarget?.status + return typeof status === 'number' && Number.isFinite(status) ? status : undefined +} + +const formatModelLoadError = (error: unknown, url: string) => { + if (!url.trim()) return '模型 URL 缺失' + + const status = getErrorStatus(error) + if (status === 500) return '模型代理返回 500,请检查 /gis/sdk/minio 或 MinIO 代理层' + if (status === 404) return '模型资源不存在或代理返回 404' + if (status && status >= 400) return `模型资源请求失败(HTTP ${status})` + + const rawMessage = error instanceof Error ? error.message : String(error || '') + const message = rawMessage.toLowerCase() + if (message.includes('timeout')) return '模型资源请求超时' + if (message.includes('parse') || message.includes('json') || message.includes('gltf')) { + return '模型资源解析失败' + } + + return rawMessage || '模型资源加载失败' +} + const loadModelOnce = ( url: string, label: string, @@ -2198,13 +2262,18 @@ const loadModelOnce = ( ) }) -const loadModel = async ( +const loadModelFromNetwork = async ( url: string, label: string, loadToken?: number, options: LoadModelOptions = {} ) => { let lastError: unknown = null + const normalizedUrl = url.trim() + + if (!normalizedUrl) { + throw new Error('模型 URL 缺失') + } for (let attempt = 0; attempt <= modelLoadRetryDelaysMs.length; attempt += 1) { if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) { @@ -2212,7 +2281,7 @@ const loadModel = async ( } try { - return await loadModelOnce(url, label, loadToken, options) + return await loadModelOnce(normalizedUrl, label, loadToken, options) } catch (error) { if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) { throw createStaleModelLoadError() @@ -2230,7 +2299,33 @@ const loadModel = async ( } } - throw lastError instanceof Error ? lastError : new Error('模型资源加载失败') + throw new Error(formatModelLoadError(lastError, normalizedUrl)) +} + +const loadModelWithFallback = async ( + urls: string[], + label: string, + loadToken?: number, + options: LoadModelOptions = {}, + semanticKey?: string +) => { + const candidates = urls.map((url) => url.trim()).filter(Boolean) + if (!candidates.length) throw new Error('模型 URL 缺失') + + const gltf = await guideModelLoadManager.load({ + urls: candidates, + label, + semanticKey, + loadSource: (url) => loadModelFromNetwork(url, label, loadToken, options), + shouldStopOnError: isStaleModelLoadError + }) + + if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) { + disposeObject(gltf.scene) + throw createStaleModelLoadError() + } + + return gltf } const prepareModel = (model: THREE.Object3D) => { @@ -2295,6 +2390,67 @@ const canLoadFloorSilently = (floorId: string) => { return Boolean(floor && canAttachCachedSharedModel(floor.modelUrl)) } +const getFloorModelUrls = (floor: FloorIndexItem) => ( + floor.modelUrls?.length ? floor.modelUrls : [floor.modelUrl] +) + +const getOverviewModelUrls = (packageData: GuideModelRenderPackage) => ( + packageData.overviewModelUrls?.length ? packageData.overviewModelUrls : [packageData.overviewModelUrl] +) + +const preloadModelUrls = async (urls: string[], label: string, preloadSeq: number) => { + const candidates = urls.map((url) => url.trim()).filter(Boolean) + if (!candidates.length || preloadSeq !== adjacentPreloadSeq || isDisposed) return + + try { + await guideModelLoadManager.preload({ + urls: candidates, + label, + loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true }) + }) + } catch (error) { + console.warn('[ThreeMap] 相邻楼层模型预加载失败:', { + urls: candidates, + label, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +const getAdjacentFloors = (floorId: string) => { + const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom) + const currentIndex = orderedFloors.findIndex((floor) => floor.floorId === floorId) + if (currentIndex < 0) return [] + + return [ + orderedFloors[currentIndex - 1], + orderedFloors[currentIndex + 1] + ].filter((floor): floor is FloorIndexItem => Boolean(floor?.modelUrl)) +} + +const scheduleAdjacentFloorPreload = (floorId: string) => { + const adjacentFloors = getAdjacentFloors(floorId) + if (!adjacentFloors.length) return + + const preloadSeq = adjacentPreloadSeq + 1 + adjacentPreloadSeq = preloadSeq + + window.setTimeout(() => { + if (preloadSeq !== adjacentPreloadSeq || isDisposed) return + + void (async () => { + for (const floor of adjacentFloors) { + if (preloadSeq !== adjacentPreloadSeq || isDisposed) return + await preloadModelUrls( + getFloorModelUrls(floor), + `预加载 ${formatFloorLabel(floor.floorId)} 模型`, + preloadSeq + ) + } + })() + }, adjacentPreloadDelayMs) +} + const getFloorMatchKeys = (floor: FloorIndexItem) => ( [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])] .filter((value): value is string => Boolean(value)) @@ -2894,7 +3050,7 @@ const loadOverview = async () => { setProgress(18, '正在加载建筑外观模型...') - const gltf = await loadModel(packageData.overviewModelUrl, '正在加载建筑外观模型', loadToken) + const gltf = await loadModelWithFallback(getOverviewModelUrls(packageData), '正在加载建筑外观模型', loadToken) assertCurrentModelLoad(loadToken, gltf.scene) const targetScene = scene @@ -2946,8 +3102,8 @@ const prepareFloorScene = async ( setProgress(18, `正在加载 ${formatFloorLabel(requestedFloorId)} 模型...`) } - const gltf = await loadModel( - floor.modelUrl, + const gltf = await loadModelWithFallback( + getFloorModelUrls(floor), `正在加载 ${formatFloorLabel(requestedFloorId)} 模型`, loadToken, { suppressProgress: options.suppressProgress } @@ -3046,6 +3202,7 @@ const commitPreparedFloorScene = ( markFloorAutoSwitchEntry() refreshPoiVisibilityByDistance() renderRoutePreview() + scheduleAdjacentFloorPreload(expectedFloorId) } const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { @@ -3106,7 +3263,7 @@ const loadMultiFloor = async () => { let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null if (!sourceModel) { - const gltf = await loadModel(sharedModelUrl, '正在加载多层共享模型', loadToken) + const gltf = await loadModelWithFallback([sharedModelUrl], '正在加载多层共享模型', loadToken) assertCurrentModelLoad(loadToken, gltf.scene) const targetScene = scene @@ -3178,7 +3335,7 @@ const loadMultiFloor = async () => { const floor = orderedFloors[index] const label = formatFloorLabel(floor.floorId) - const gltf = await loadModel(floor.modelUrl, `正在加载 ${label} 多层模型`, loadToken) + const gltf = await loadModelWithFallback(getFloorModelUrls(floor), `正在加载 ${label} 多层模型`, loadToken) if (!isCurrentModelLoad(loadToken)) { disposeObject(gltf.scene) disposeObject(group) @@ -4259,6 +4416,7 @@ const retryLoad = () => { const disposeScene = () => { isDisposed = true + adjacentPreloadSeq += 1 invalidateModelLoads() if (animationId) { diff --git a/src/domain/guideModel.ts b/src/domain/guideModel.ts index 18f3b99..5ce2753 100644 --- a/src/domain/guideModel.ts +++ b/src/domain/guideModel.ts @@ -23,12 +23,14 @@ export interface GuideModelFloorAsset { label: string order: number modelUrl: string + modelUrls?: string[] sharedModelAsset?: boolean modelMatchKeys?: string[] } export interface GuideModelRenderPackage { overviewModelUrl: string + overviewModelUrls?: string[] floors: GuideModelFloorAsset[] } diff --git a/src/pages/facility/detail.vue b/src/pages/facility/detail.vue index d5bd927..f1dc7e2 100644 --- a/src/pages/facility/detail.vue +++ b/src/pages/facility/detail.vue @@ -104,6 +104,7 @@ const normalizeGuideFloorId = (labelOrId: string) => || guideUseCase.normalizeFloorId(labelOrId) const activeMode = ref<'2d' | '3d'>('3d') const activeFloor = ref('1F') +const requestedTargetFloorId = ref('') const targetFocusRequest = ref(null) const targetFocusRequestId = ref(0) const isDetailPanelVisible = ref(true) @@ -200,6 +201,14 @@ const normalizePoiName = (value: string) => value .replace(/[\s()()【】[\]_-]/g, '') .toLowerCase() +const decodeRouteText = (value: unknown) => ( + typeof value === 'string' ? decodeURIComponent(value) : '' +) + +const isPoiOnRequestedFloor = (poi: Pick) => ( + !requestedTargetFloorId.value || poi.floorId === requestedTargetFloorId.value +) + const resolvePoiFromHall = async () => { if (!facility.value.id) return null @@ -221,14 +230,19 @@ const resolveFacilityPoi = async () => { if (!normalizedTarget) return null const candidates = await guideUseCase.searchPois(facility.value.name) - return candidates.find((candidate) => { + const floorMatchedCandidates = requestedTargetFloorId.value + ? candidates.filter(isPoiOnRequestedFloor) + : candidates + const candidatesToMatch = floorMatchedCandidates.length ? floorMatchedCandidates : candidates + + return candidatesToMatch.find((candidate) => { const candidateName = normalizePoiName(candidate.name) const candidateHallName = normalizePoiName(candidate.hallName || '') return candidateName === normalizedTarget || candidateHallName === normalizedTarget || candidateName.includes(normalizedTarget) || normalizedTarget.includes(candidateName) - }) || candidates[0] || null + }) || candidatesToMatch[0] || null } const resolveRenderPoiByName = async () => { @@ -236,7 +250,11 @@ const resolveRenderPoiByName = async () => { if (!normalizedTarget) return null const modelPackage = await indoorModelSource.loadPackage() - for (const floor of modelPackage.floors) { + const floorsToSearch = requestedTargetFloorId.value + ? modelPackage.floors.filter((floor) => floor.floorId === requestedTargetFloorId.value) + : modelPackage.floors + + for (const floor of floorsToSearch) { const floorId = floor.floorId const pois = await indoorModelSource.loadFloorPois(floorId).catch(() => []) const matchedPoi = pois.find((poi) => { @@ -279,11 +297,24 @@ onLoad(async (options: any) => { await loadGuideFloors() if (options.id) { - facility.value.id = options.id + facility.value.id = decodeRouteText(options.id) } if (options.target) { - facility.value.name = decodeURIComponent(options.target) + facility.value.name = decodeRouteText(options.target) + } + + if (options.floorId) { + requestedTargetFloorId.value = normalizeGuideFloorId(decodeRouteText(options.floorId)) + if (requestedTargetFloorId.value) { + activeFloor.value = requestedTargetFloorId.value + } + } + + if (options.floorLabel && requestedTargetFloorId.value) { + const floorLabel = decodeRouteText(options.floorLabel) + facility.value.location = `${floorLabel} · 位置预览` + facility.value.tags = [floorLabel, '三维位置', '位置预览'] } if (!facility.value.id && !normalizePoiName(facility.value.name)) return @@ -291,13 +322,6 @@ onLoad(async (options: any) => { try { const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message - // 详情页以位置预览为主,优先使用模型点位,避免旧内容兜底覆盖真实三维点位。 - const renderPoi = await tryResolveRenderPoiByName() - if (renderPoi) { - applyRenderPoiToFacility(renderPoi, routeReadinessMessage) - return - } - const poi = await tryResolveFacilityPoi() if (poi) { facility.value = toFacilityDetailViewModel( @@ -305,6 +329,12 @@ onLoad(async (options: any) => { routeReadinessMessage ) focusFacilityPoi(poi) + return + } + + const renderPoi = await tryResolveRenderPoiByName() + if (renderPoi) { + applyRenderPoiToFacility(renderPoi, routeReadinessMessage) } } catch (error) { console.error('加载设施位置详情失败:', error) diff --git a/src/pages/search/index.vue b/src/pages/search/index.vue index c01cf79..e00756e 100644 --- a/src/pages/search/index.vue +++ b/src/pages/search/index.vue @@ -296,16 +296,25 @@ const handleFloorChange = (floor: string) => { activeFloor.value = floor } +const encodeQueryValue = (value: string) => encodeURIComponent(value) + +const createPoiLocationQuery = (poi: MuseumPoi) => ( + `id=${encodeQueryValue(poi.id)}` + + `&target=${encodeQueryValue(poi.name)}` + + `&floorId=${encodeQueryValue(poi.floorId)}` + + `&floorLabel=${encodeQueryValue(poi.floorLabel)}` +) + const handleResultTap = (poi: MuseumPoi) => { if (poi.primaryCategory.id === 'operation_experience') { uni.navigateTo({ - url: `/pages/route/detail?facilityId=${poi.id}&target=${encodeURIComponent(poi.name)}&state=preview` + url: `/pages/route/detail?facilityId=${encodeQueryValue(poi.id)}&target=${encodeQueryValue(poi.name)}&floorId=${encodeQueryValue(poi.floorId)}&state=preview` }) return } uni.navigateTo({ - url: `/pages/facility/detail?id=${poi.id}&target=${encodeURIComponent(poi.name)}` + url: `/pages/facility/detail?${createPoiLocationQuery(poi)}` }) } diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index a224b98..0149846 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -32,6 +32,11 @@ import { type StaticNavAssetsProvider, type StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider' +import { + mapWithConcurrency +} from '@/utils/concurrency' + +const SDK_FLOOR_BUNDLE_CONCURRENCY = 2 const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({ id: poi.id, @@ -105,6 +110,10 @@ const resolveSgsAssetUrl = (url?: string | null) => { return origin ? `${origin}${normalizedUrl}` : normalizedUrl } +const uniqueModelUrls = (urls: Array) => Array.from(new Set( + urls.map(resolveSgsAssetUrl).filter(Boolean) +)) + const sortSgsFloors = (floors: SgsSdkFloorSummaryPayload[]) => [...floors] .sort(compareFloorsTopToBottom) @@ -224,13 +233,36 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { const floors = allFloors.filter(isIndoorNavigableFloor) this.floorsCache = floors - const bundles = await Promise.all( - floors.map((floor) => this.provider.getFloorBundle(String(floor.floorId))) + const bundleResults = await mapWithConcurrency( + floors, + SDK_FLOOR_BUNDLE_CONCURRENCY, + async (floor) => { + try { + return { + floor, + bundle: await this.provider.getFloorBundle(String(floor.floorId)), + error: null + } + } catch (error) { + warnSgsGuideModelDiagnostics('floor bundle request failed', { + floorId: String(floor.floorId), + floorCode: floor.floorCode, + error: error instanceof Error ? error.message : String(error) + }) + return { + floor, + bundle: null, + error + } + } + } ) - const floorModelItems = floors - .map((floor, index) => { - const bundle = bundles[index] + const floorModelItems: GuideModelFloorAsset[] = bundleResults + .map((result): GuideModelFloorAsset | null => { + const { floor, bundle } = result + if (!bundle) return null + const bundleFloor = bundle.floor if (bundleFloor && String(bundleFloor.floorId) !== String(floor.floorId)) { warnSgsGuideModelDiagnostics('floor bundle metadata does not match requested floor', { @@ -241,7 +273,11 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { }) } - const modelUrl = resolveSgsAssetUrl(bundle.model?.modelUrl || bundle.model?.fallbackModelUrl) + const modelUrls = uniqueModelUrls([ + bundle.model?.modelUrl, + bundle.model?.fallbackModelUrl + ]) + const modelUrl = modelUrls[0] || '' const label = formatSgsFloorLabel(floor.floorCode, floor.floorName) return { @@ -249,18 +285,22 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { label, order: Number(floor.sortOrder || 0), modelUrl, + modelUrls, modelMatchKeys: getSgsFloorModelMatchKeys(floor, label) } }) - .filter((asset) => asset.modelUrl) + .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 overviewModelUrl = resolveSgsAssetUrl( - overviewBundle?.model?.modelUrl || overviewBundle?.model?.fallbackModelUrl - ) || floorModelItems[0]?.modelUrl || '' + const overviewModelUrls = uniqueModelUrls([ + overviewBundle?.model?.modelUrl, + overviewBundle?.model?.fallbackModelUrl, + floorModelItems[0]?.modelUrl + ]) + const overviewModelUrl = overviewModelUrls[0] || '' const floorModelUrls = floorModelItems.map((asset) => asset.modelUrl) const floorAssets: GuideModelFloorAsset[] = floorModelItems.map((asset) => ({ ...asset, @@ -269,6 +309,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { return { overviewModelUrl, + overviewModelUrls, floors: floorAssets } } diff --git a/src/services/model/GuideModelLoadManager.ts b/src/services/model/GuideModelLoadManager.ts new file mode 100644 index 0000000..1770868 --- /dev/null +++ b/src/services/model/GuideModelLoadManager.ts @@ -0,0 +1,373 @@ +import * as THREE from 'three' +import type { GLTF } from 'three/addons/loaders/GLTFLoader.js' + +export interface GuideModelResourceSet { + geometries: Set + materials: Set + textures: Set +} + +export interface GuideModelLoadOptions { + urls: string[] + label: string + semanticKey?: string + loadSource: (url: string) => Promise + shouldStopOnError?: (error: unknown) => boolean +} + +export interface GuideModelLoadDiagnostics { + sourceEntries: number + inFlightEntries: number + hits: number + misses: number + inFlightHits: number + evictions: number +} + +interface CachedModelEntry { + key: string + url: string + gltf: GLTF + lastUsedAt: number +} + +// 当前 SDK 数据包含 8 个可导览楼层;缓存需覆盖多层展开、全楼模型和相邻预加载余量,避免加载中途淘汰刚加载过的单层模型。 +const defaultMaxModelSourceEntries = 10 + +const volatileQueryKeys = new Set([ + 'token', + 'access_token', + 'expires', + 'expires_at', + 'expiration', + 'timestamp', + 'ts', + 'time', + 't', + '_' +]) + +const emptyResources = (): GuideModelResourceSet => ({ + geometries: new Set(), + materials: new Set(), + textures: new Set() +}) + +const collectMaterialTextures = (material: THREE.Material) => { + const textures = new Set() + const materialRecord = material as unknown as Record + + Object.keys(materialRecord).forEach((key) => { + const value = materialRecord[key] + if (value instanceof THREE.Texture) { + textures.add(value) + } + }) + + return textures +} + +const isRenderableObject = ( + object: THREE.Object3D +): object is (THREE.Mesh | THREE.Line | THREE.Points) => ( + object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points +) + +const cloneMaterialWithTextures = (material: TMaterial): TMaterial => { + const clonedMaterial = material.clone() as TMaterial + const sourceRecord = material as unknown as Record + const cloneRecord = clonedMaterial as unknown as Record + + Object.keys(sourceRecord).forEach((key) => { + const value = sourceRecord[key] + if (value instanceof THREE.Texture) { + const clonedTexture = value.clone() + clonedTexture.needsUpdate = true + cloneRecord[key] = clonedTexture + } + }) + + return clonedMaterial +} + +const cloneObjectResources = (object: THREE.Object3D) => { + object.traverse((child) => { + if (isRenderableObject(child)) { + if (child.geometry) { + child.geometry = child.geometry.clone() + } + + if (Array.isArray(child.material)) { + child.material = child.material.map((material) => cloneMaterialWithTextures(material)) + } else if (child.material) { + child.material = cloneMaterialWithTextures(child.material) + } + } + + if (child instanceof THREE.Sprite && child.material) { + child.material = cloneMaterialWithTextures(child.material) + } + }) +} + +const collectResources = (object: THREE.Object3D | null): GuideModelResourceSet => { + const resources = emptyResources() + + object?.traverse((child) => { + if (isRenderableObject(child)) { + if (child.geometry) { + resources.geometries.add(child.geometry) + } + + const materials = Array.isArray(child.material) ? child.material : [child.material] + materials.forEach((material) => { + if (!material) return + resources.materials.add(material) + collectMaterialTextures(material).forEach((texture) => resources.textures.add(texture)) + }) + } + + if (child instanceof THREE.Sprite && child.material) { + resources.materials.add(child.material) + collectMaterialTextures(child.material).forEach((texture) => resources.textures.add(texture)) + } + }) + + return resources +} + +const disposeMaterialTextures = ( + material: THREE.Material, + protectedResources: GuideModelResourceSet, + disposedTextures: Set +) => { + collectMaterialTextures(material).forEach((texture) => { + if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) { + texture.dispose() + disposedTextures.add(texture) + } + }) +} + +const disposeObject = ( + object: THREE.Object3D, + protectedResources = emptyResources() +) => { + const disposedGeometries = new Set() + const disposedMaterials = new Set() + const disposedTextures = new Set() + + object.traverse((child) => { + if (isRenderableObject(child)) { + if ( + child.geometry + && !protectedResources.geometries.has(child.geometry) + && !disposedGeometries.has(child.geometry) + ) { + child.geometry.dispose() + disposedGeometries.add(child.geometry) + } + + const materials = Array.isArray(child.material) ? child.material : [child.material] + materials.forEach((material) => { + if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) { + disposeMaterialTextures(material, protectedResources, disposedTextures) + material.dispose() + disposedMaterials.add(material) + } + }) + } + + if (child instanceof THREE.Sprite && child.material) { + if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) { + disposeMaterialTextures(child.material, protectedResources, disposedTextures) + child.material.dispose() + disposedMaterials.add(child.material) + } + } + }) +} + +export class GuideModelLoadManager { + private readonly sourceCache = new Map() + private readonly inFlightCache = new Map>() + private readonly diagnostics: GuideModelLoadDiagnostics = { + sourceEntries: 0, + inFlightEntries: 0, + hits: 0, + misses: 0, + inFlightHits: 0, + evictions: 0 + } + + constructor(private readonly maxEntries = defaultMaxModelSourceEntries) {} + + getDiagnostics(): GuideModelLoadDiagnostics { + return { + ...this.diagnostics, + sourceEntries: this.sourceCache.size, + inFlightEntries: this.inFlightCache.size + } + } + + collectProtectedResources(): GuideModelResourceSet { + const resources = emptyResources() + + this.sourceCache.forEach((entry) => { + const entryResources = collectResources(entry.gltf.scene) + entryResources.geometries.forEach((geometry) => resources.geometries.add(geometry)) + entryResources.materials.forEach((material) => resources.materials.add(material)) + entryResources.textures.forEach((texture) => resources.textures.add(texture)) + }) + + return resources + } + + createCacheKey(url: string, semanticKey?: string) { + const normalizedSemanticKey = semanticKey?.trim() + if (normalizedSemanticKey) return `semantic:${normalizedSemanticKey}` + + const normalizedUrl = url.trim() + if (!normalizedUrl) return '' + + try { + const baseUrl = typeof window === 'undefined' ? 'http://localhost' : window.location.origin + const parsedUrl = new URL(normalizedUrl, baseUrl) + Array.from(parsedUrl.searchParams.keys()) + .filter((key) => volatileQueryKeys.has(key.toLowerCase())) + .forEach((key) => parsedUrl.searchParams.delete(key)) + + const sortedParams = [...parsedUrl.searchParams.entries()] + .sort(([leftKey, leftValue], [rightKey, rightValue]) => ( + leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue) + )) + parsedUrl.search = '' + sortedParams.forEach(([key, value]) => parsedUrl.searchParams.append(key, value)) + + return parsedUrl.href + } catch { + const [path, query = ''] = normalizedUrl.split('?') + if (!query) return path + + const filteredQuery = query + .split('&') + .map((part) => part.split('=')) + .filter(([key]) => !volatileQueryKeys.has(decodeURIComponent(key || '').toLowerCase())) + .sort(([leftKey, leftValue = ''], [rightKey, rightValue = '']) => ( + leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue) + )) + .map((parts) => parts.join('=')) + .join('&') + + return filteredQuery ? `${path}?${filteredQuery}` : path + } + } + + has(url: string, semanticKey?: string) { + const key = this.createCacheKey(url, semanticKey) + return Boolean(key && this.sourceCache.has(key)) + } + + isInFlight(url: string, semanticKey?: string) { + const key = this.createCacheKey(url, semanticKey) + return Boolean(key && this.inFlightCache.has(key)) + } + + async load(options: GuideModelLoadOptions): Promise { + const candidates = options.urls.map((url) => url.trim()).filter(Boolean) + if (!candidates.length) throw new Error('模型 URL 缺失') + + let lastError: unknown = null + for (const url of candidates) { + try { + const entry = await this.loadSource(url, options) + return this.clone(entry) + } catch (error) { + if (options.shouldStopOnError?.(error)) throw error + lastError = error + } + } + + throw lastError instanceof Error ? lastError : new Error('模型资源加载失败') + } + + async preload(options: GuideModelLoadOptions) { + 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 + await this.loadSource(url, options) + return + } + } + + clearAll() { + this.sourceCache.forEach((entry) => disposeObject(entry.gltf.scene)) + this.sourceCache.clear() + this.inFlightCache.clear() + } + + private async loadSource(url: string, options: GuideModelLoadOptions) { + const key = this.createCacheKey(url, options.semanticKey) + if (!key) throw new Error('模型 URL 缺失') + + const cached = this.sourceCache.get(key) + if (cached) { + cached.lastUsedAt = Date.now() + this.diagnostics.hits += 1 + return cached + } + + this.diagnostics.misses += 1 + let inFlight = this.inFlightCache.get(key) + if (inFlight) { + this.diagnostics.inFlightHits += 1 + } else { + inFlight = options.loadSource(url) + this.inFlightCache.set(key, inFlight) + } + + const gltf = await inFlight.finally(() => { + if (this.inFlightCache.get(key) === inFlight) { + this.inFlightCache.delete(key) + } + }) + + const entry: CachedModelEntry = { + key, + url, + gltf, + lastUsedAt: Date.now() + } + this.sourceCache.set(key, entry) + this.trim() + return entry + } + + private clone(entry: CachedModelEntry): GLTF { + entry.lastUsedAt = Date.now() + const scene = entry.gltf.scene.clone(true) + cloneObjectResources(scene) + scene.userData.modelCacheKey = entry.key + + return { + ...entry.gltf, + scene + } + } + + private trim() { + while (this.sourceCache.size > this.maxEntries) { + const lru = [...this.sourceCache.values()] + .sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0] + + if (!lru) return + + this.sourceCache.delete(lru.key) + disposeObject(lru.gltf.scene) + this.diagnostics.evictions += 1 + } + } +} + +export const guideModelLoadManager = new GuideModelLoadManager() diff --git a/src/utils/concurrency.ts b/src/utils/concurrency.ts new file mode 100644 index 0000000..8fec27c --- /dev/null +++ b/src/utils/concurrency.ts @@ -0,0 +1,20 @@ +export const mapWithConcurrency = async ( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise +): Promise => { + const results: R[] = new Array(items.length) + const limit = Math.max(1, Math.floor(concurrency)) + let nextIndex = 0 + + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex + nextIndex += 1 + results[currentIndex] = await mapper(items[currentIndex], currentIndex) + } + }) + + await Promise.all(workers) + return results +}