feat: wire guide data source and POI focus UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user