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 } type GuideModelDiagnosticEvent = | 'cache-hit' | 'cache-miss' | 'in-flight-hit' | 'load-start' | 'load-complete' | 'load-failed' | 'preload-skip' // 当前 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 getNow = () => ( typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now() ) const isModelDiagnosticsEnabled = () => { if (!import.meta.env.DEV) return false if (typeof window === 'undefined') return true const diagnosticsWindow = window as unknown as { __GUIDE_3D_MODEL_DIAGNOSTICS__?: boolean } return diagnosticsWindow.__GUIDE_3D_MODEL_DIAGNOSTICS__ !== false } const logModelDiagnostic = ( event: GuideModelDiagnosticEvent, payload: Record ) => { if (!isModelDiagnosticsEnabled()) return console.debug(`[GuideModelLoadManager] ${event}`, payload) } const collectMaterialTextures = (material: THREE.Material) => { const textures = new Set() const materialRecord = material as unknown as Record 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)) { logModelDiagnostic('preload-skip', { label: options.label, url, key, reason: !key ? 'empty-key' : this.sourceCache.has(key) ? 'cache-hit' : 'in-flight-hit', diagnostics: this.getDiagnostics() }) return } await this.loadSource(url, options) return } } 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 logModelDiagnostic('cache-hit', { label: options.label, url, key, diagnostics: this.getDiagnostics() }) return cached } this.diagnostics.misses += 1 logModelDiagnostic('cache-miss', { label: options.label, url, key, diagnostics: this.getDiagnostics() }) let inFlight = this.inFlightCache.get(key) const startedAt = getNow() if (inFlight) { this.diagnostics.inFlightHits += 1 logModelDiagnostic('in-flight-hit', { label: options.label, url, key, diagnostics: this.getDiagnostics() }) } else { logModelDiagnostic('load-start', { label: options.label, url, key }) inFlight = options.loadSource(url) this.inFlightCache.set(key, inFlight) } let gltf: GLTF try { gltf = await inFlight } catch (error) { logModelDiagnostic('load-failed', { label: options.label, url, key, elapsedMs: Math.round(getNow() - startedAt), error: error instanceof Error ? error.message : String(error) }) throw error } finally { if (this.inFlightCache.get(key) === inFlight) { this.inFlightCache.delete(key) } } const entry: CachedModelEntry = { key, url, gltf, lastUsedAt: Date.now() } this.sourceCache.set(key, entry) this.trim() logModelDiagnostic('load-complete', { label: options.label, url, key, elapsedMs: Math.round(getNow() - startedAt), diagnostics: this.getDiagnostics() }) return entry } 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()