恢复空间标签层级与模型锚点
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
cxk
2026-07-20 08:56:03 +08:00
parent 298e7d7606
commit 3cc0336806
6 changed files with 223 additions and 80 deletions

View File

@@ -65,6 +65,11 @@ import type {
GuideRouteFloorSegment, GuideRouteFloorSegment,
GuideRouteResult GuideRouteResult
} from '@/domain/museum' } from '@/domain/museum'
import {
getPoiDisplayPolicy,
isPoiVisibilityTierAtLeast,
type PoiVisibilityTier
} from '@/domain/poiDisplay'
import { import {
compareFloorsTopToBottom, compareFloorsTopToBottom,
getFloorSortLevel getFloorSortLevel
@@ -341,7 +346,6 @@ interface FloorOption {
type RenderPoi = GuideRenderPoi type RenderPoi = GuideRenderPoi
type PoiDisplayMode = 'core' | 'balanced' | 'detail' type PoiDisplayMode = 'core' | 'balanced' | 'detail'
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
interface PoiSpriteUserData { interface PoiSpriteUserData {
poi?: RenderPoi poi?: RenderPoi
@@ -607,8 +611,6 @@ const poiHitTargetScaleMultiplier = 4.8
const poiHitTargetCoreScaleMultiplier = 5.4 const poiHitTargetCoreScaleMultiplier = 5.4
const poiScreenHitRadiusPx = 64 const poiScreenHitRadiusPx = 64
const poiScreenHitRadiusCorePx = 76 const poiScreenHitRadiusCorePx = 76
const poiAmbientLabelHallSpacingPx = 92
const poiAmbientLabelServiceSpacingPx = 72
const modelLoadRetryDelaysMs = [300, 900] const modelLoadRetryDelaysMs = [300, 900]
const adjacentPreloadDelayMs = 900 const adjacentPreloadDelayMs = 900
const manualAutoSwitchPauseMs = 1500 const manualAutoSwitchPauseMs = 1500
@@ -844,29 +846,6 @@ const syncControlInteractionOptions = (event?: PointerEvent) => {
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE
} }
const corePoiCategories = new Set([
'poi',
'touring_poi',
'exhibition_hall',
'exhibition_hall_entrance',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service'
])
const poiCategoryPriority: Record<string, number> = {
target_preview: 100,
exhibition_hall: 96,
exhibition_hall_entrance: 92,
touring_poi: 90,
poi: 86,
accessibility_special_service: 88,
basic_service_facility: 82,
business_poi: 80,
transport_circulation: 76,
operation_experience: 46
}
const isHallPoi = (poi: RenderPoi) => ( const isHallPoi = (poi: RenderPoi) => (
poi.kind === 'hall' poi.kind === 'hall'
|| poi.primaryCategory === 'exhibition_hall' || poi.primaryCategory === 'exhibition_hall'
@@ -881,6 +860,13 @@ const isServiceFacilityPoi = (poi: RenderPoi) => (
const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation' const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'
const getPoiPolicy = (poi: RenderPoi) => (
poi.displayPolicy || getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory,
kind: poi.kind
})
)
const poiGlyphMap: Record<string, string> = { const poiGlyphMap: Record<string, string> = {
poi: '点', poi: '点',
accessible_restroom: '无', accessible_restroom: '无',
@@ -1263,15 +1249,11 @@ const getPoiGlyph = (poi: RenderPoi) => (
const shouldShowPoiInCurrentMode = (poi: RenderPoi) => { const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
const mode = getPoiDisplayMode() const mode = getPoiDisplayMode()
const policy = getPoiPolicy(poi)
if (mode === 'core') { if (!policy.markerVisible) return false
return corePoiCategories.has(poi.primaryCategory) if (mode === 'core') return policy.allowOverview
} if (mode === 'balanced') return policy.allowMulti
if (mode === 'balanced') {
return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi'
}
return true return true
} }
@@ -1314,22 +1296,9 @@ const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
return true return true
} }
if (activeView.value === 'floor' && isHallPoi(poi)) { const policy = getPoiPolicy(poi)
return true return policy.markerVisible
} && isPoiVisibilityTierAtLeast(tier, policy.minMarkerTier)
if (tier === 'tight') {
return corePoiCategories.has(poi.primaryCategory) && !isTransportPoi(poi)
}
if (tier === 'balanced') {
return !isTransportPoi(poi) && (
corePoiCategories.has(poi.primaryCategory)
|| poi.primaryCategory === 'touring_poi'
)
}
return true
} }
const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => { const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
@@ -1345,7 +1314,7 @@ const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
} }
const getPoiPriority = (poi: RenderPoi) => { const getPoiPriority = (poi: RenderPoi) => {
const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20 const categoryPriority = getPoiPolicy(poi).priority
const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0 const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0 const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0
const transportPenalty = isTransportPoi(poi) ? 18 : 0 const transportPenalty = isTransportPoi(poi) ? 18 : 0
@@ -1364,8 +1333,7 @@ const getPoiLabelDensityTier = (): PoiVisibilityTier => {
} }
const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => ( const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
isHallPoi(poi) getPoiPolicy(poi).labelVisible
|| isServiceFacilityPoi(poi)
) )
const shouldShowAmbientPoiLabel = ( const shouldShowAmbientPoiLabel = (
@@ -1375,11 +1343,9 @@ const shouldShowAmbientPoiLabel = (
) => { ) => {
if (!markerVisible || activeView.value !== 'floor') return false if (!markerVisible || activeView.value !== 'floor') return false
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false
if (isHallPoi(poi)) return true const policy = getPoiPolicy(poi)
if (isTransportPoi(poi)) return false return policy.labelVisible
if (isServiceFacilityPoi(poi)) return densityTier === 'full' && isPoiVisibilityTierAtLeast(densityTier, policy.minLabelTier)
return false
} }
const getPoiAmbientLabelPriority = (poi: RenderPoi) => { const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
@@ -1490,17 +1456,15 @@ const updateAmbientPoiLabels = () => {
return return
} }
const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx const spacing = getPoiPolicy(poi).labelCollisionSpacing
const bounds = updatePoiDomLabelPosition(labelHandle) const bounds = updatePoiDomLabelPosition(labelHandle)
if (!bounds) { if (!bounds) return
return
}
const overlaps = acceptedBounds.some((acceptedBound) => domLabelBoundsOverlap(bounds, acceptedBound, spacing)) const overlaps = acceptedBounds.some((acceptedBound) => (
domLabelBoundsOverlap(bounds, acceptedBound, spacing)
))
setPoiDomLabelVisible(labelHandle, !overlaps) setPoiDomLabelVisible(labelHandle, !overlaps)
if (!overlaps) { if (!overlaps) acceptedBounds.push(bounds)
acceptedBounds.push(bounds)
}
}) })
} }
@@ -4802,7 +4766,7 @@ const findLoadedPoi = (poiId: string) => (
const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => { const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
const sprite = new THREE.Sprite(createPoiMaterial(poi)) const sprite = new THREE.Sprite(createPoiMaterial(poi))
const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial()) const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview' const isCorePoi = getPoiPolicy(poi).allowOverview || poi.primaryCategory === 'target_preview'
const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier) const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)
sprite.userData.baseScale = markerSize sprite.userData.baseScale = markerSize

