From 04c908b9e7dac626aee56e77e9491b3f56fa192d Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Mon, 29 Jun 2026 14:24:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5=E5=BB=BA?= =?UTF-8?q?=E7=AD=91=E5=A4=96=E8=A7=82=E6=96=9C=E8=A7=86=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页外观模型使用建筑主体包围盒进行相机适配 - 外观首屏改为标准 Y 轴向上的建筑斜视角 - 优化室内三维操作提示与交互反馈 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/map/ThreeMap.vue | 732 ++++++++++++++++++++++++++------ src/pages/index/index.vue | 44 +- 2 files changed, 639 insertions(+), 137 deletions(-) diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index f34e7c9..932ae4d 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -69,6 +69,10 @@ import { type ViewMode = 'overview' | 'floor' | 'multi' type CameraPreset = 'top' | 'oblique' type TouchGestureMode = 'orbit' | 'pan' +type ZoomCameraSource = 'button' | 'gesture' + +const CANVAS_FONT_FAMILY = '"HarmonyOS Sans SC", "HarmonyOS Sans", "鸿蒙黑体", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif' + interface CameraFitOptions { distanceFactor?: number targetOffsetRatio?: THREE.Vector3 @@ -76,6 +80,25 @@ interface CameraFitOptions { up?: THREE.Vector3 } +interface LoadFloorOptions { + preserveCurrentSceneUntilReady?: boolean + suppressProgress?: boolean +} + +interface LoadModelOptions { + suppressProgress?: boolean +} + +interface CameraTweenState { + fromPosition: THREE.Vector3 + toPosition: THREE.Vector3 + fromTarget: THREE.Vector3 + toTarget: THREE.Vector3 + startedAt: number + durationMs: number + onComplete?: () => void +} + type FloorIndexItem = GuideModelFloorAsset interface FloorOption { @@ -90,11 +113,16 @@ type PoiVisibilityTier = 'tight' | 'balanced' | 'full' interface PoiSpriteUserData { poi?: RenderPoi baseScale?: number + hitTargetBaseScale?: number + visibleMarker?: THREE.Sprite + hitTarget?: THREE.Sprite + feedbackUntil?: number labelBaseScaleX?: number labelBaseScaleY?: number isPoiLabel?: boolean isPoiPulse?: boolean isPoiBase?: boolean + isPoiHitTarget?: boolean isCorePoi?: boolean } @@ -250,9 +278,21 @@ let floorExitThresholdExceededSince = 0 let floorExitZoomOutAccumulatedDistance = 0 let autoSwitchTemporarilyDisabled = false let autoSwitchDisableTimer: ReturnType | null = null +let suppressNextAutoSwitchCheck = false +let overviewWheelZoomStartDistance = 0 +let overviewWheelZoomIntentUntil = 0 +let overviewWheelAutoSwitchTimer: ReturnType | null = null let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | null = null +let cameraTween: CameraTweenState | null = null +const cameraTweenEnabled = true +const cameraTweenDurationMs = 480 +const poiTapFeedbackDurationMs = 180 +const poiHitTargetScaleMultiplier = 4.8 +const poiHitTargetCoreScaleMultiplier = 5.4 +const poiScreenHitRadiusPx = 64 +const poiScreenHitRadiusCorePx = 76 const floorAutoSwitchProtectionMs = 1600 const floorExitZoomOutIntentMinDeltaRatio = 0.01 const floorExitZoomOutAccumulatedRatio = 0.12 @@ -261,8 +301,9 @@ const floorExitThresholdHoldMs = 650 const syncControlInteractionOptions = () => { if (!controls) return - const shouldEnablePan = props.showControls || props.touchGestureMode === 'pan' - controls.enablePan = shouldEnablePan + controls.enablePan = true + controls.screenSpacePanning = true + controls.panSpeed = 0.85 controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE controls.touches.TWO = THREE.TOUCH.DOLLY_PAN } @@ -321,6 +362,15 @@ const floorExteriorNameKeywords = [ '室外' ] +const overviewBuildingSubjectNameKeywords = [ + '外墙', + '幕墙', + '玻璃幕墙', + '楼顶', + '屋顶', + '外墙装饰' +] + const isRenderableObject = (object: THREE.Object3D) => ( object instanceof THREE.Mesh || object instanceof THREE.Line @@ -403,6 +453,28 @@ const isExteriorModelNode = (object: THREE.Object3D) => ( )) ) +const isOverviewBuildingSubjectNode = (object: THREE.Object3D) => ( + getModelNodeNames(object).some((name) => ( + overviewBuildingSubjectNameKeywords.some((keyword) => name.includes(keyword)) + )) +) + +const getOverviewBuildingSubjectBox = (object: THREE.Object3D) => { + const box = new THREE.Box3() + + object.updateWorldMatrix(true, true) + object.traverse((child) => { + if (!child.visible || !isRenderableObject(child) || !isOverviewBuildingSubjectNode(child)) return + + const childBox = new THREE.Box3().setFromObject(child) + if (!childBox.isEmpty()) { + box.union(childBox) + } + }) + + return box.isEmpty() ? getObjectBox(object) : box +} + const getKnownFloorMatchEntriesBySpecificity = () => ( floorIndex.value .flatMap((floor) => [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])] @@ -527,7 +599,18 @@ const shouldShowPoiInCurrentMode = (poi: RenderPoi) => { const getPoiSpriteUserData = (sprite: THREE.Sprite) => sprite.userData as PoiSpriteUserData const isPoiAuxiliarySprite = (sprite: THREE.Sprite) => ( - Boolean(sprite.userData.isPoiLabel || sprite.userData.isPoiPulse || sprite.userData.isPoiBase) + Boolean( + sprite.userData.isPoiLabel + || sprite.userData.isPoiPulse + || sprite.userData.isPoiBase + || sprite.userData.isPoiHitTarget + ) +) + +const isPoiHitTargetSprite = (child: THREE.Object3D): child is THREE.Sprite => ( + child instanceof THREE.Sprite + && Boolean(child.userData.isPoiHitTarget) + && Boolean(child.userData.poi) ) const getActiveModelSpan = () => { @@ -621,10 +704,18 @@ const updatePoiVisibilityByDistance = () => { if (!categoryVisible || (!isSelected && (isTooClose || exceedsLimit))) { sprite.visible = false + const hitTarget = getPoiSpriteUserData(sprite).hitTarget + if (hitTarget) { + hitTarget.visible = false + } return } sprite.visible = true + const hitTarget = getPoiSpriteUserData(sprite).hitTarget + if (hitTarget) { + hitTarget.visible = true + } acceptedPositions.push(screenPosition) visibleCount += 1 }) @@ -636,9 +727,144 @@ const refreshPoiVisibilityByDistance = () => { const handleControlChange = () => { updatePoiVisibilityByDistance() + if (suppressNextAutoSwitchCheck) { + suppressNextAutoSwitchCheck = false + updateLastControlDistance() + return + } checkAutoSwitch() } +const clearProgrammaticCameraTimer = () => { + if (programmaticCameraTimer) { + clearTimeout(programmaticCameraTimer) + programmaticCameraTimer = null + } +} + +const endProgrammaticCameraChangeSoon = (onComplete?: () => void) => { + clearProgrammaticCameraTimer() + programmaticCameraTimer = window.setTimeout(() => { + isProgrammaticCameraChange = false + programmaticCameraTimer = null + onComplete?.() + }, 120) +} + +const easeInOutCubic = (t: number) => ( + t < 0.5 + ? 4 * t * t * t + : 1 - ((-2 * t + 2) ** 3) / 2 +) + +const cancelCameraTween = (options: { manual?: boolean } = {}) => { + if (!cameraTween) return + + cameraTween = null + if (options.manual) { + isProgrammaticCameraChange = false + clearProgrammaticCameraTimer() + const distance = controls?.getDistance() + if (typeof distance === 'number' && Number.isFinite(distance)) { + lastControlDistance = distance + } + } +} + +const moveCameraTo = ( + position: THREE.Vector3, + target: THREE.Vector3, + options: { durationMs?: number; immediate?: boolean; onComplete?: () => void } = {} +) => { + if (!camera || !controls) return + + const toPosition = position.clone() + const toTarget = target.clone() + isProgrammaticCameraChange = true + clearProgrammaticCameraTimer() + + const nextDistance = toPosition.distanceTo(toTarget) + if (Number.isFinite(nextDistance)) { + lastControlDistance = nextDistance + } + + if (!cameraTweenEnabled || options.immediate) { + cameraTween = null + camera.position.copy(toPosition) + controls.target.copy(toTarget) + controls.update() + updatePoiVisibilityByDistance() + endProgrammaticCameraChangeSoon(options.onComplete) + return + } + + cameraTween = { + fromPosition: camera.position.clone(), + toPosition, + fromTarget: controls.target.clone(), + toTarget, + startedAt: performance.now(), + durationMs: options.durationMs ?? cameraTweenDurationMs, + onComplete: options.onComplete + } +} + +const updateCameraTween = (now: number) => { + if (!cameraTween || !camera || !controls) return + + const elapsed = now - cameraTween.startedAt + const progress = Math.min(1, Math.max(0, elapsed / cameraTween.durationMs)) + const eased = easeInOutCubic(progress) + + camera.position.lerpVectors(cameraTween.fromPosition, cameraTween.toPosition, eased) + controls.target.lerpVectors(cameraTween.fromTarget, cameraTween.toTarget, eased) + controls.update() + updatePoiVisibilityByDistance() + + if (progress >= 1) { + camera.position.copy(cameraTween.toPosition) + controls.target.copy(cameraTween.toTarget) + controls.update() + const { onComplete } = cameraTween + cameraTween = null + endProgrammaticCameraChangeSoon(onComplete) + } +} + +const updatePoiTapFeedback = (now: number) => { + getPoiSprites().forEach((sprite) => { + const userData = getPoiSpriteUserData(sprite) + const feedbackUntil = userData.feedbackUntil || 0 + const poi = userData.poi + const focused = Boolean(poi && poi.id === activeFocusPoiId.value) + + if (feedbackUntil <= now) { + if (feedbackUntil) { + userData.feedbackUntil = 0 + setPoiSpriteFocusStyle(sprite, focused) + } + return + } + + const remaining = Math.max(0, feedbackUntil - now) + const progress = remaining / poiTapFeedbackDurationMs + const baseScale = typeof userData.baseScale === 'number' + ? userData.baseScale + : sprite.scale.x + const scaleBoost = userData.isCorePoi ? 1.18 : 1 + const selectedBoost = focused ? 1.62 : 1 + const tapBoost = 1 + Math.sin(progress * Math.PI) * 0.22 + const scale = baseScale * scaleBoost * selectedBoost * tapBoost + + sprite.scale.set(scale, scale, scale) + sprite.material.opacity = Math.min(1, focused ? 1 : 0.92 + progress * 0.08) + }) +} + +const handleControlStart = () => { + cancelCameraTween({ manual: true }) +} + const setProgress = (progress: number, message: string) => { loadingProgress.value = Math.min(100, Math.max(0, progress)) loadingMessage.value = message @@ -753,9 +979,11 @@ const initThree = async () => { resizeObserver.observe(container) container.addEventListener('pointerdown', handlePointerDown) container.addEventListener('pointerup', handlePointerUp) + container.addEventListener('wheel', handleWheelIntent, { passive: true }) // 监听控制器变化,用于自动切换和点位可见层级更新 if (controls) { + controls.addEventListener('start', handleControlStart) controls.addEventListener('change', handleControlChange) } @@ -766,7 +994,12 @@ const startRenderLoop = () => { const render = () => { if (isDisposed || !renderer || !scene || !camera) return - controls?.update() + if (cameraTween) { + updateCameraTween(performance.now()) + } else { + controls?.update() + } + updatePoiTapFeedback(performance.now()) updateFocusLabelScale() renderer.render(scene, camera) animationId = window.requestAnimationFrame(render) @@ -944,6 +1177,62 @@ const getPoiSprites = () => { return sprites } +const getPoiHitTargets = () => { + const sprites: THREE.Sprite[] = [] + + poiGroup?.traverse((child) => { + if (isPoiHitTargetSprite(child)) { + sprites.push(child) + } + }) + + return sprites +} + +const getPoiScreenPoint = (sprite: THREE.Sprite, rect: DOMRect) => { + if (!camera) return null + + const projected = sprite.position.clone().project(camera) + if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) { + return null + } + + return new THREE.Vector2( + rect.left + ((projected.x + 1) * rect.width * 0.5), + rect.top + ((1 - projected.y) * rect.height * 0.5) + ) +} + +const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) => { + const pointer = new THREE.Vector2(event.clientX, event.clientY) + + return getPoiSprites() + .filter((sprite) => sprite.visible && sprite.userData.poi) + .map((sprite) => { + const screenPoint = getPoiScreenPoint(sprite, rect) + const poi = sprite.userData.poi as RenderPoi | undefined + if (!screenPoint || !poi) return null + + const isCorePoi = Boolean(getPoiSpriteUserData(sprite).isCorePoi) + const hitRadius = isCorePoi ? poiScreenHitRadiusCorePx : poiScreenHitRadiusPx + const distance = screenPoint.distanceTo(pointer) + + return { + sprite, + distance, + hitRadius, + priority: getPoiPriority(poi) + } + }) + .filter((candidate): candidate is { + sprite: THREE.Sprite + distance: number + hitRadius: number + priority: number + } => Boolean(candidate && candidate.distance <= candidate.hitRadius)) + .sort((a, b) => (a.distance - b.distance) || (b.priority - a.priority))[0]?.sprite || null +} + const detachPoiMarkerGroups = () => { poiMarkerGroupCache.forEach((entry) => { entry.group.parent?.remove(entry.group) @@ -1235,7 +1524,7 @@ const createRouteMarkerSprite = (label: string, color: string, markerSize: numbe context.strokeStyle = '#ffffff' context.stroke() - context.font = '700 30px sans-serif' + context.font = `700 30px ${CANVAS_FONT_FAMILY}` context.textAlign = 'center' context.textBaseline = 'middle' context.fillStyle = '#ffffff' @@ -1348,6 +1637,8 @@ const renderRoutePreview = () => { } const clearSceneData = () => { + cancelCameraTween() + if (activeModel && scene) { clearFocusHallHighlight() scene.remove(activeModel) @@ -1367,7 +1658,12 @@ const clearSceneData = () => { clearPoiGroupChildren() } -const loadModel = (url: string, label: string, loadToken?: number) => new Promise((resolve, reject) => { +const loadModel = ( + url: string, + label: string, + loadToken?: number, + options: LoadModelOptions = {} +) => new Promise((resolve, reject) => { if (!loader) { reject(new Error('GLTF 加载器未初始化')) return @@ -1377,7 +1673,7 @@ const loadModel = (url: string, label: string, loadToken?: number) => new Promis url, resolve, (event) => { - if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return + if (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return if (event.total > 0) { const modelProgress = Math.round((event.loaded / event.total) * 70) @@ -1443,6 +1739,17 @@ const getSharedMultiFloorModelUrl = (floorsToLoad: FloorIndexItem[]) => { : '' } +const canAttachCachedSharedModel = (modelUrl: string) => ( + Boolean(cachedOverviewModel && cachedSharedModelUrl === modelUrl && scene) +) + +const canLoadFloorSilently = (floorId: string) => { + const floor = floorIndex.value.find((item) => item.floorId === floorId) + return Boolean(floor && canAttachCachedSharedModel(floor.modelUrl)) +} + +const hasRenderableSceneForFloorTransition = () => Boolean(scene && activeModel) + const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => { const floorModel = sourceModel.clone(true) floorModel.name = `GuideMultiFloorModel_${floor.floorId}` @@ -1488,10 +1795,44 @@ const resetAutoSwitchDistanceTracking = () => { floorAutoSwitchProtectedUntil = 0 floorExitThresholdExceededSince = 0 floorExitZoomOutAccumulatedDistance = 0 + overviewWheelZoomStartDistance = 0 + overviewWheelZoomIntentUntil = 0 updateLastControlDistance() } -const checkAutoSwitch = () => { +const clearOverviewWheelAutoSwitchTimer = () => { + if (overviewWheelAutoSwitchTimer) { + clearTimeout(overviewWheelAutoSwitchTimer) + overviewWheelAutoSwitchTimer = null + } +} + +const handleWheelIntent = (event: WheelEvent) => { + if (!controls || activeView.value !== 'overview' || event.deltaY >= 0) return + + const distance = controls.getDistance() + if (!Number.isFinite(distance)) return + + const now = Date.now() + if (!overviewWheelZoomStartDistance || now > overviewWheelZoomIntentUntil) { + overviewWheelZoomStartDistance = distance + } + overviewWheelZoomIntentUntil = now + 900 + + clearOverviewWheelAutoSwitchTimer() + overviewWheelAutoSwitchTimer = setTimeout(() => { + overviewWheelAutoSwitchTimer = null + checkAutoSwitch({ forceOverviewEntry: true }) + }, 180) +} + +const shouldForceOverviewEntryFromWheel = (distance: number, now: number) => ( + now <= overviewWheelZoomIntentUntil + && overviewWheelZoomStartDistance > 0 + && distance <= overviewWheelZoomStartDistance * 0.86 +) + +const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { // 检查是否启用自动切换 if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) { return @@ -1526,12 +1867,17 @@ const checkAutoSwitch = () => { const exitFloorAt = maxDim * props.autoSwitchThresholdHigh // 判断是否需要切换 - if (activeView.value === 'overview' && distance < enterFloorAt) { + if (activeView.value === 'overview' && (distance < enterFloorAt || (options.forceOverviewEntry && shouldForceOverviewEntryFromWheel(distance, now)))) { // 从建筑外观自动切换到单楼层 + const targetFloorId = currentFloor.value || props.initialFloorId || 'L1' + const hasSceneToKeep = hasRenderableSceneForFloorTransition() isAutoSwitchLocked = true lastAutoSwitchTime = now floorExitThresholdExceededSince = 0 floorExitZoomOutAccumulatedDistance = 0 + overviewWheelZoomStartDistance = 0 + overviewWheelZoomIntentUntil = 0 + clearOverviewWheelAutoSwitchTimer() void runAutoSwitchLoad( { @@ -1540,8 +1886,12 @@ const checkAutoSwitch = () => { trigger: 'zoom-in', distance }, - () => loadFloor(currentFloor.value || props.initialFloorId || 'L1'), - '单楼层模型加载失败' + () => loadFloor(targetFloorId, { + preserveCurrentSceneUntilReady: hasSceneToKeep, + suppressProgress: hasSceneToKeep + }), + '单楼层模型加载失败', + { showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) } ) return } @@ -1606,13 +1956,20 @@ const checkAutoSwitch = () => { const runAutoSwitchLoad = async ( event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }, loadTask: () => Promise, - fallbackMessage: string + fallbackMessage: string, + options: { showLoading?: boolean } = {} ) => { + const showLoading = options.showLoading ?? true + try { - isLoading.value = true + if (showLoading) { + isLoading.value = true + } loadError.value = false await loadTask() - isLoading.value = false + if (showLoading) { + isLoading.value = false + } emit('autoSwitch', event) } catch (error) { if (isStaleModelLoadError(error)) return @@ -1639,6 +1996,10 @@ const disableAutoSwitchTemporarily = (durationMs: number) => { }, durationMs) } +const suppressAutoSwitchOnce = () => { + suppressNextAutoSwitchCheck = true +} + const setCameraView = ( center: THREE.Vector3, maxDim: number, @@ -1647,11 +2008,6 @@ const setCameraView = ( ) => { if (!camera || !controls) return - isProgrammaticCameraChange = true - if (programmaticCameraTimer) { - clearTimeout(programmaticCameraTimer) - } - const fov = camera.fov * (Math.PI / 180) const distanceFactor = options.distanceFactor ?? 0.58 const targetOffset = options.targetOffsetRatio @@ -1664,54 +2020,49 @@ const setCameraView = ( const targetCenter = center.clone().add(targetOffset) const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor - try { - camera.near = Math.max(0.1, maxDim / 1000) - camera.far = Math.max(10000, maxDim * 20) - camera.up.copy(options.up || new THREE.Vector3(0, 1, 0)) - camera.position.copy(targetCenter).add(direction.normalize().multiplyScalar(distance)) - camera.updateProjectionMatrix() + camera.near = Math.max(0.1, maxDim / 1000) + camera.far = Math.max(10000, maxDim * 20) + camera.up.copy(options.up || new THREE.Vector3(0, 1, 0)) + camera.updateProjectionMatrix() - controls.target.copy(targetCenter) - if (options.screenOffsetRatio) { - const viewDirection = controls.target.clone().sub(camera.position).normalize() - const cameraRight = new THREE.Vector3().crossVectors(viewDirection, camera.up).normalize() - const viewHeight = 2 * distance * Math.tan(fov / 2) - const viewWidth = viewHeight * camera.aspect - const screenOffset = cameraRight - .multiplyScalar(options.screenOffsetRatio.x * viewWidth) - .add(camera.up.clone().multiplyScalar(options.screenOffsetRatio.y * viewHeight)) + const nextTarget = targetCenter.clone() + const nextPosition = targetCenter.clone().add(direction.clone().normalize().multiplyScalar(distance)) - camera.position.add(screenOffset) - controls.target.add(screenOffset) - } - controls.minDistance = Math.max(2, maxDim * 0.08) - controls.maxDistance = Math.max(20, maxDim * 8) - controls.update() - } finally { - programmaticCameraTimer = window.setTimeout(() => { - isProgrammaticCameraChange = false - programmaticCameraTimer = null - }, 120) + if (options.screenOffsetRatio) { + const viewDirection = nextTarget.clone().sub(nextPosition).normalize() + const cameraRight = new THREE.Vector3().crossVectors(viewDirection, camera.up).normalize() + const viewHeight = 2 * distance * Math.tan(fov / 2) + const viewWidth = viewHeight * camera.aspect + const screenOffset = cameraRight + .multiplyScalar(options.screenOffsetRatio.x * viewWidth) + .add(camera.up.clone().multiplyScalar(options.screenOffsetRatio.y * viewHeight)) + + nextPosition.add(screenOffset) + nextTarget.add(screenOffset) } + controls.minDistance = Math.max(2, maxDim * 0.08) + controls.maxDistance = Math.max(20, maxDim * 8) + moveCameraTo(nextPosition, nextTarget) } const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => { if (!camera || !controls) return - const box = getObjectBox(object) + const useOverviewBuildingFit = activeView.value === 'overview' && preset !== 'top' + const box = useOverviewBuildingFit ? getOverviewBuildingSubjectBox(object) : getObjectBox(object) const center = box.getCenter(new THREE.Vector3()) const size = box.getSize(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z, 1) const direction = preset === 'top' ? new THREE.Vector3(0.02, 1, 0.02) : activeView.value === 'overview' - ? new THREE.Vector3(-0.16, 1, 0.18) + ? new THREE.Vector3(0.76, 0.48, 1) : new THREE.Vector3(0.85, 0.62, 1) const overviewFitOptions: CameraFitOptions = activeView.value === 'overview' ? { - distanceFactor: 1.1, - screenOffsetRatio: new THREE.Vector2(-0.085, 0.065), - up: new THREE.Vector3(-0.82, 0, -0.57) + distanceFactor: 0.9, + screenOffsetRatio: new THREE.Vector2(-0.045, 0.02), + up: new THREE.Vector3(0, 1, 0) } : {} @@ -1730,7 +2081,7 @@ const setCameraPreset = (preset: CameraPreset) => { } } -const zoomCamera = (direction: 'in' | 'out') => { +const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => { if (!camera || !controls) return const offset = camera.position.clone().sub(controls.target) @@ -1742,30 +2093,23 @@ const zoomCamera = (direction: 'in' | 'out') => { if (!Number.isFinite(nextDistance) || nextDistance <= 0) return - 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( - { - from: 'overview', - to: 'floor', - trigger: 'zoom-in', - distance: nextDistance - }, - () => loadFloor(currentFloor.value || props.initialFloorId || 'L1'), - '单楼层模型加载失败' - ) - return + const shouldSuppressAutoSwitch = options.source === 'button' + if (shouldSuppressAutoSwitch) { + suppressAutoSwitchOnce() } + offset.setLength(nextDistance) + const nextPosition = controls.target.clone().add(offset) + moveCameraTo(nextPosition, controls.target, { + onComplete: shouldSuppressAutoSwitch + ? () => { + suppressAutoSwitchOnce() + handleControlChange() + } + : handleControlChange + }) + lastControlDistance = nextDistance + handleControlChange() } @@ -1837,19 +2181,41 @@ const attachCachedSharedModel = ( return true } -const loadFloor = async (floorId: string) => { +const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { const floor = floorIndex.value.find((item) => item.floorId === floorId) if (!floor || !scene) return const loadToken = startModelLoad() - activeView.value = 'floor' - currentFloor.value = floor.floorId - clearSceneData() - setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`) + const canUseCachedModel = canAttachCachedSharedModel(floor.modelUrl) + const preserveCurrentSceneUntilReady = options.preserveCurrentSceneUntilReady && !canUseCachedModel + let nextModel: THREE.Object3D | null = null - if (!attachCachedSharedModel(floor.modelUrl, `GuideFloorModel_${floor.floorId}`, loadToken, floor.floorId)) { - const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, loadToken) - assertCurrentModelLoad(loadToken, gltf.scene) + if (!preserveCurrentSceneUntilReady) { + activeView.value = 'floor' + currentFloor.value = floor.floorId + clearSceneData() + } + + if (canUseCachedModel) { + activeView.value = 'floor' + currentFloor.value = floor.floorId + attachCachedSharedModel(floor.modelUrl, `GuideFloorModel_${floor.floorId}`, loadToken, floor.floorId) + } else { + if (!options.suppressProgress) { + setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`) + } + const gltf = await loadModel( + floor.modelUrl, + `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, + loadToken, + { suppressProgress: options.suppressProgress } + ) + try { + assertCurrentModelLoad(loadToken, gltf.scene) + } catch (error) { + disposeObject(gltf.scene) + throw error + } const targetScene = scene if (!targetScene) { @@ -1857,14 +2223,28 @@ const loadFloor = async (floorId: string) => { throw createStaleModelLoadError() } - activeModel = gltf.scene - activeModel.name = `GuideFloorModel_${floor.floorId}` - activeModel.userData.floorId = floor.floorId - prepareModel(activeModel) - if (floor.sharedModelAsset) { - cacheAutoSwitchOverviewMaxDim(activeModel) + nextModel = gltf.scene + nextModel.name = `GuideFloorModel_${floor.floorId}` + nextModel.userData.floorId = floor.floorId + prepareModel(nextModel) + try { + assertCurrentModelLoad(loadToken, nextModel) + } catch (error) { + nextModel = null + throw error } - applyModelVisibilityForView(activeModel, 'floor', floor.floorId) + if (floor.sharedModelAsset) { + cacheAutoSwitchOverviewMaxDim(nextModel) + } + applyModelVisibilityForView(nextModel, 'floor', floor.floorId) + + if (preserveCurrentSceneUntilReady) { + activeView.value = 'floor' + currentFloor.value = floor.floorId + clearSceneData() + } + + activeModel = nextModel if (floor.sharedModelAsset) { cachedOverviewModel = activeModel cachedSharedModelUrl = floor.modelUrl @@ -2064,7 +2444,7 @@ const createPoiMaterial = (poi: RenderPoi) => { context.fillStyle = 'rgba(255, 255, 255, 0.94)' context.fill() - context.font = '700 28px sans-serif' + context.font = `700 28px ${CANVAS_FONT_FAMILY}` context.textAlign = 'center' context.textBaseline = 'middle' context.fillStyle = color @@ -2081,6 +2461,32 @@ const createPoiMaterial = (poi: RenderPoi) => { }) } +const createPoiHitTargetMaterial = () => { + const canvas = document.createElement('canvas') + canvas.width = 32 + canvas.height = 32 + const context = canvas.getContext('2d') + + if (context) { + context.clearRect(0, 0, canvas.width, canvas.height) + context.beginPath() + context.arc(16, 16, 15, 0, Math.PI * 2) + context.fillStyle = 'rgba(255, 255, 255, 0.02)' + context.fill() + } + + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + return new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + depthWrite: false, + opacity: 0.01, + colorWrite: false + }) +} + const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => { const canvas = document.createElement('canvas') canvas.width = 560 @@ -2115,17 +2521,17 @@ const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => { context.fillStyle = color context.fill() - context.font = '700 24px sans-serif' + context.font = `700 24px ${CANVAS_FONT_FAMILY}` context.textAlign = 'left' context.textBaseline = 'middle' context.fillStyle = '#ffffff' context.fillText(getPoiGlyph(poi), 74, 70, 28) - context.font = '700 34px sans-serif' + context.font = `700 34px ${CANVAS_FONT_FAMILY}` context.fillStyle = '#1f2329' context.fillText(label, 120, 61, 360) - context.font = '500 22px sans-serif' + context.font = `500 22px ${CANVAS_FONT_FAMILY}` context.fillStyle = '#6b7178' context.fillText(`${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`, 120, 94, 360) } @@ -2338,6 +2744,12 @@ const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => { sprite.scale.set(scale, scale, scale) sprite.renderOrder = focused ? 22 : (userData.isCorePoi ? 12 : 10) sprite.material.opacity = focused ? 1 : 0.86 + + if (userData.hitTarget) { + const hitBaseScale = userData.hitTargetBaseScale || baseScale * poiHitTargetScaleMultiplier + userData.hitTarget.scale.set(hitBaseScale, hitBaseScale, hitBaseScale) + userData.hitTarget.renderOrder = focused ? 21 : 11 + } } const updatePoiMarkerFocus = () => { @@ -2359,6 +2771,30 @@ const findLoadedPoi = (poiId: string) => ( findPoiSprite(poiId)?.userData.poi as RenderPoi | undefined ) +const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => { + const sprite = new THREE.Sprite(createPoiMaterial(poi)) + const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial()) + const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview' + const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier) + + sprite.userData.baseScale = markerSize + sprite.userData.poi = poi + sprite.userData.isCorePoi = isCorePoi + sprite.userData.hitTarget = hitTarget + sprite.userData.hitTargetBaseScale = hitTargetScale + + hitTarget.userData.baseScale = hitTargetScale + hitTarget.userData.poi = poi + hitTarget.userData.visibleMarker = sprite + hitTarget.userData.isPoiHitTarget = true + hitTarget.renderOrder = 11 + hitTarget.scale.set(hitTargetScale, hitTargetScale, hitTargetScale) + + setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value) + + return { sprite, hitTarget } +} + const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => { if (!request.positionGltf) return null @@ -2382,13 +2818,11 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => { const [x, y, z] = poi.positionGltf const markerSize = getPoiMarkerSize() - const sprite = new THREE.Sprite(createPoiMaterial(poi)) + const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize) sprite.position.set(x, y + markerSize * 0.5, z) - sprite.userData.baseScale = markerSize - sprite.userData.poi = poi - sprite.userData.isCorePoi = true - setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value) + hitTarget.position.copy(sprite.position) poiGroup.add(sprite) + poiGroup.add(hitTarget) return poi } @@ -2406,14 +2840,13 @@ const createPoiMarkerGroup = ( pois.forEach((poi) => { const [x, y, z] = poi.positionGltf! - const sprite = new THREE.Sprite(createPoiMaterial(poi)) + const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize) sprite.position.set(x, y + markerSize * 0.5, z) - sprite.userData.baseScale = markerSize - sprite.userData.poi = poi - sprite.userData.isCorePoi = corePoiCategories.has(poi.primaryCategory) - setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value) + hitTarget.position.copy(sprite.position) sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier()) + hitTarget.visible = sprite.visible group.add(sprite) + group.add(hitTarget) }) return group @@ -2495,6 +2928,22 @@ const findFloorObject = (object: THREE.Object3D) => { return null } +const getPoiMarkerFromHit = (object: THREE.Object3D) => { + if (object instanceof THREE.Sprite && isPoiHitTargetSprite(object)) { + return getPoiSpriteUserData(object).visibleMarker || null + } + + if (object instanceof THREE.Sprite && isPoiMarkerSprite(object)) { + return object + } + + return null +} + +const triggerPoiTapFeedback = (sprite: THREE.Sprite) => { + getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs +} + const handleSceneTap = (event: PointerEvent) => { if (!camera || !renderer || !getContainerElement()) return @@ -2507,18 +2956,19 @@ const handleSceneTap = (event: PointerEvent) => { raycaster.setFromCamera(pointer, camera) if (activeView.value === 'floor' && poiGroup) { - const poiHits = raycaster.intersectObjects(getPoiSprites(), false) + const poiHits = raycaster.intersectObjects([...getPoiHitTargets(), ...getPoiSprites()], false) const hit = poiHits.find((item) => ( item.object.visible && item.object instanceof THREE.Sprite - && !isPoiAuxiliarySprite(item.object) - && item.object.userData.poi + && getPoiMarkerFromHit(item.object) )) + const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : findNearestPoiMarkerByScreenPoint(event, rect) - if (hit?.object.userData.poi) { - const poi = hit.object.userData.poi as RenderPoi + if (hitMarker?.userData.poi) { + const poi = hitMarker.userData.poi as RenderPoi selectedPOI.value = poi activeFocusPoiId.value = poi.id + triggerPoiTapFeedback(hitMarker) updatePoiMarkerFocus() showFocusPoiAffordances(poi) focusCameraOnPoi(poi) @@ -2603,16 +3053,15 @@ const focusCameraOnPoi = (poi: RenderPoi) => { Math.max(maxDim * focusMaxDistanceFactor, 80) ) const direction = new THREE.Vector3(0.72, 0.58, 1).normalize() + const nextPosition = target.clone().add(direction.multiplyScalar(distance)) - camera.position.copy(target).add(direction.multiplyScalar(distance)) camera.near = Math.max(0.1, maxDim / 1200) camera.far = Math.max(10000, maxDim * 20) camera.updateProjectionMatrix() - controls.target.copy(target) controls.minDistance = Math.max(2, maxDim * 0.025) controls.maxDistance = Math.max(20, maxDim * 6) - controls.update() + moveCameraTo(nextPosition, target) } const focusTargetPoi = async (request: TargetPoiFocusRequest) => { @@ -2700,7 +3149,10 @@ const loadModelPackage = async () => { setProgress(14, '正在读取楼层索引...') floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom) - if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) { + const requestedInitialFloor = floorIndex.value.find((floor) => floor.floorId === props.initialFloorId) + if (requestedInitialFloor) { + currentFloor.value = requestedInitialFloor.floorId + } else if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) { currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1' } @@ -2743,15 +3195,32 @@ const init3DScene = async () => { } const handleFloorChange = async (floorId: string) => { + const hasSceneToKeep = hasRenderableSceneForFloorTransition() + const showLoading = !hasSceneToKeep && !canLoadFloorSilently(floorId) + try { - isLoading.value = true + if (showLoading) { + isLoading.value = true + } loadError.value = false - await loadFloor(floorId) + await loadFloor(floorId, { + preserveCurrentSceneUntilReady: hasSceneToKeep, + suppressProgress: !showLoading + }) updatePoiVisibilityByDistance() - isLoading.value = false + if (showLoading) { + isLoading.value = false + } else { + isLoading.value = false + } emit('floorChange', floorId) } catch (error) { - if (isStaleModelLoadError(error)) return + if (isStaleModelLoadError(error)) { + if (showLoading) { + isLoading.value = false + } + return + } console.error('楼层模型加载失败:', error) loadError.value = true @@ -2800,12 +3269,14 @@ const disposeScene = () => { const container = getContainerElement() container?.removeEventListener('pointerdown', handlePointerDown) container?.removeEventListener('pointerup', handlePointerUp) + container?.removeEventListener('wheel', handleWheelIntent) resizeObserver?.disconnect() resizeObserver = null // 清理自动切换相关资源 if (controls) { + controls.removeEventListener('start', handleControlStart) controls.removeEventListener('change', handleControlChange) controls.dispose() } @@ -2816,12 +3287,12 @@ const disposeScene = () => { autoSwitchDisableTimer = null } - if (programmaticCameraTimer) { - clearTimeout(programmaticCameraTimer) - programmaticCameraTimer = null - } + clearOverviewWheelAutoSwitchTimer() + clearProgrammaticCameraTimer() + cameraTween = null isProgrammaticCameraChange = false lastControlDistance = 0 + suppressNextAutoSwitchCheck = false floorAutoSwitchProtectedUntil = 0 floorExitThresholdExceededSince = 0 floorExitZoomOutAccumulatedDistance = 0 @@ -2884,10 +3355,27 @@ watch(() => [props.routePreview, props.showRoute], () => { deep: true }) -watch(() => props.initialView, (newView) => { - if (newView === 'floor' || newView === 'multi') { - activeView.value = newView +const syncRequestedIndoorView = async () => { + if (!scene || !floorIndex.value.length || isLoading.value) return + + if (props.initialView === 'multi') { + if (activeView.value !== 'multi') { + await showMultiFloor() + } + return } + + if (props.initialView === 'floor') { + const requestedFloorId = floorIndex.value.find((floor) => floor.floorId === props.initialFloorId)?.floorId + || currentFloor.value + if (requestedFloorId && (activeView.value !== 'floor' || currentFloor.value !== requestedFloorId)) { + await handleFloorChange(requestedFloorId) + } + } +} + +watch(() => [props.initialView, props.initialFloorId], () => { + void syncRequestedIndoorView() }) onMounted(() => { diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 356c284..90d9c30 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -53,6 +53,7 @@ @poi-click="handleGuidePoiClick" @selection-clear="handleGuideSelectionClear" @auto-switch="handleAutoSwitch" + @tool-click="handleIndoorToolClick" @map-tap="handleMapTapForOutdoorNav" >