import type { GuideModelFloorAsset, GuideModelRenderPackage, GuideModelSource, GuideRenderPoi } from '@/domain/guideModel' import type { MuseumPoi } from '@/domain/museum' import { isVisitorSearchPoi } from '@/domain/poiCategories' import { getPoiDisplayPolicy } from '@/domain/poiDisplay' import { dataSourceConfig } from '@/config/dataSource' import { compareFloorsTopToBottom, isIndoorNavigableFloor } from '@/domain/guideFloor' import { buildSgsFloorAliases, createSgsHallPoiDiagnostics, formatSgsFloorLabel, toMuseumHallPoisFromSgs, toMuseumPoiFromSgs } from '@/data/adapters/sgsSdkGuideAdapter' import { defaultSgsSdkApiProvider, type SgsSdkApiProvider, type SgsSdkFloorSummaryPayload } from '@/data/providers/sgsSdkApiProvider' import { formatNavFloorLabel, isStaticIndoorNavigableFloorId, toMuseumPoi } from '@/data/adapters/navAssetsAdapter' import { defaultStaticNavAssetsProvider, type StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider' import { mapWithConcurrency } from '@/utils/concurrency' import type { GuideRepository } from '@/repositories/GuideRepository' import { guideRepository } from '@/repositories/createGuideRepository' import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl' const SDK_FLOOR_BUNDLE_CONCURRENCY = 2 const getNow = () => ( typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now() ) const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({ id: poi.id, name: poi.name, floorId: poi.floorId, primaryCategory: poi.primaryCategory.id, primaryCategoryZh: poi.primaryCategory.label, iconType: poi.primaryCategory.iconType || poi.primaryCategory.id, positionGltf: poi.positionGltf, sourceObjectName: poi.sourceObjectName, kind: poi.kind, hallId: poi.hallId, hallName: poi.hallName, spaceId: poi.spaceId, sourcePlaceId: poi.sourcePlaceId, sourceSpaceId: poi.sourceSpaceId, entrances: poi.entrances, displayPolicy: poi.displayPolicy || getPoiDisplayPolicy({ primaryCategory: poi.primaryCategory.id, kind: poi.kind }) }) const toSgsRenderPoi = (poi: ReturnType) => toGuideRenderPoi(poi) const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => { // A facility can be located inside the same spatial area as other facilities. // That relationship must not collapse distinct visitor markers (for example, // a restroom and an elevator in one service zone). Space identity is only a // canonical-place key for halls, spaces, and business-place representations. const canDedupeBySpace = poi.kind === 'hall' || poi.kind === 'space' || poi.primaryCategory === 'business_poi' const stableKeys = [ poi.id ? `id:${poi.id}` : '', canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '', canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '', poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : '' ].filter(Boolean) if (stableKeys.length) return stableKeys return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : [] } const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => { const seen = new Set() return pois.filter((poi) => { const keys = getRenderPoiDedupeKeys(poi) if (!keys.length || keys.some((key) => seen.has(key))) return false keys.forEach((key) => seen.add(key)) return true }) } const countByValue = ( items: T[], selector: (item: T) => string | number | null | undefined ) => items.reduce>((counts, item) => { const key = String(selector(item) || '') counts[key] = (counts[key] || 0) + 1 return counts }, {}) const countDroppedRenderPoiCategories = ( sourcePois: GuideRenderPoi[], keptPois: GuideRenderPoi[] ) => { const keptIds = new Set(keptPois.map((poi) => poi.id)) return countByValue( sourcePois.filter((poi) => !keptIds.has(poi.id)), (poi) => poi.primaryCategory ) } const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '') const resolveSgsAssetUrl = (url?: string | null) => { const normalizedUrl = url?.trim() if (!normalizedUrl) return '' if (/^https?:\/\//i.test(normalizedUrl)) { return normalizeSameOriginPublicUrl(normalizedUrl) } if (!normalizedUrl.startsWith('/')) return normalizedUrl const baseUrl = trimTrailingSlash(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '') const origin = baseUrl.endsWith('/app-api') ? baseUrl.slice(0, -'/app-api'.length) : baseUrl 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) const isSharedSgsModelAsset = ( modelUrl: string, overviewModelUrl: string, allModelUrls: string[] ) => { if (!modelUrl) return false return modelUrl === overviewModelUrl || allModelUrls.filter((url) => url === modelUrl).length > 1 } const warnSgsGuideModelDiagnostics = ( message: string, payload: Record ) => { if (!import.meta.env.DEV) return console.warn(`[SGS guide model] ${message}`, payload) } const stringifyDiagnosticPayload = (payload: Record) => { try { return JSON.stringify(payload) } catch { return '[unserializable]' } } const logSgsGuideModelDiagnostics = ( message: string, payload: Record ) => { if (!import.meta.env.DEV) return if (typeof window !== 'undefined') { const diagnosticsWindow = window as unknown as { __GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{ source: string event: string payload: Record }> } diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= [] diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({ source: 'SGS guide model', event: message, payload }) } console.debug(`[SGS guide model] ${message} ${stringifyDiagnosticPayload(payload)}`) } const summarizeYValues = (pois: GuideRenderPoi[]) => { const values = pois .map((poi) => poi.positionGltf?.[1]) .filter((value): value is number => Number.isFinite(value)) .sort((left, right) => left - right) if (!values.length) { return { count: 0, min: null, median: null, max: null } } return { count: values.length, min: values[0], median: values[Math.floor(values.length / 2)], max: values[values.length - 1] } } const getMedianPoiY = (pois: GuideRenderPoi[]) => { const values = pois .map((poi) => poi.positionGltf?.[1]) .filter((value): value is number => Number.isFinite(value)) .sort((left, right) => left - right) if (!values.length) return undefined const middle = Math.floor(values.length / 2) return values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2 } const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => ( [ String(floor.floorId), floor.floorCode, floor.floorName, label ] .filter((value): value is string => Boolean(value)) ) export interface GuideModelRepository extends GuideModelSource {} export class StaticGuideModelRepository implements GuideModelRepository { constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {} async loadPackage(): Promise { const [manifest, floorIndex] = await Promise.all([ this.provider.loadManifest(), this.provider.loadFloorIndex() ]) const floors: GuideModelFloorAsset[] = [...floorIndex.floors] .filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId)) .sort(compareFloorsTopToBottom) .map((floor) => ({ floorId: floor.floorId, label: formatNavFloorLabel(floor.floorId), order: floor.order, modelUrl: this.provider.assetUrl(floor.modelAsset), sharedModelAsset: floor.sharedModelAsset })) return { overviewModelUrl: this.provider.assetUrl(manifest.assets.overviewModel.asset), floors } } async loadFloorPois(floorId: string) { const floorIndex = await this.provider.loadFloorIndex() const floor = floorIndex.floors.find((item) => item.floorId === floorId) if (!floor || !isStaticIndoorNavigableFloorId(floor.floorId)) return [] const pois = await this.provider.loadFloorPois(floor.poiDataAsset) return pois .map(toMuseumPoi) .filter(isVisitorSearchPoi) .map(toGuideRenderPoi) .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) } } export class SgsSdkGuideModelRepository implements GuideModelRepository { private floorsCache: SgsSdkFloorSummaryPayload[] | null = null constructor( private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider, private readonly guide: GuideRepository = guideRepository ) {} async loadPackage(): Promise { const startedAt = getNow() const manifest = await this.provider.getManifest() const allFloors = sortSgsFloors(manifest.floors) const floors = allFloors.filter(isIndoorNavigableFloor) const overviewFloor = allFloors.find((floor) => !isIndoorNavigableFloor(floor)) const overviewBundleRequest = overviewFloor ? this.provider.getFloorBundle(String(overviewFloor.floorId)).catch((error) => { warnSgsGuideModelDiagnostics('overview bundle request failed', { floorId: String(overviewFloor.floorId), floorCode: overviewFloor.floorCode, error: error instanceof Error ? error.message : String(error) }) return null }) : Promise.resolve(null) this.floorsCache = floors const bundleResults = await mapWithConcurrency( 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: 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', { requestedFloorId: String(floor.floorId), requestedFloorCode: floor.floorCode, bundleFloorId: String(bundleFloor.floorId), bundleFloorCode: bundleFloor.floorCode }) } const modelUrls = uniqueModelUrls([ bundle.model?.modelUrl, bundle.model?.fallbackModelUrl ]) const modelUrl = modelUrls[0] || '' const label = formatSgsFloorLabel(floor.floorCode, floor.floorName) return { floorId: String(floor.floorId), label, order: Number(floor.sortOrder || 0), modelUrl, modelUrls, modelMatchKeys: getSgsFloorModelMatchKeys(floor, label) } }) .filter((asset): asset is GuideModelFloorAsset => Boolean(asset?.modelUrl)) const overviewBundle = await overviewBundleRequest 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, sharedModelAsset: isSharedSgsModelAsset(asset.modelUrl, overviewModelUrl, floorModelUrls) })) logSgsGuideModelDiagnostics('render package ready', { elapsedMs: Math.round(getNow() - startedAt), mapId: manifest.mapId, mode: dataSourceConfig.guideDataMode, overviewFloorId: overviewFloor ? String(overviewFloor.floorId) : '', overviewModelUrl, floorCount: floorAssets.length, floorBundleCount: bundleResults.filter((result) => Boolean(result.bundle)).length }) return { overviewModelUrl, overviewModelUrls, floors: floorAssets } } async loadFloorPois(floorId: string) { const manifest = await this.provider.getManifest() this.floorsCache = sortSgsFloors(manifest.floors).filter(isIndoorNavigableFloor) const aliases = buildSgsFloorAliases(this.floorsCache) const requestedFloorId = floorId.trim() const normalizedFloorId = aliases.get(requestedFloorId) || aliases.get(requestedFloorId.toLowerCase()) || requestedFloorId const matchedFloor = this.floorsCache.find((floor) => ( String(floor.floorId) === normalizedFloorId || floor.floorCode === normalizedFloorId || formatSgsFloorLabel(floor.floorCode, floor.floorName) === normalizedFloorId )) if (!matchedFloor) { warnSgsGuideModelDiagnostics('unable to resolve floor for render POI request', { requestedFloorId: floorId, normalizedFloorId, knownFloors: this.floorsCache.map((floor) => ({ floorId: String(floor.floorId), floorCode: floor.floorCode, label: formatSgsFloorLabel(floor.floorCode, floor.floorName) })) }) return [] } const resolvedFloorId = String(matchedFloor.floorId) const loadFloorData = async ( endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints', loader: () => Promise ) => { try { return await loader() } catch (error) { warnSgsGuideModelDiagnostics('floor data request failed', { floorId: resolvedFloorId, endpoint, error: error instanceof Error ? error.message : String(error) }) return [] } } const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([ loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)), loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)), loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)), loadFloorData('guidePois', () => this.guide.listPois()), loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints()) ]) const ordinaryPois = pois .map((poi) => toMuseumPoiFromSgs(poi, manifest.floors)) .filter(isVisitorSearchPoi) .map(toSgsRenderPoi) const repositoryBusinessPois = guidePois .filter((poi) => ( poi.floorId === resolvedFloorId && poi.primaryCategory.id === 'business_poi' && isVisitorSearchPoi(poi) )) .map(toGuideRenderPoi) const repositorySpacePois = guideSpacePoints .filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi)) .map(toGuideRenderPoi) const floorPoiMedianY = getMedianPoiY(ordinaryPois) const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, { fallbackY: floorPoiMedianY }) const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois) if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) { warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', { floorId: resolvedFloorId, ...hallDiagnostics }) } const hallPois = museumHallPois .filter(isVisitorSearchPoi) .map(toSgsRenderPoi) const adaptedPois = dedupeRenderPoisById([ ...hallPois, ...ordinaryPois, ...repositorySpacePois, ...repositoryBusinessPois ]) const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId) const renderPois = floorMatchedPois .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall') logSgsGuideModelDiagnostics('floor POI category diagnostics', { floorId: resolvedFloorId, floorCode: matchedFloor.floorCode, rawPoiCount: pois.length, rawRepositoryBusinessPoiCount: repositoryBusinessPois.length, rawRepositorySpacePoiCount: repositorySpacePois.length, rawPoiTypeCounts: countByValue(pois, (poi) => poi.type), rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup), adaptedPoiCount: adaptedPois.length, adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory), adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length, renderPoiCount: renderPois.length, renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length, repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois) }) warnSgsGuideModelDiagnostics('floor render POI diagnostics', { floorId: resolvedFloorId, floorCode: matchedFloor.floorCode, poiCount: renderPois.length, hallPoiCount: renderHallPois.length, hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length, poiY: summarizeYValues(renderPois), hallPoiY: summarizeYValues(renderHallPois), fallbackHallY: floorPoiMedianY ?? null }) if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) { warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', { floorId: resolvedFloorId, ...hallDiagnostics, renderPoiCount: renderPois.length }) } return renderPois } } export const createGuideModelRepository = (): GuideModelRepository => { if (dataSourceConfig.guideDataMode === 'api' || dataSourceConfig.guideDataMode === 'sdk') { return new SgsSdkGuideModelRepository() } return new StaticGuideModelRepository() } export const guideModelRepository = createGuideModelRepository()