修复点位楼层跳转并优化模型加载缓存
This commit is contained in:
76
docs/QA/h5-model-cache-network-verification-2026-07-02.md
Normal file
76
docs/QA/h5-model-cache-network-verification-2026-07-02.md
Normal file
@@ -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 持久缓存策略;如需跨刷新复用,应作为后续专项处理。
|
||||
@@ -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<typeof setTimeout> | null = null
|
||||
let isProgrammaticCameraChange = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | 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<THREE.BufferGeometry>()
|
||||
const disposedMaterials = new Set<THREE.Material>()
|
||||
const disposedTextures = new Set<THREE.Texture>()
|
||||
@@ -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<void>((resolve) => {
|
||||
window.setTimeout(resolve, delayMs)
|
||||
})
|
||||
|
||||
const getErrorStatus = (error: unknown) => {
|
||||
const candidate = error as {
|
||||
status?: unknown
|
||||
target?: { status?: unknown }
|
||||
currentTarget?: { status?: unknown }
|
||||
} | null
|
||||
const status = candidate?.status ?? candidate?.target?.status ?? candidate?.currentTarget?.status
|
||||
return typeof status === 'number' && Number.isFinite(status) ? status : undefined
|
||||
}
|
||||
|
||||
const formatModelLoadError = (error: unknown, url: string) => {
|
||||
if (!url.trim()) return '模型 URL 缺失'
|
||||
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 500) return '模型代理返回 500,请检查 /gis/sdk/minio 或 MinIO 代理层'
|
||||
if (status === 404) return '模型资源不存在或代理返回 404'
|
||||
if (status && status >= 400) return `模型资源请求失败(HTTP ${status})`
|
||||
|
||||
const rawMessage = error instanceof Error ? error.message : String(error || '')
|
||||
const message = rawMessage.toLowerCase()
|
||||
if (message.includes('timeout')) return '模型资源请求超时'
|
||||
if (message.includes('parse') || message.includes('json') || message.includes('gltf')) {
|
||||
return '模型资源解析失败'
|
||||
}
|
||||
|
||||
return rawMessage || '模型资源加载失败'
|
||||
}
|
||||
|
||||
const 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) {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TargetPoiFocusRequestViewModel | null>(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<MuseumPoi | GuideRenderPoi, 'floorId'>) => (
|
||||
!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)
|
||||
|
||||
@@ -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)}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | undefined | null>) => 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
|
||||
}
|
||||
}
|
||||
|
||||
373
src/services/model/GuideModelLoadManager.ts
Normal file
373
src/services/model/GuideModelLoadManager.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
||||
|
||||
export interface GuideModelResourceSet {
|
||||
geometries: Set<THREE.BufferGeometry>
|
||||
materials: Set<THREE.Material>
|
||||
textures: Set<THREE.Texture>
|
||||
}
|
||||
|
||||
export interface GuideModelLoadOptions {
|
||||
urls: string[]
|
||||
label: string
|
||||
semanticKey?: string
|
||||
loadSource: (url: string) => Promise<GLTF>
|
||||
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<THREE.Texture>()
|
||||
const materialRecord = material as unknown as Record<string, unknown>
|
||||
|
||||
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 = <TMaterial extends THREE.Material>(material: TMaterial): TMaterial => {
|
||||
const clonedMaterial = material.clone() as TMaterial
|
||||
const sourceRecord = material as unknown as Record<string, unknown>
|
||||
const cloneRecord = clonedMaterial as unknown as Record<string, unknown>
|
||||
|
||||
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<THREE.Texture>
|
||||
) => {
|
||||
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<THREE.BufferGeometry>()
|
||||
const disposedMaterials = new Set<THREE.Material>()
|
||||
const disposedTextures = new Set<THREE.Texture>()
|
||||
|
||||
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<string, CachedModelEntry>()
|
||||
private readonly inFlightCache = new Map<string, Promise<GLTF>>()
|
||||
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<GLTF> {
|
||||
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()
|
||||
20
src/utils/concurrency.ts
Normal file
20
src/utils/concurrency.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user