修复点位楼层跳转并优化模型加载缓存

This commit is contained in:
lyf
2026-07-02 16:42:38 +08:00
parent 7cda427de9
commit 9b1f855515
8 changed files with 750 additions and 41 deletions

View 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 持久缓存策略;如需跨刷新复用,应作为后续专项处理。

View File

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

View File

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

View File

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

View File

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

View File

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

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