feat: wire guide data source and POI focus UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lyf
2026-06-12 09:25:22 +08:00
parent a6bfda30e1
commit 2055b13b90
58 changed files with 5768 additions and 1898 deletions

View File

@@ -50,56 +50,23 @@ import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import FloorSwitcher from './FloorSwitcher.vue'
import { NAV_ASSET_BASE_URL } from '@/services/navAssets'
import type {
GuideModelFloorAsset,
GuideModelRenderPackage,
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
type ViewMode = 'overview' | 'floor'
interface ManifestFloorModel {
floorId: string
order: number
asset: string
}
interface CleanNavManifest {
status: string
assets: {
overviewModel: {
asset: string
}
floorModels: ManifestFloorModel[]
}
data: {
floorIndex: string
}
}
interface FloorIndexItem {
floorId: string
order: number
modelAsset: string
poiDataAsset: string
poiCount: number
}
interface FloorIndexData {
status: string
floors: FloorIndexItem[]
}
type FloorIndexItem = GuideModelFloorAsset
interface FloorOption {
id: string
label: string
}
interface CleanPoi {
id: string
name: string
floorId: string
primaryCategory: string
primaryCategoryZh: string
iconType: string
positionGltf?: [number, number, number]
}
type RenderPoi = GuideRenderPoi
interface TargetPoiFocusRequest {
requestId: number | string
@@ -119,23 +86,18 @@ interface TargetPoiFocusResult {
message?: string
}
interface FloorPoiData {
status: string
floorId: string
pois: CleanPoi[]
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
const props = withDefaults(defineProps<{
assetBaseUrl?: string
modelSource: GuideModelSource
initialFloorId?: string
initialView?: ViewMode
showControls?: boolean
showPoi?: boolean
targetFocus?: TargetPoiFocusRequest | null
}>(), {
assetBaseUrl: NAV_ASSET_BASE_URL,
assetBaseUrl: '',
initialFloorId: 'L1',
initialView: 'overview',
showControls: true,
@@ -145,7 +107,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
floorChange: [floorId: string]
poiClick: [poi: CleanPoi]
poiClick: [poi: RenderPoi]
targetFocus: [result: TargetPoiFocusResult]
}>()
@@ -156,9 +118,10 @@ const loadingProgress = ref(0)
const loadingMessage = ref('正在读取室内导览资源...')
const activeView = ref<ViewMode>(props.initialView)
const currentFloor = ref(props.initialFloorId)
const selectedPOI = ref<CleanPoi | null>(null)
const selectedPOI = ref<RenderPoi | null>(null)
const activeFocusPoiId = ref('')
const floorIndex = ref<FloorIndexItem[]>([])
const renderPackage = ref<GuideModelRenderPackage | null>(null)
const floors = computed<FloorOption[]>(() => (
[...floorIndex.value]
@@ -170,7 +133,6 @@ const floors = computed<FloorOption[]>(() => (
))
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))
let manifest: CleanNavManifest | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let renderer: THREE.WebGLRenderer | null = null
@@ -178,16 +140,13 @@ let controls: OrbitControls | null = null
let loader: GLTFLoader | null = null
let activeModel: THREE.Object3D | null = null
let poiGroup: THREE.Group | null = null
let activeFocusLabelSprite: THREE.Sprite | null = null
let animationId = 0
let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = Promise.resolve()
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const assetUrl = (relativePath: string) => `${normalizeBaseUrl(props.assetBaseUrl)}/${relativePath.replace(/^\/+/, '')}`
const getContainerElement = () => {
const container = containerRef.value
if (!container) return null
@@ -202,27 +161,15 @@ const getContainerElement = () => {
return element instanceof HTMLElement ? element : null
}
const formatFloorLabel = (floorId: string) => {
if (floorId === 'L-2') return 'B2'
if (floorId === 'L-1') return 'B1'
if (floorId === 'L1.5') return '1.5F'
if (floorId.startsWith('L')) return `${floorId.slice(1)}F`
return floorId
}
const formatFloorLabel = (floorId: string) => (
floorIndex.value.find((floor) => floor.floorId === floorId)?.label || floorId
)
const setProgress = (progress: number, message: string) => {
loadingProgress.value = Math.min(100, Math.max(0, progress))
loadingMessage.value = message
}
const fetchJson = async <T,>(url: string): Promise<T> => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`资源读取失败: ${response.status} ${url}`)
}
return response.json() as Promise<T>
}
const waitForContainer = async () => {
await nextTick()
@@ -274,7 +221,7 @@ const initThree = async () => {
loader = new GLTFLoader()
poiGroup = new THREE.Group()
poiGroup.name = 'CleanPackagePOI'
poiGroup.name = 'GuideModelPOI'
scene.add(poiGroup)
const ambientLight = new THREE.AmbientLight(0xffffff, 1.6)
@@ -340,6 +287,7 @@ const clearSceneData = () => {
}
activeModel = null
disposeFocusLabel()
selectedPOI.value = null
if (poiGroup) {
@@ -405,15 +353,16 @@ const fitCameraToObject = (object: THREE.Object3D) => {
}
const loadOverview = async () => {
if (!manifest || !scene) return
const packageData = renderPackage.value
if (!packageData || !scene) return
activeView.value = 'overview'
clearSceneData()
setProgress(18, '正在加载全馆三维模型...')
const gltf = await loadModel(assetUrl(manifest.assets.overviewModel.asset), '正在加载全馆三维模型')
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载全馆三维模型')
activeModel = gltf.scene
activeModel.name = 'CleanOverviewModel'
activeModel.name = 'GuideOverviewModel'
prepareModel(activeModel)
scene.add(activeModel)
fitCameraToObject(activeModel)
@@ -428,9 +377,9 @@ const loadFloor = async (floorId: string) => {
clearSceneData()
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
const gltf = await loadModel(assetUrl(floor.modelAsset), `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
activeModel = gltf.scene
activeModel.name = `CleanFloorModel_${floor.floorId}`
activeModel.name = `GuideFloorModel_${floor.floorId}`
prepareModel(activeModel)
scene.add(activeModel)
fitCameraToObject(activeModel)
@@ -440,7 +389,7 @@ const loadFloor = async (floorId: string) => {
}
}
const createPoiMaterial = (poi: CleanPoi) => {
const createPoiMaterial = (poi: RenderPoi) => {
const canvas = document.createElement('canvas')
canvas.width = 96
canvas.height = 96
@@ -476,6 +425,69 @@ const createPoiMaterial = (poi: CleanPoi) => {
})
}
const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 512
canvas.height = 144
const context = canvas.getContext('2d')
const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}` : poi.name
if (context) {
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = 'rgba(0, 0, 0, 0.82)'
context.beginPath()
context.roundRect(36, 18, 440, 82, 30)
context.fill()
context.beginPath()
context.moveTo(238, 100)
context.lineTo(274, 100)
context.lineTo(256, 124)
context.closePath()
context.fill()
context.font = '600 38px sans-serif'
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
context.fillText(label, 256, 59, 390)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
}))
sprite.userData.isPoiLabel = true
sprite.renderOrder = 30
sprite.scale.set(markerSize * 4.8, markerSize * 1.35, 1)
return sprite
}
const disposeFocusLabel = () => {
if (!activeFocusLabelSprite) return
activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
disposeObject(activeFocusLabelSprite)
activeFocusLabelSprite = null
}
const showFocusPoiLabel = (poi: RenderPoi) => {
if (!poiGroup || !poi.positionGltf) return
disposeFocusLabel()
const markerSize = getPoiMarkerSize()
const [x, y, z] = poi.positionGltf
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
poiGroup.add(activeFocusLabelSprite)
}
const getPoiColor = (category: string) => {
const colorMap: Record<string, string> = {
touring_poi: '#2f6fed',
@@ -509,8 +521,8 @@ const updatePoiMarkerFocus = () => {
if (!poiGroup) return
poiGroup.children.forEach((child) => {
if (child instanceof THREE.Sprite) {
const poi = child.userData.poi as CleanPoi | undefined
if (child instanceof THREE.Sprite && !child.userData.isPoiLabel) {
const poi = child.userData.poi as RenderPoi | undefined
setPoiSpriteFocusStyle(child, Boolean(poi && poi.id === activeFocusPoiId.value))
}
})
@@ -518,15 +530,15 @@ const updatePoiMarkerFocus = () => {
const findPoiSprite = (poiId: string) => (
poiGroup?.children.find((child): child is THREE.Sprite => (
child instanceof THREE.Sprite && (child.userData.poi as CleanPoi | undefined)?.id === poiId
child instanceof THREE.Sprite && (child.userData.poi as RenderPoi | undefined)?.id === poiId
))
)
const findLoadedPoi = (poiId: string) => (
findPoiSprite(poiId)?.userData.poi as CleanPoi | undefined
findPoiSprite(poiId)?.userData.poi as RenderPoi | undefined
)
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): CleanPoi | null => {
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
if (!request.positionGltf) return null
return {
@@ -561,8 +573,7 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
const loadFloorPOIs = async (floor: FloorIndexItem) => {
if (!poiGroup) return
const data = await fetchJson<FloorPoiData>(assetUrl(floor.poiDataAsset))
const validPois = data.pois.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const validPois = await props.modelSource.loadFloorPois(floor.floorId)
const markerSize = getPoiMarkerSize()
validPois.forEach((poi) => {
@@ -588,8 +599,10 @@ const handlePointerDown = (event: PointerEvent) => {
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(poiGroup.children, false)
if (hits[0]?.object.userData.poi) {
selectedPOI.value = hits[0].object.userData.poi as CleanPoi
const hit = hits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi)
if (hit?.object.userData.poi) {
selectedPOI.value = hit.object.userData.poi as RenderPoi
activeFocusPoiId.value = selectedPOI.value.id
updatePoiMarkerFocus()
emit('poiClick', selectedPOI.value)
@@ -614,6 +627,7 @@ const clearTargetFocus = () => {
pendingTargetFocus = null
activeFocusPoiId.value = ''
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
}
@@ -623,7 +637,7 @@ const isSceneReadyForTargetFocus = () => (
&& !loadError.value
)
const focusCameraOnPoi = (poi: CleanPoi) => {
const focusCameraOnPoi = (poi: RenderPoi) => {
if (!camera || !controls || !poi.positionGltf) return
const [x, y, z] = poi.positionGltf
@@ -675,6 +689,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
if (!poi?.positionGltf) {
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
emitTargetFocus(request, 'missing', '目标暂无三维坐标')
return false
@@ -682,6 +697,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
selectedPOI.value = poi
updatePoiMarkerFocus()
showFocusPoiLabel(poi)
focusCameraOnPoi(poi)
emitTargetFocus(request, 'focused')
return true
@@ -718,17 +734,13 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
})
}
const loadCleanPackage = async () => {
const loadModelPackage = async () => {
setProgress(8, '正在读取室内导览资源...')
manifest = await fetchJson<CleanNavManifest>(assetUrl('app_nav_manifest.json'))
if (manifest.status !== 'pass') {
throw new Error('室内导览资源包状态不是 pass')
}
const packageData = await props.modelSource.loadPackage()
renderPackage.value = packageData
setProgress(14, '正在读取楼层索引...')
const floorData = await fetchJson<FloorIndexData>(assetUrl(manifest.data.floorIndex))
floorIndex.value = [...floorData.floors].sort((a, b) => a.order - b.order)
floorIndex.value = [...packageData.floors].sort((a, b) => a.order - b.order)
if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1'
@@ -752,13 +764,13 @@ const init3DScene = async () => {
setProgress(0, '正在初始化三维场景...')
await initThree()
await loadCleanPackage()
await loadModelPackage()
setProgress(100, '室内三维模型加载完成')
isLoading.value = false
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
} catch (error) {
console.error('室内 3D clean package 加载失败:', error)
console.error('室内 3D 模型资源加载失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试')
@@ -840,11 +852,12 @@ defineExpose({
clearNavigation: () => {
activeFocusPoiId.value = ''
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
}
})
watch(() => props.assetBaseUrl, () => {
watch(() => props.modelSource, () => {
init3DScene()
})