优化三维导览模型加载与交互体验

This commit is contained in:
lyf
2026-06-27 23:49:38 +08:00
parent 4f6059bbdb
commit f043c85af0
12 changed files with 1433 additions and 509 deletions

View File

@@ -124,6 +124,19 @@ interface MultiFloorModelItem {
size: THREE.Vector3
}
interface ReusableModelResources {
geometries: Set<THREE.BufferGeometry>
materials: Set<THREE.Material>
textures: Set<THREE.Texture>
}
interface PoiMarkerCacheEntry {
floorId: string
pois: RenderPoi[]
group: THREE.Group
markerSize: number
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
const props = withDefaults(defineProps<{
@@ -154,8 +167,8 @@ const props = withDefaults(defineProps<{
routePreview: null,
showRoute: false,
autoSwitch: true,
autoSwitchThresholdLow: 1.0,
autoSwitchThresholdHigh: 1.3,
autoSwitchThresholdLow: 1.45,
autoSwitchThresholdHigh: 3.8,
autoSwitchCooldown: 2000
})
@@ -199,6 +212,9 @@ let dracoLoader: DRACOLoader | null = null
let activeModel: THREE.Object3D | null = null
let poiGroup: THREE.Group | null = null
let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
@@ -218,11 +234,20 @@ let autoSwitchOverviewMaxDim = 0
// 自动切换状态
let isAutoSwitchLocked = false
let lastAutoSwitchTime = 0
let lastControlDistance = 0
let floorAutoSwitchProtectedUntil = 0
let floorExitThresholdExceededSince = 0
let floorExitZoomOutAccumulatedDistance = 0
let autoSwitchTemporarilyDisabled = false
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
const floorAutoSwitchProtectionMs = 1600
const floorExitZoomOutIntentMinDeltaRatio = 0.01
const floorExitZoomOutAccumulatedRatio = 0.12
const floorExitThresholdHoldMs = 650
const syncControlInteractionOptions = () => {
if (!controls) return
@@ -549,8 +574,7 @@ const updatePoiVisibilityByDistance = () => {
const acceptedPositions: THREE.Vector2[] = []
let visibleCount = 0
const candidates = poiGroup.children
.filter((child): child is THREE.Sprite => child instanceof THREE.Sprite && !isPoiAuxiliarySprite(child))
const candidates = getPoiSprites()
.map((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
const screenPosition = sprite.position.clone().project(camera!)
@@ -744,27 +768,195 @@ const handleResize = () => {
renderer.setSize(width, height)
}
const disposeObject = (object: THREE.Object3D) => {
const materialTextureKeys = [
'map',
'alphaMap',
'aoMap',
'bumpMap',
'normalMap',
'displacementMap',
'roughnessMap',
'metalnessMap',
'emissiveMap',
'specularMap',
'envMap',
'lightMap',
'gradientMap',
'matcap',
'clearcoatMap',
'clearcoatNormalMap',
'clearcoatRoughnessMap',
'sheenColorMap',
'sheenRoughnessMap',
'transmissionMap',
'thicknessMap',
'iridescenceMap',
'iridescenceThicknessMap'
] as const
const collectMaterialTextures = (material: THREE.Material | null | undefined) => {
const textures = new Set<THREE.Texture>()
if (!material) return textures
const materialRecord = material as unknown as Record<string, unknown>
materialTextureKeys.forEach((key) => {
const texture = materialRecord[key]
if (texture instanceof THREE.Texture) {
textures.add(texture)
}
})
return textures
}
const disposeMaterialTextures = (
material: THREE.Material,
protectedResources: ReusableModelResources,
disposedTextures: Set<THREE.Texture>
) => {
collectMaterialTextures(material).forEach((texture) => {
if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) {
texture.dispose()
disposedTextures.add(texture)
}
})
}
const collectReusableModelResources = (object: THREE.Object3D | null): ReusableModelResources => {
const geometries = new Set<THREE.BufferGeometry>()
const materials = new Set<THREE.Material>()
const textures = new Set<THREE.Texture>()
object?.traverse((child) => {
if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
if (child.geometry) {
geometries.add(child.geometry)
}
const childMaterials = Array.isArray(child.material) ? child.material : [child.material]
childMaterials.forEach((material) => {
if (!material) return
materials.add(material)
collectMaterialTextures(material).forEach((texture) => textures.add(texture))
})
}
if (child instanceof THREE.Sprite && child.material) {
materials.add(child.material)
collectMaterialTextures(child.material).forEach((texture) => textures.add(texture))
}
})
return { geometries, materials, textures }
}
const disposeObject = (
object: THREE.Object3D,
protectedResources = collectReusableModelResources(null)
) => {
const disposedGeometries = new Set<THREE.BufferGeometry>()
const disposedMaterials = new Set<THREE.Material>()
const disposedTextures = new Set<THREE.Texture>()
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose()
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) => material?.dispose())
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.map?.dispose()
child.material.dispose()
if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
disposeMaterialTextures(child.material, protectedResources, disposedTextures)
child.material.dispose()
disposedMaterials.add(child.material)
}
}
if (child instanceof THREE.Line) {
child.geometry?.dispose()
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) => material?.dispose())
materials.forEach((material) => {
if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
disposeMaterialTextures(material, protectedResources, disposedTextures)
material.dispose()
disposedMaterials.add(material)
}
})
}
})
}
const isPoiMarkerSprite = (child: THREE.Object3D): child is THREE.Sprite => (
child instanceof THREE.Sprite
&& Boolean(child.userData.poi)
&& !isPoiAuxiliarySprite(child)
)
const getPoiSprites = () => {
const sprites: THREE.Sprite[] = []
poiGroup?.traverse((child) => {
if (isPoiMarkerSprite(child)) {
sprites.push(child)
}
})
return sprites
}
const detachPoiMarkerGroups = () => {
poiMarkerGroupCache.forEach((entry) => {
entry.group.parent?.remove(entry.group)
})
}
const disposePoiMarkerCache = () => {
detachPoiMarkerGroups()
poiMarkerGroupCache.forEach((entry) => {
disposeObject(entry.group)
})
poiMarkerGroupCache.clear()
poiDataCache.clear()
}
const clearPoiGroupChildren = () => {
if (!poiGroup) return
while (poiGroup.children.length) {
const child = poiGroup.children[0]
poiGroup.remove(child)
if (child instanceof THREE.Group) {
const cachedEntry = [...poiMarkerGroupCache.values()].find((entry) => entry.group === child)
if (cachedEntry) continue
}
disposeObject(child)
}
}
const getMaterialColor = (material: THREE.Material) => {
const candidate = material as THREE.Material & { color?: unknown }
return candidate.color instanceof THREE.Color ? candidate.color : undefined
@@ -943,6 +1135,7 @@ const disposeCachedOverviewModel = () => {
}
const clearRoutePreview = () => {
activeRoutePreviewSignature = ''
if (!routeGroup) return
while (routeGroup.children.length) {
@@ -1073,15 +1266,54 @@ const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number
})
}
const renderRoutePreview = () => {
clearRoutePreview()
const routePositionSignature = (position: [number, number, number]) => (
position.map((value) => Number(value.toFixed(3))).join(',')
)
const getRoutePreviewSignature = (
route: GuideRouteResult,
visibleSegments: GuideRouteFloorSegment[],
markerSize: number
) => JSON.stringify({
routeId: route.id,
view: activeView.value,
floor: activeView.value === 'floor' ? currentFloor.value : '',
markerSize: Number(markerSize.toFixed(3)),
start: {
poiId: route.start.poiId,
floorId: route.start.floorId,
position: routePositionSignature(route.start.position)
},
end: {
poiId: route.end.poiId,
floorId: route.end.floorId,
position: routePositionSignature(route.end.position)
},
segments: visibleSegments.map((segment) => ({
floorId: segment.floorId,
points: segment.points.map((point) => `${point.nodeId}:${routePositionSignature(point.position)}`)
})),
connectors: route.connectorPoints
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
.map((point) => `${point.nodeId}:${point.floorId}:${routePositionSignature(point.position)}`)
})
const renderRoutePreview = () => {
if (!props.showRoute || !props.routePreview || !routeGroup || !scene) {
clearRoutePreview()
return
}
if (!props.showRoute || !props.routePreview || !routeGroup || !scene) return
const route = props.routePreview
const targetRouteGroup = routeGroup
const visibleSegments = getVisibleRouteSegments(route)
const markerSize = Math.max(getPoiMarkerSize() * 0.82, 2.8)
const signature = getRoutePreviewSignature(route, visibleSegments, markerSize)
if (signature === activeRoutePreviewSignature) return
clearRoutePreview()
activeRoutePreviewSignature = signature
visibleSegments.forEach((segment) => {
if (segment.points.length < 2) return
@@ -1104,7 +1336,7 @@ const clearSceneData = () => {
if (activeModel === cachedOverviewModel) {
cachedOverviewModel.visible = false
} else {
disposeObject(activeModel)
disposeObject(activeModel, collectReusableModelResources(cachedOverviewModel))
}
}
@@ -1114,14 +1346,7 @@ const clearSceneData = () => {
disposeFocusBase()
selectedPOI.value = null
clearRoutePreview()
if (poiGroup) {
while (poiGroup.children.length) {
const child = poiGroup.children[0]
poiGroup.remove(child)
disposeObject(child)
}
}
clearPoiGroupChildren()
}
const loadModel = (url: string, label: string, loadToken?: number) => new Promise<GLTF>((resolve, reject) => {
@@ -1191,6 +1416,63 @@ const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
})
}
const getSharedMultiFloorModelUrl = (floorsToLoad: FloorIndexItem[]) => {
if (!floorsToLoad.length || !floorsToLoad.every((floor) => floor.sharedModelAsset)) return ''
const [firstFloor] = floorsToLoad
return floorsToLoad.every((floor) => floor.modelUrl === firstFloor.modelUrl)
? firstFloor.modelUrl
: ''
}
const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => {
const floorModel = sourceModel.clone(true)
floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
floorModel.userData.floorId = floor.floorId
prepareModel(floorModel)
applyModelVisibilityForView(floorModel, 'floor', floor.floorId)
return {
floor,
label: formatFloorLabel(floor.floorId),
model: floorModel,
size: getObjectSize(floorModel)
}
}
const disposeDetachedMultiFloorModels = (
items: MultiFloorModelItem[],
group: THREE.Group,
protectedResources = collectReusableModelResources(null)
) => {
items
.filter((item) => !group.children.includes(item.model))
.forEach((item) => disposeObject(item.model, protectedResources))
}
const updateLastControlDistance = () => {
if (!controls) return
const distance = controls.getDistance()
if (Number.isFinite(distance)) {
lastControlDistance = distance
}
}
const markFloorAutoSwitchEntry = () => {
floorAutoSwitchProtectedUntil = Date.now() + floorAutoSwitchProtectionMs
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
updateLastControlDistance()
}
const resetAutoSwitchDistanceTracking = () => {
floorAutoSwitchProtectedUntil = 0
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
updateLastControlDistance()
}
const checkAutoSwitch = () => {
// 检查是否启用自动切换
if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) {
@@ -1215,18 +1497,23 @@ const checkAutoSwitch = () => {
// 获取当前距离
const distance = controls.getDistance()
const previousDistance = lastControlDistance || distance
const distanceDelta = distance - previousDistance
lastControlDistance = distance
// 自动切换使用全馆稳定尺寸,避免单楼层可见性过滤压低 floor -> overview 阈值。
const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel)
const thresholdLow = maxDim * props.autoSwitchThresholdLow
const thresholdHigh = maxDim * props.autoSwitchThresholdHigh
const enterFloorAt = maxDim * props.autoSwitchThresholdLow
const exitFloorAt = maxDim * props.autoSwitchThresholdHigh
// 判断是否需要切换
if (activeView.value === 'overview' && distance < thresholdLow) {
if (activeView.value === 'overview' && distance < enterFloorAt) {
// 从建筑外观自动切换到单楼层
isAutoSwitchLocked = true
lastAutoSwitchTime = now
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
void runAutoSwitchLoad(
{
@@ -1238,10 +1525,52 @@ const checkAutoSwitch = () => {
() => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
'单楼层模型加载失败'
)
} else if (activeView.value === 'floor' && !props.targetFocus && distance > thresholdHigh) {
return
}
if (activeView.value !== 'floor' || props.targetFocus) {
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
return
}
if (now < floorAutoSwitchProtectedUntil) {
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
return
}
const zoomOutIntentDelta = Math.max(maxDim * floorExitZoomOutIntentMinDeltaRatio, 1)
const isClearZoomOutIntent = distanceDelta > zoomOutIntentDelta
const isBeyondExitThreshold = distance > exitFloorAt
if (!isBeyondExitThreshold) {
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
return
}
if (isClearZoomOutIntent) {
floorExitZoomOutAccumulatedDistance += distanceDelta
if (!floorExitThresholdExceededSince) {
floorExitThresholdExceededSince = now
}
}
const requiredZoomOutDistance = Math.max(maxDim * floorExitZoomOutAccumulatedRatio, 8)
if (
!floorExitThresholdExceededSince
|| floorExitZoomOutAccumulatedDistance < requiredZoomOutDistance
) {
return
}
if (now - floorExitThresholdExceededSince >= floorExitThresholdHoldMs) {
// 从单楼层自动切换到完整外围模型
isAutoSwitchLocked = true
lastAutoSwitchTime = now
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
void runAutoSwitchLoad(
{
@@ -1362,10 +1691,13 @@ const zoomCamera = (direction: 'in' | 'out') => {
offset.setLength(nextDistance)
camera.position.copy(controls.target).add(offset)
controls.update()
lastControlDistance = nextDistance
if (direction === 'in' && activeView.value === 'overview' && !isLoading.value && !isAutoSwitchLocked) {
isAutoSwitchLocked = true
lastAutoSwitchTime = Date.now()
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
void runAutoSwitchLoad(
{
@@ -1401,6 +1733,7 @@ const loadOverview = async () => {
cacheAutoSwitchOverviewMaxDim(activeModel)
targetScene.add(activeModel)
fitCameraToObject(activeModel)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
return
@@ -1426,6 +1759,7 @@ const loadOverview = async () => {
cachedSharedModelUrl = packageData.overviewModelUrl
targetScene.add(activeModel)
fitCameraToObject(activeModel)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
}
@@ -1486,6 +1820,7 @@ const loadFloor = async (floorId: string) => {
if (!activeModel) throw createStaleModelLoadError()
fitCameraToObject(activeModel)
markFloorAutoSwitchEntry()
if (shouldRenderPoiMarkers.value) {
await loadFloorPOIs(floor, loadToken)
@@ -1508,9 +1843,81 @@ const loadMultiFloor = async () => {
group.name = 'GuideMultiFloorModel'
const orderedFloors = [...floorIndex.value].sort((a, b) => a.order - b.order)
const loadedFloors: MultiFloorModelItem[] = []
const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)
if (sharedModelUrl) {
let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null
if (!sourceModel) {
const gltf = await loadModel(sharedModelUrl, '正在加载多层共享模型', loadToken)
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
if (!targetScene) {
disposeObject(gltf.scene)
disposeObject(group)
throw createStaleModelLoadError()
}
sourceModel = gltf.scene
sourceModel.name = 'GuideOverviewModel'
prepareModel(sourceModel)
applyModelVisibilityForView(sourceModel, 'overview')
cacheAutoSwitchOverviewMaxDim(sourceModel)
cachedOverviewModel = sourceModel
cachedSharedModelUrl = sharedModelUrl
}
const sharedSourceModel = sourceModel
const protectedResources = collectReusableModelResources(sharedSourceModel)
try {
orderedFloors.forEach((floor, index) => {
assertCurrentModelLoad(loadToken, group)
const item = createMultiFloorItemFromSharedModel(sharedSourceModel, floor)
loadedFloors.push(item)
setProgress(
18 + Math.round(((index + 1) / orderedFloors.length) * 72),
`已准备 ${item.label}`
)
})
assertCurrentModelLoad(loadToken, group)
applyMultiFloorLayout(loadedFloors)
loadedFloors.forEach((item) => group.add(item.model))
const targetScene = scene
if (!targetScene) {
disposeObject(group, protectedResources)
throw createStaleModelLoadError()
}
activeModel = group
targetScene.add(activeModel)
fitCameraToObject(activeModel)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
return
} catch (error) {
disposeObject(group, protectedResources)
disposeDetachedMultiFloorModels(loadedFloors, group, protectedResources)
if (isStaleModelLoadError(error)) {
throw error
}
throw error
}
}
for (let index = 0; index < orderedFloors.length; index += 1) {
assertCurrentModelLoad(loadToken, group)
try {
assertCurrentModelLoad(loadToken, group)
} catch (error) {
disposeDetachedMultiFloorModels(loadedFloors, group)
throw error
}
const floor = orderedFloors[index]
const label = formatFloorLabel(floor.floorId)
@@ -1518,6 +1925,7 @@ const loadMultiFloor = async () => {
if (!isCurrentModelLoad(loadToken)) {
disposeObject(gltf.scene)
disposeObject(group)
disposeDetachedMultiFloorModels(loadedFloors, group)
throw createStaleModelLoadError()
}
@@ -1538,19 +1946,27 @@ const loadMultiFloor = async () => {
)
}
assertCurrentModelLoad(loadToken, group)
try {
assertCurrentModelLoad(loadToken, group)
} catch (error) {
disposeDetachedMultiFloorModels(loadedFloors, group)
throw error
}
applyMultiFloorLayout(loadedFloors)
loadedFloors.forEach((item) => group.add(item.model))
const targetScene = scene
if (!targetScene) {
disposeObject(group)
disposeDetachedMultiFloorModels(loadedFloors, group)
throw createStaleModelLoadError()
}
activeModel = group
targetScene.add(activeModel)
fitCameraToObject(activeModel)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
}
@@ -1873,25 +2289,16 @@ const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
const updatePoiMarkerFocus = () => {
if (!poiGroup) return
poiGroup.children.forEach((child) => {
if (
child instanceof THREE.Sprite
&& !child.userData.isPoiLabel
&& !child.userData.isPoiPulse
&& !child.userData.isPoiBase
) {
const poi = child.userData.poi as RenderPoi | undefined
setPoiSpriteFocusStyle(child, Boolean(poi && poi.id === activeFocusPoiId.value))
}
getPoiSprites().forEach((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
setPoiSpriteFocusStyle(sprite, Boolean(poi && poi.id === activeFocusPoiId.value))
})
refreshPoiVisibilityByDistance()
}
const findPoiSprite = (poiId: string) => (
poiGroup?.children.find((child): child is THREE.Sprite => (
child instanceof THREE.Sprite && (child.userData.poi as RenderPoi | undefined)?.id === poiId
))
getPoiSprites().find((sprite) => (sprite.userData.poi as RenderPoi | undefined)?.id === poiId)
)
const findLoadedPoi = (poiId: string) => (
@@ -1932,19 +2339,12 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
return poi
}
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
if (!poiGroup) return
const createPoiMarkerGroup = (floorId: string, pois: RenderPoi[], markerSize: number) => {
const group = new THREE.Group()
group.name = `GuideModelPOI_${floorId}`
group.userData.floorId = floorId
const validPois = (await props.modelSource.loadFloorPois(floor.floorId))
.filter((poi) => shouldShowPoiInCurrentMode(poi))
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
const targetPoiGroup = poiGroup
if (!targetPoiGroup) return
const markerSize = getPoiMarkerSize()
validPois.forEach((poi) => {
pois.forEach((poi) => {
const [x, y, z] = poi.positionGltf!
const sprite = new THREE.Sprite(createPoiMaterial(poi))
sprite.position.set(x, y + markerSize * 0.5, z)
@@ -1953,8 +2353,55 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
sprite.userData.isCorePoi = corePoiCategories.has(poi.primaryCategory)
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
targetPoiGroup.add(sprite)
group.add(sprite)
})
return group
}
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
if (!poiGroup) return
detachPoiMarkerGroups()
if (entry.group.parent !== poiGroup) {
poiGroup.add(entry.group)
}
updatePoiMarkerFocus()
}
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
if (!poiGroup) return
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
const cachedEntry = poiMarkerGroupCache.get(floor.floorId)
if (cachedEntry) {
attachPoiMarkerGroup(cachedEntry)
return
}
const cachedPois = poiDataCache.get(floor.floorId)
const validPois = cachedPois || (await props.modelSource.loadFloorPois(floor.floorId))
.filter((poi) => shouldShowPoiInCurrentMode(poi))
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
poiDataCache.set(floor.floorId, validPois)
const markerSize = getPoiMarkerSize()
const markerGroup = createPoiMarkerGroup(floor.floorId, validPois, markerSize)
const entry: PoiMarkerCacheEntry = {
floorId: floor.floorId,
pois: validPois,
group: markerGroup,
markerSize
}
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
disposeObject(markerGroup)
return
}
poiMarkerGroupCache.set(floor.floorId, entry)
attachPoiMarkerGroup(entry)
}
const clearMapSelection = (shouldEmit = true) => {
@@ -1997,7 +2444,7 @@ const handleSceneTap = (event: PointerEvent) => {
raycaster.setFromCamera(pointer, camera)
if (activeView.value === 'floor' && poiGroup) {
const poiHits = raycaster.intersectObjects(poiGroup.children, false)
const poiHits = raycaster.intersectObjects(getPoiSprites(), false)
const hit = poiHits.find((item) => (
item.object.visible
&& item.object instanceof THREE.Sprite
@@ -2126,7 +2573,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
await loadFloor(request.floorId)
isLoading.value = false
emit('floorChange', request.floorId)
} else if (shouldRenderPoiMarkers.value && poiGroup && poiGroup.children.length === 0) {
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
await loadFloorPOIs(floor, modelLoadVersion)
}
@@ -2311,8 +2758,13 @@ const disposeScene = () => {
programmaticCameraTimer = null
}
isProgrammaticCameraChange = false
lastControlDistance = 0
floorAutoSwitchProtectedUntil = 0
floorExitThresholdExceededSince = 0
floorExitZoomOutAccumulatedDistance = 0
clearSceneData()
disposePoiMarkerCache()
disposeCachedOverviewModel()
scene?.clear()
scene = null

View File

@@ -193,6 +193,9 @@ import type {
import type {
GuideRouteResult
} from '@/domain/museum'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
interface GuideFloorOption {
id: string
@@ -367,7 +370,8 @@ const indoorRendererRef = ref<{
disableAutoSwitchTemporarily?: (durationMs: number) => void
} | null>(null)
const floorLabels = computed(() => props.floors.map((floor) => floor.label))
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
const floorLabels = computed(() => indoorFloors.value.map((floor) => floor.label))
// #ifdef H5
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
// #endif
@@ -376,13 +380,13 @@ const showIndoorRightControls = computed(() => (
))
const activeFloorId = computed(() => {
const matchedFloor = props.floors.find((floor) => (
const matchedFloor = indoorFloors.value.find((floor) => (
floor.id === props.activeFloor || floor.label === props.activeFloor
))
if (matchedFloor) return matchedFloor.id
const normalizedFloorId = props.normalizeFloorId(props.activeFloor)
const normalizedFloor = props.floors.find((floor) => (
const normalizedFloor = indoorFloors.value.find((floor) => (
floor.id === normalizedFloorId || floor.label === normalizedFloorId
))
return normalizedFloor?.id || normalizedFloorId
@@ -497,7 +501,7 @@ const handleMoreTap = () => {
}
const handleThreeFloorChange = (floorId: string) => {
const floor = props.floors.find((item) => item.id === floorId)
const floor = indoorFloors.value.find((item) => item.id === floorId)
emit('floorChange', floor?.label || floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')

View File

@@ -65,6 +65,9 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
export interface RoutePointOption {
poiId: string
@@ -128,9 +131,14 @@ watch(
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase())
const filteredOptions = computed(() => {
if (!normalizedKeyword.value) return props.options
const indoorOptions = props.options.filter((option) => isIndoorNavigableFloor({
floorId: option.floorId,
label: option.floorLabel
}))
return props.options.filter((option) => {
if (!normalizedKeyword.value) return indoorOptions
return indoorOptions.filter((option) => {
const fields = [
option.name,
option.floorId,

View File

@@ -6,6 +6,9 @@ import type {
StaticNavPoiCategoryPayload,
StaticNavPoiPayload
} from '@/data/providers/staticNavAssetsProvider'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
const floorIdPattern = /^L(-?\d+(?:\.\d+)?)$/
@@ -31,6 +34,13 @@ export const navFloorIdFromLabel = (labelOrId: string) => {
return labelOrId
}
export const isStaticIndoorNavigableFloorId = (floorId: string) => (
isIndoorNavigableFloor({
floorId,
label: formatNavFloorLabel(floorId)
})
)
const toCategory = (category: StaticNavPoiCategoryPayload): MuseumCategory => ({
id: category.topCategory,
label: category.topCategoryZh,

View File

@@ -13,6 +13,9 @@ import type {
import {
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/domain/guideReadiness'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import type {
SgsFloorDiagnosticsPayload,
SgsMapDiagnosticsPayload,
@@ -129,6 +132,8 @@ export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
const aliases = new Map<string, string>()
floors.forEach((floor) => {
if (!isIndoorNavigableFloor(floor)) return
const floorId = stringifyId(floor.floorId)
if (!floorId) return
@@ -242,16 +247,17 @@ export const toGuideMapDiagnostics = (
const floors = diagnostics.floors?.length
? diagnostics.floors.map(toGuideFloorDetailFromSgs)
: (manifest?.floors || []).map(toGuideFloorDetailFromSgs)
const indoorFloors = floors.filter(isIndoorNavigableFloor)
return {
mapId: stringifyId(diagnostics.mapId || manifest?.mapId),
mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图',
sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined,
status: normalizeStatus(diagnostics.status),
floors,
floors: indoorFloors,
summary: {
floorCount: toNumber(summary.floorCount, floors.length),
modelReadyFloorCount: toNumber(summary.modelReadyFloorCount),
floorCount: indoorFloors.length,
modelReadyFloorCount: indoorFloors.filter((floor) => floor.modelReady === true).length || toNumber(summary.modelReadyFloorCount),
poiCount: toNumber(summary.poiCount),
spaceCount: toNumber(summary.spaceCount),
guideStopCount: toNumber(summary.guideStopCount),
@@ -267,7 +273,7 @@ export const toRouteReadinessFromSgsDiagnostics = (
diagnostics: SgsMapDiagnosticsPayload
): GuideRouteReadiness => {
const warnings = diagnostics.warnings || []
const floors = diagnostics.floors || []
const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor)
const notReadyFloors = floors.filter((floor) => (
floor.modelReady !== true
|| floor.routePlanningReady !== true

View File

@@ -126,7 +126,7 @@
<text class="more-action-desc">{{ routePlannerEntryDesc }}</text>
</view>
<view class="more-action" @tap="handleMoreOutdoorNav">
<text class="more-action-title">室外导航</text>
<text class="more-action-title">来馆</text>
<text class="more-action-desc">导航到博物馆</text>
</view>
</view>
@@ -591,9 +591,8 @@ const buildExplainHallItems = (
}
const handleGuideSearchTap = () => {
uni.showToast({
title: '功能待开放',
icon: 'none'
uni.navigateTo({
url: '/pages/search/index'
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,9 @@ import type {
import {
dataSourceConfig
} from '@/config/dataSource'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
formatSgsFloorLabel,
toMuseumPoiFromSgs
@@ -17,7 +20,8 @@ import {
type SgsSdkFloorSummaryPayload
} from '@/data/providers/sgsSdkApiProvider'
import {
formatNavFloorLabel
formatNavFloorLabel,
isStaticIndoorNavigableFloorId
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
@@ -97,6 +101,7 @@ export class StaticGuideModelRepository implements GuideModelRepository {
])
const floors: GuideModelFloorAsset[] = [...floorIndex.floors]
.filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId))
.sort((a, b) => a.order - b.order)
.map((floor) => ({
floorId: floor.floorId,
@@ -115,7 +120,7 @@ export class StaticGuideModelRepository implements GuideModelRepository {
async loadFloorPois(floorId: string) {
const floorIndex = await this.provider.loadFloorIndex()
const floor = floorIndex.floors.find((item) => item.floorId === floorId)
if (!floor) return []
if (!floor || !isStaticIndoorNavigableFloorId(floor.floorId)) return []
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
return pois
@@ -131,7 +136,8 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
async loadPackage(): Promise<GuideModelRenderPackage> {
const manifest = await this.provider.getManifest()
const floors = sortSgsFloors(manifest.floors)
const allFloors = sortSgsFloors(manifest.floors)
const floors = allFloors.filter(isIndoorNavigableFloor)
this.floorsCache = floors
const bundles = await Promise.all(
@@ -153,8 +159,13 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
})
.filter((asset) => asset.modelUrl)
const overviewFloorIndex = floors.findIndex((floor) => floor.floorCode === 'EXTERIOR')
const overviewModelUrl = floorAssets[overviewFloorIndex]?.modelUrl || floorAssets[0]?.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
) || floorAssets[0]?.modelUrl || ''
return {
overviewModelUrl,
@@ -164,7 +175,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
async loadFloorPois(floorId: string) {
const manifest = await this.provider.getManifest()
this.floorsCache = sortSgsFloors(manifest.floors)
this.floorsCache = sortSgsFloors(manifest.floors).filter(isIndoorNavigableFloor)
const matchedFloor = this.floorsCache.find((floor) => (
String(floor.floorId) === floorId
|| floor.floorCode === floorId

View File

@@ -10,8 +10,13 @@ import type {
import {
NAV_ROUTE_READINESS
} from '@/domain/guideReadiness'
import {
isIndoorNavigableFloor,
isPoiOnIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
formatNavFloorLabel,
isStaticIndoorNavigableFloorId,
navFloorIdFromLabel,
toMuseumPoi
} from '@/data/adapters/navAssetsAdapter'
@@ -68,6 +73,11 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
sourceObjectName: poi.sourceObjectName
})
const isSearchableIndoorPoi = (poi: MuseumPoi) => (
searchableCategories.has(poi.primaryCategory.id)
&& isPoiOnIndoorNavigableFloor(poi)
)
export interface GuideRepository {
getAssetBaseUrl(): string
getFloors(): Promise<MuseumFloor[]>
@@ -94,6 +104,7 @@ export class StaticGuideRepository implements GuideRepository {
async getFloors() {
const floorIndex = await this.provider.loadFloorIndex()
return [...floorIndex.floors]
.filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId))
.sort((a, b) => b.order - a.order)
.map((floor) => ({
id: floor.floorId,
@@ -111,8 +122,8 @@ export class StaticGuideRepository implements GuideRepository {
const pois = await this.provider.loadPoiIndex()
this.poiCache = pois
.filter((poi) => searchableCategories.has(poi.primaryCategory))
.map(toMuseumPoi)
.filter(isSearchableIndoorPoi)
return this.poiCache
}
@@ -208,8 +219,10 @@ export class SgsSdkGuideRepository implements GuideRepository {
if (this.floorsCache) return this.floorsCache
const manifest = await this.provider.getManifest()
this.floorAliases = buildSgsFloorAliases(manifest.floors)
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
this.floorAliases = buildSgsFloorAliases(indoorFloors)
this.floorsCache = [...manifest.floors]
.filter(isIndoorNavigableFloor)
.sort((a, b) => Number(a.sortOrder || 0) - Number(b.sortOrder || 0))
.map(toMuseumFloorFromSgs)
@@ -228,15 +241,18 @@ export class SgsSdkGuideRepository implements GuideRepository {
if (this.poiCache) return this.poiCache
const manifest = await this.provider.getManifest()
this.floorAliases = buildSgsFloorAliases(manifest.floors)
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
this.floorAliases = buildSgsFloorAliases(indoorFloors)
const poiGroups = await Promise.all(
manifest.floors.map((floor) => this.provider.getFloorPois(String(floor.floorId)))
indoorFloors.map((floor) => this.provider.getFloorPois(String(floor.floorId)))
)
this.poiCache = poiGroups
.flat()
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter((poi) => poi.id && poi.name)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
return this.poiCache
}
@@ -271,11 +287,11 @@ export class SgsSdkGuideRepository implements GuideRepository {
const normalizedFloorId = this.normalizeFloorId(floorId)
try {
const diagnostics = await this.provider.getFloorDiagnostics(normalizedFloorId)
return toGuideFloorDetailFromSgs(diagnostics)
return isIndoorNavigableFloor(diagnostics) ? toGuideFloorDetailFromSgs(diagnostics) : null
} catch {
const manifest = await this.provider.getManifest()
const floor = manifest.floors.find((item) => String(item.floorId) === normalizedFloorId)
return floor ? toGuideFloorDetailFromSgs(floor) : null
return floor && isIndoorNavigableFloor(floor) ? toGuideFloorDetailFromSgs(floor) : null
}
}

View File

@@ -3,6 +3,9 @@ import type {
GuideRouteResult,
GuideRouteTarget
} from '@/domain/museum'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
applyRouteReadinessGate,
NAV_ROUTE_UNAVAILABLE_MESSAGE,
@@ -164,7 +167,10 @@ export class StaticGuideRouteRepository implements GuideRouteRepository {
async listRouteTargets() {
const dataset = await this.loadDataset()
return dataset.targets
return dataset.targets.filter((target) => isIndoorNavigableFloor({
floorId: target.floorId,
label: target.floorLabel
}))
}
async findRoute(startPoiId: string, endPoiId: string) {

View File

@@ -6,6 +6,9 @@ import type {
import {
NAV_ROUTE_READINESS
} from '@/domain/guideReadiness'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
GuideRouteError
} from '@/repositories/GuideRouteRepository'
@@ -63,7 +66,7 @@ export class SdkGuideRouteRepository {
async getRouteReadiness(): Promise<GuideRouteReadiness> {
try {
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
const floors = diagnostics.floors || []
const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor)
if (!floors.length) {
return createUnavailableReadiness('SDK 地图楼层数据为空')
@@ -115,7 +118,7 @@ export class SdkGuideRouteRepository {
if (this.targetsCache) return this.targetsCache
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
const floors = diagnostics.floors || []
const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor)
const targets: GuideRouteTarget[] = []
@@ -196,7 +199,7 @@ export class SdkGuideRouteRepository {
// 获取原始 floorId 用于 API 调用
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
const floorIdMap = new Map<string, string>()
for (const floor of diagnostics.floors || []) {
for (const floor of (diagnostics.floors || []).filter(isIndoorNavigableFloor)) {
floorIdMap.set(toAppFloorId(floor.floorId), stringifyId(floor.floorId))
}
const rawStartFloorId = floorIdMap.get(startTarget.floorId) || startTarget.floorId

View File

@@ -3,6 +3,9 @@ import type {
GuideRouteResult,
GuideRouteTarget
} from '@/domain/museum'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
GuideRouteError,
type GuideRouteRepository
@@ -47,7 +50,11 @@ export class GuideRouteUseCase {
async listTargets() {
if (this.targetsCache) return this.targetsCache
this.targetsCache = await this.repository.listRouteTargets()
this.targetsCache = (await this.repository.listRouteTargets())
.filter((target) => isIndoorNavigableFloor({
floorId: target.floorId,
label: target.floorLabel
}))
return this.targetsCache
}