View File

@@ -2,6 +2,7 @@ import type {
MuseumPoiEntrance, MuseumPoiEntrance,
MuseumPoiKind MuseumPoiKind
} from '@/domain/museum' } from '@/domain/museum'
import type { PoiDisplayPolicy } from '@/domain/poiDisplay'
export interface GuideRenderPoi { export interface GuideRenderPoi {
id: string id: string
@@ -19,6 +20,7 @@ export interface GuideRenderPoi {
sourcePlaceId?: string sourcePlaceId?: string
sourceSpaceId?: string sourceSpaceId?: string
entrances?: MuseumPoiEntrance[] entrances?: MuseumPoiEntrance[]
displayPolicy?: PoiDisplayPolicy
} }
export interface GuideModelFloorAsset { export interface GuideModelFloorAsset {

View File

@@ -104,6 +104,7 @@ export interface MuseumPoi {
sourcePlaceId?: string sourcePlaceId?: string
sourceSpaceId?: string sourceSpaceId?: string
entrances?: MuseumPoiEntrance[] entrances?: MuseumPoiEntrance[]
displayPolicy?: import('@/domain/poiDisplay').PoiDisplayPolicy
} }
export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable' export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable'

125
src/domain/poiDisplay.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { MuseumPoiKind } from '@/domain/museum'
export type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
export interface PoiDisplayPolicy {
markerVisible: boolean
labelVisible: boolean
minMarkerTier: PoiVisibilityTier
minLabelTier: PoiVisibilityTier
allowOverview: boolean
allowMulti: boolean
priority: number
labelCollisionSpacing: number
}
export interface PoiDisplayPolicyInput {
primaryCategory?: string
kind?: MuseumPoiKind
}
const tierRank: Record<PoiVisibilityTier, number> = {
tight: 0,
balanced: 1,
full: 2
}
export const isPoiVisibilityTierAtLeast = (
tier: PoiVisibilityTier,
minimum: PoiVisibilityTier
) => tierRank[tier] >= tierRank[minimum]
export const getPoiDisplayPolicy = ({
primaryCategory = '',
kind
}: PoiDisplayPolicyInput): PoiDisplayPolicy => {
const category = primaryCategory.trim().toLowerCase()
const isTarget = category === 'target_preview'
const isHall = kind === 'hall'
|| category === 'exhibition_hall'
|| category === 'exhibition_hall_entrance'
|| category === 'touring_poi'
const isSpace = kind === 'space' || category.startsWith('space_')
const isService = category === 'basic_service_facility'
|| category === 'accessibility_special_service'
|| category === 'business_poi'
const isTransport = category === 'transport_circulation'
if (isTarget) {
return {
markerVisible: true,
labelVisible: true,
minMarkerTier: 'tight',
minLabelTier: 'tight',
allowOverview: true,
allowMulti: true,
priority: 1000,
labelCollisionSpacing: 16
}
}
if (isHall) {
return {
markerVisible: true,
labelVisible: true,
minMarkerTier: 'tight',
// 原始 floor 视图中展厅 marker 可见即显示标签,不受更近一档缩放限制。
minLabelTier: 'tight',
allowOverview: true,
allowMulti: true,
priority: 120,
labelCollisionSpacing: 64
}
}
if (isSpace) {
return {
markerVisible: true,
labelVisible: false,
minMarkerTier: 'tight',
// 普通空间保留 marker但不参与展厅/设施环境标签层。
minLabelTier: 'full',
allowOverview: true,
allowMulti: true,
priority: 50,
labelCollisionSpacing: 20
}
}
if (isService) {
return {
markerVisible: true,
labelVisible: true,
minMarkerTier: 'tight',
minLabelTier: 'full',
allowOverview: true,
allowMulti: true,
priority: 80,
labelCollisionSpacing: 48
}
}
if (isTransport) {
return {
markerVisible: true,
labelVisible: false,
minMarkerTier: 'balanced',
minLabelTier: 'full',
allowOverview: true,
allowMulti: true,
priority: 40,
labelCollisionSpacing: 40
}
}
return {
markerVisible: true,
labelVisible: false,
minMarkerTier: 'tight',
minLabelTier: 'full',
allowOverview: true,
allowMulti: true,
priority: 20,
labelCollisionSpacing: 32
}
}

View File

@@ -7,6 +7,9 @@ import type {
import type { import type {
MuseumPoi MuseumPoi
} from '@/domain/museum' } from '@/domain/museum'
import {
getPoiDisplayPolicy
} from '@/domain/poiDisplay'
import { import {
dataSourceConfig dataSourceConfig
} from '@/config/dataSource' } from '@/config/dataSource'
@@ -56,18 +59,25 @@ const getNow = () => (
: Date.now() : Date.now()
) )
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({ const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => {
id: poi.id, const kind = poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility'
name: poi.name, return {
floorId: poi.floorId, id: poi.id,
primaryCategory: poi.primaryCategory, name: poi.name,
primaryCategoryZh: poi.primaryCategoryZh, floorId: poi.floorId,
iconType: poi.iconType || poi.primaryCategory, primaryCategory: poi.primaryCategory,
positionGltf: poi.positionGltf, primaryCategoryZh: poi.primaryCategoryZh,
sourceObjectName: poi.sourceObjectName, iconType: poi.iconType || poi.primaryCategory,
kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility', positionGltf: poi.positionGltf,
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined sourceObjectName: poi.sourceObjectName,
}) kind,
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined,
displayPolicy: getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory,
kind
})
}
}
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
id: poi.id, id: poi.id,
@@ -84,7 +94,11 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
spaceId: poi.spaceId, spaceId: poi.spaceId,
sourcePlaceId: poi.sourcePlaceId, sourcePlaceId: poi.sourcePlaceId,
sourceSpaceId: poi.sourceSpaceId, sourceSpaceId: poi.sourceSpaceId,
entrances: poi.entrances entrances: poi.entrances,
displayPolicy: poi.displayPolicy || getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory.id,
kind: poi.kind
})
}) })
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi) const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
getPoiDisplayPolicy,
isPoiVisibilityTierAtLeast
} from '@/domain/poiDisplay'
describe('POI 显示策略', () => {
it('空间点位初始总览保留 marker但不进入环境标签层', () => {
const policy = getPoiDisplayPolicy({
primaryCategory: 'space_public_area',
kind: 'space'
})
expect(policy.labelVisible).toBe(false)
expect(policy.minMarkerTier).toBe('tight')
expect(policy.allowOverview).toBe(true)
expect(policy.allowMulti).toBe(true)
expect(policy.minLabelTier).toBe('full')
})
it('展厅和服务设施拥有不同优先级与标签密度策略', () => {
const hall = getPoiDisplayPolicy({ primaryCategory: 'exhibition_hall', kind: 'hall' })
const service = getPoiDisplayPolicy({ primaryCategory: 'business_poi', kind: 'facility' })
expect(hall.labelVisible).toBe(true)
expect(hall.minLabelTier).toBe('tight')
expect(hall.priority).toBeGreaterThan(service.priority)
expect(service.minLabelTier).toBe('full')
})
it('LOD tier 按 tight 到 full 单调放宽', () => {
expect(isPoiVisibilityTierAtLeast('tight', 'tight')).toBe(true)
expect(isPoiVisibilityTierAtLeast('balanced', 'tight')).toBe(true)
expect(isPoiVisibilityTierAtLeast('balanced', 'full')).toBe(false)
expect(isPoiVisibilityTierAtLeast('full', 'balanced')).toBe(true)
})
})