BAI 2 недель назад
Родитель
Сommit
8992847152

BIN
public/qushou/qushou03.glb


+ 10 - 1
src/App.vue

@@ -2,11 +2,12 @@
 import { ref } from 'vue'
 import Scene3D from './scenes/Scene3D.vue'
 import DucaoScene from './scenes/DucaoScene.vue'
+import QushouScene from './scenes/QushouScene.vue'
 import Map3DScene from './scenes/Map3DScene.vue'
 import HydraulicDiagram3D from './scenes/HydraulicDiagram3D.vue'
 import type { POIConfig } from './utils/geoCoord'
 
-const currentScene = ref<'scene3d' | 'ducao' | 'map3d' | 'hydraulic'>('map3d')
+const currentScene = ref<'scene3d' | 'ducao' | 'qushou' | 'map3d' | 'hydraulic'>('map3d')
 
 const mapPOIs: POIConfig[] = [
   {
@@ -42,6 +43,13 @@ const mapPOIs: POIConfig[] = [
       >
         渡槽场景
       </button>
+      <button
+        class="switch-btn"
+        :class="{ active: currentScene === 'qushou' }"
+        @click="currentScene = 'qushou'"
+      >
+        渠首场景
+      </button>
       <button
         class="switch-btn"
         :class="{ active: currentScene === 'hydraulic' }"
@@ -53,6 +61,7 @@ const mapPOIs: POIConfig[] = [
     <Map3DScene v-if="currentScene === 'map3d'" :initialPOIs="mapPOIs" />
     <Scene3D v-else-if="currentScene === 'scene3d'" :show-debug-tools="true" />
     <DucaoScene v-else-if="currentScene === 'ducao'" :show-debug-tools="true" />
+    <QushouScene v-else-if="currentScene === 'qushou'" />
     <HydraulicDiagram3D v-else-if="currentScene === 'hydraulic'" />
   </div>
 </template>

+ 2 - 2
src/composables/useIconMarkers.ts

@@ -146,8 +146,8 @@ export function useIconMarkers(
     if (iconMarkerGroup && scene) {
       scene.remove(iconMarkerGroup)
       iconMarkerGroup.traverse((child) => {
-        if ((child as CSS2DObject).isCSS2DObject) {
-          ;(child as CSS2DObject).element.remove()
+        if (child instanceof CSS2DObject) {
+          child.element.remove()
         }
       })
       iconMarkerGroup = null

+ 2 - 0
src/config/sceneConfig.ts

@@ -203,6 +203,8 @@ export const defaultCscwaterParams: CscwaterMaterialParams = {
   waterNormalTiling: 0.07,
   collisionFoamThreshold: 0.39,
   collisionFoamStrength: 0.45,
+  fresnelDistanceNear: 5.0,
+  fresnelDistanceFar: 15.0,
 }
 
 /** 泡沫片面材质参数默认值 */

+ 3 - 2
src/index.ts

@@ -1,10 +1,11 @@
-import Scene3D from './scenes/Scene3D.vue'
+import Scene3D from './scenes/Scene3D.vue'
 import WaterLevelLabel from './scenes/WaterLevelLabel.vue'
 import DucaoScene from './scenes/DucaoScene.vue'
+import QushouScene from './scenes/QushouScene.vue'
 import Map3DScene from './scenes/Map3DScene.vue'
 export * from './config/sceneConfig'
 export * from './utils/geoCoord'
 
-export { Scene3D, WaterLevelLabel, DucaoScene, Map3DScene }
+export { Scene3D, WaterLevelLabel, DucaoScene, QushouScene, Map3DScene }
 
 export type { WaterLevelLabelConfig, SceneType } from './config/sceneConfig'

+ 16 - 1
src/materials/rimFlowMaterial.ts

@@ -63,4 +63,19 @@ function createRimFlowMaterial(options: RimFlowMaterialOptions = {}): THREE.Shad
   pubuTex.wrapT = THREE.RepeatWrapping
   pubuTex.repeat.copy(options.textureRepeat ?? new THREE.Vector2(1, 3))
 
-  return new THREE.Shader
+  return new THREE.ShaderMaterial({
+    uniforms: {
+      uColor: { value: options.color ?? new THREE.Color('#00aaff') },
+      uTime: { value: 0 },
+      uIntensity: { value: options.intensity ?? 1.0 },
+      uSpeed: { value: options.speed ?? 0.3 },
+      uTexture: { value: pubuTex }
+    },
+    vertexShader,
+    fragmentShader,
+    side: THREE.DoubleSide
+  })
+}
+
+// Re-export for external use
+export { createRimFlowMaterial }

+ 11 - 10
src/scenes/DucaoScene.vue

@@ -12,12 +12,12 @@ import {
 
 const containerRef = ref<HTMLDivElement>()
 
-let scene: THREE.Scene
-let camera: THREE.PerspectiveCamera
-let renderer: THREE.WebGLRenderer
-let controls: OrbitControls
+let scene!: THREE.Scene
+let camera!: THREE.PerspectiveCamera
+let renderer!: THREE.WebGLRenderer
+let controls!: OrbitControls
 let animationId: number
-let sky: Sky
+let sky!: Sky
 
 let ducaoGroup: THREE.Group | null = null
 let raycaster: THREE.Raycaster
@@ -119,19 +119,19 @@ function disposeScene() {
   if (sky) {
     if (scene) scene.remove(sky)
     sky.material.dispose()
-    sky = null
+    sky = null as any
   }
 
   if (scene) {
     while (scene.children.length > 0) {
       scene.remove(scene.children[0])
     }
-    scene = null
+    scene = null as any
   }
 
   if (controls) {
     controls.dispose()
-    controls = null
+    controls = null as any
   }
 
   if (renderer) {
@@ -141,10 +141,10 @@ function disposeScene() {
     }
     renderer.forceContextLoss()
     renderer.dispose()
-    renderer = null
+    renderer = null as any
   }
 
-  camera = null
+  camera = null as any
   sceneInitialized = false
 }
 
@@ -307,6 +307,7 @@ defineExpose({
     :key="data.config.id"
     :ref="(el: any) => { if (el) data.componentRef.value = el }"
     :label-id="data.config.id"
+    :name="data.config.name"
     :type="data.config.type"
     :position-x="data.config.positionX"
     :position-y="data.config.positionY"

+ 5 - 42
src/scenes/Map3DScene.vue

@@ -11,7 +11,6 @@ import { latLonToLocalENU, latLonToECEF, ecefToLatLon } from '../utils/geoCoord'
 import { createRimFlowMaterial } from '../materials/rimFlow'
 import { createTechFloorMaterial } from '../materials/techFloor'
 import xinjiangdibanGLB from '../assets/xinjiangdiban.glb'
-import markersConfig from '../config/markerConfig'
 import { useIconMarkers } from '../composables/useIconMarkers'
 
 const containerRef = ref<HTMLDivElement>()
@@ -28,53 +27,17 @@ let modelGroup: THREE.Group | null = null
 
 /** 存储标记点位置,供聚焦按钮使用 */
 let markerPositions: Record<string, THREE.Vector3> = {}
-const focusTarget = ref('原点')
-
-const iconMarkers = useIconMarkers(containerRef, ORIGIN_LAT, ORIGIN_LON)
-const { selectedMarker, popupStyle, closePopup } = iconMarkers
-
-// 缓动函数
-function easeInOutCubic(t: number): number {
-  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
-}
-
-/** 平滑飞向目标点 */
-function flyTo(targetName: string, duration = 1000) {
-  const pos = markerPositions[targetName]
-  if (!pos) return
-
-  focusTarget.value = targetName
-
-  const startPos = camera.position.clone()
-  const startTarget = controls.target.clone()
-  const endPos = new THREE.Vector3(pos.x, pos.y + 50000, pos.z + 80000)
-  const endTarget = pos.clone()
-  endTarget.y += 20000
-
-  const startTime = performance.now()
-
-  function animateFly(time: number) {
-    const elapsed = time - startTime
-    const t = Math.min(elapsed / duration, 1)
-    const e = easeInOutCubic(t)
-
-    camera.position.lerpVectors(startPos, endPos, e)
-    controls.target.lerpVectors(startTarget, endTarget, e)
-    controls.update()
-
-    if (t < 1) {
-      requestAnimationFrame(animateFly)
-    }
-  }
-
-  requestAnimationFrame(animateFly)
-}
 
 // ---------- 测试用经纬度点 ----------
 // 以指定坐标为原点 (84.79211°E, 37.52110°N)
 const ORIGIN_LAT = 37.52110
 const ORIGIN_LON = 84.79211
 
+const iconMarkers = useIconMarkers(containerRef, ORIGIN_LAT, ORIGIN_LON)
+const { selectedMarker, popupStyle, closePopup } = iconMarkers
+
+// flyTo intentionally removed (unused in current implementation)
+
 const testPoints = [
   { lat: 37.52110, lon: 84.79211, label: '原点' },
   { lat: 37.53110, lon: 84.81211, label: '东北方向' },

+ 607 - 0
src/scenes/QushouScene.vue

@@ -0,0 +1,607 @@
+<script setup lang="ts">
+import { onMounted, onUnmounted, ref, reactive, watch } from 'vue'
+import * as THREE from 'three'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import { Sky } from 'three/examples/jsm/objects/Sky.js'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
+
+const containerRef = ref<HTMLDivElement>()
+
+let scene!: THREE.Scene
+let camera!: THREE.PerspectiveCamera
+let renderer!: THREE.WebGLRenderer
+let controls!: OrbitControls
+let animationId: number
+let sky!: Sky
+
+let qushouGroup: THREE.Group | null = null
+let dirLight: THREE.DirectionalLight | null = null
+let sunLight: THREE.DirectionalLight | null = null
+let raycaster: THREE.Raycaster
+let mouse: THREE.Vector2
+
+const pickedPosition = ref<{ x: number; y: number; z: number } | null>({ x: 0, y: 0, z: 0 })
+const cameraInfo = ref({
+  position: { x: 0, y: 0, z: 0 },
+  target: { x: 0, y: 0, z: 0 },
+  distance: 0,
+  minDistance: 0,
+  maxDistance: 0,
+})
+
+const showCoordinatePanel = ref(false)
+const showCameraPanel = ref(false)
+const showLightPanel = ref(false)
+
+// 光照旋转角度控制
+const lightRotation = reactive({
+  x: 2,    // 绕 X 轴旋转 (°)
+  y: 18,   // 绕 Y 轴旋转 (°)
+  z: 104,  // 绕 Z 轴旋转 (°)
+})
+
+function updateLightPosition() {
+  if (!sunLight) return
+  // 光源距离:~2828,模拟远距离日光
+  const basePos = new THREE.Vector3(2000, 2000, 0)
+  const euler = new THREE.Euler(
+    THREE.MathUtils.degToRad(lightRotation.x),
+    THREE.MathUtils.degToRad(lightRotation.y),
+    THREE.MathUtils.degToRad(lightRotation.z),
+    'XYZ'
+  )
+  basePos.applyEuler(euler)
+  sunLight.position.copy(basePos)
+  // 同步天空太阳位置(场景与 Sky 均为 Y-up,直接使用)
+  if (sky) {
+    sky.material.uniforms.sunPosition.value.copy(basePos)
+  }
+}
+
+watch(() => [lightRotation.x, lightRotation.y, lightRotation.z], updateLightPosition)
+
+let sceneInitialized = false
+
+const modelTransform = reactive({ positionX: 0, positionY: 0, positionZ: 0, rotationX: 0, rotationY: 0, rotationZ: 0, scaleX: 1, scaleY: 1, scaleZ: 1 })
+
+watch(modelTransform, () => {
+  if (!qushouGroup) return
+  const t = modelTransform
+  qushouGroup.position.set(t.positionX, t.positionY, t.positionZ)
+  qushouGroup.rotation.set(THREE.MathUtils.degToRad(t.rotationX), THREE.MathUtils.degToRad(t.rotationY), THREE.MathUtils.degToRad(t.rotationZ))
+  qushouGroup.scale.set(t.scaleX, t.scaleY, t.scaleZ)
+}, { deep: true })
+
+// 释放 Three.js 资源
+function disposeScene() {
+  cancelAnimationFrame(animationId)
+  animationId = 0
+
+  if (qushouGroup) {
+    if (scene) scene.remove(qushouGroup)
+    qushouGroup.traverse((child) => {
+      const mesh = child as THREE.Mesh
+      if (mesh.isMesh) {
+        mesh.geometry?.dispose()
+        if (Array.isArray(mesh.material)) {
+          mesh.material.forEach(m => m.dispose())
+        } else {
+          mesh.material?.dispose()
+        }
+      }
+    })
+    qushouGroup = null
+  }
+
+  if (sky) {
+    if (scene) scene.remove(sky)
+    sky.material.dispose()
+    sky = null as any
+  }
+
+  if (scene) {
+    while (scene.children.length > 0) {
+      scene.remove(scene.children[0])
+    }
+    scene = null as any
+  }
+
+  if (controls) {
+    controls.dispose()
+    controls = null as any
+  }
+
+  if (renderer) {
+    const domEl = renderer.domElement
+    if (domEl && domEl.parentNode) {
+      domEl.parentNode.removeChild(domEl)
+    }
+    renderer.forceContextLoss()
+    renderer.dispose()
+    renderer = null as any
+  }
+
+  camera = null as any
+  sceneInitialized = false
+}
+
+function initScene() {
+  if (sceneInitialized) {
+    disposeScene()
+  }
+  const container = containerRef.value!
+
+  scene = new THREE.Scene()
+  scene.background = new THREE.Color(0x87ceeb)
+
+  camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.01, 100000)
+
+  renderer = new THREE.WebGLRenderer({ antialias: true })
+  renderer.setSize(container.clientWidth, container.clientHeight)
+  renderer.domElement.style.width = '100%'
+  renderer.domElement.style.height = '100%'
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+  renderer.toneMapping = THREE.ACESFilmicToneMapping
+  renderer.toneMappingExposure = 1.2
+  renderer.outputColorSpace = THREE.SRGBColorSpace
+  ;(renderer as any).samples = 4
+  renderer.shadowMap.enabled = true
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap
+  container.appendChild(renderer.domElement)
+
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.target.set(0, 0, 0)
+  controls.enablePan = false
+  controls.enableRotate = true
+  controls.minDistance = 20
+  controls.maxDistance = 300
+  controls.mouseButtons = {
+    RIGHT: THREE.MOUSE.ROTATE,
+  }
+
+  sky = new Sky()
+  sky.scale.setScalar(10000000)
+  scene.add(sky)
+
+  const skyUniforms = sky.material.uniforms
+  skyUniforms.turbidity.value = 6
+  skyUniforms.rayleigh.value = 0.1
+  skyUniforms.mieCoefficient.value = 0.005
+  skyUniforms.mieDirectionalG.value = 0.7
+
+  const hemisphereLight = new THREE.HemisphereLight(0x87ceeb, 0x444444, 0.5)
+  scene.add(hemisphereLight)
+
+  // 环境填充光(不投影,负责整体照明)——已关闭
+  dirLight = new THREE.DirectionalLight(0xffffff, 0)
+  dirLight.position.set(-50, 80, 50)
+  scene.add(dirLight)
+
+  // 太阳投影光(主日光,负责阴影和照明)
+  sunLight = new THREE.DirectionalLight(0xffeedd, 2.5)
+  sunLight.castShadow = true
+  sunLight.shadow.mapSize.width = 4096
+  sunLight.shadow.mapSize.height = 4096
+  sunLight.shadow.camera.near = 0.5
+  sunLight.shadow.camera.far = 5000
+  sunLight.shadow.camera.left = -600
+  sunLight.shadow.camera.right = 600
+  sunLight.shadow.camera.top = 600
+  sunLight.shadow.camera.bottom = -600
+  sunLight.shadow.bias = -0.0005
+  sunLight.shadow.normalBias = 0.005
+  sunLight.shadow.radius = 2
+  scene.add(sunLight)
+  updateLightPosition()
+
+  // 创建 HDR 环境贴图(PBR 材质必需,否则粗糙度/金属度失效)
+  const pmremGenerator = new THREE.PMREMGenerator(renderer)
+  const envTexture = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture
+  envTexture.mapping = THREE.EquirectangularReflectionMapping
+  scene.environment = envTexture
+  scene.environmentIntensity = 0.4
+  scene.backgroundIntensity = 0.15
+  pmremGenerator.dispose()
+
+  raycaster = new THREE.Raycaster()
+  mouse = new THREE.Vector2()
+
+  createTestScene()
+  sceneInitialized = true
+  animate()
+}
+
+function createTestScene() {
+  // 加载渠首模型
+  loadQushouModel()
+  // 初始化光照位置
+  if (sunLight) updateLightPosition()
+}
+
+async function loadQushouModel() {
+  try {
+    const loader = new GLTFLoader()
+    const gltf = await loader.loadAsync('/qushou/qushou03.glb')
+    gltf.scene.position.set(0, 0, 0)
+    gltf.scene.traverse((child) => {
+      if ((child as THREE.Mesh).isMesh) {
+        const mesh = child as THREE.Mesh
+        mesh.castShadow = true
+        mesh.receiveShadow = true
+
+        // 检查并转换材质为 PBR(MeshStandardMaterial / MeshPhysicalMaterial)
+        const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
+        for (let i = 0; i < materials.length; i++) {
+          let mat = materials[i]
+          mat.side = THREE.DoubleSide
+
+          // 如果不是 PBR 材质,手动创建 MeshStandardMaterial 并复制属性
+          if (!(mat instanceof THREE.MeshStandardMaterial) && !(mat instanceof THREE.MeshPhysicalMaterial)) {
+            console.warn(`[Qushou] 非 PBR 材质 (${mat.type}),正在转换为 MeshStandardMaterial...`)
+            const pbr = new THREE.MeshStandardMaterial()
+            pbr.color.copy((mat as any).color || new THREE.Color(0xffffff))
+            if ((mat as any).map) pbr.map = (mat as any).map
+            if ((mat as any).normalMap) pbr.normalMap = (mat as any).normalMap
+            if ((mat as any).roughnessMap) pbr.roughnessMap = (mat as any).roughnessMap
+            if ((mat as any).metalnessMap) pbr.metalnessMap = (mat as any).metalnessMap
+            if ((mat as any).aoMap) pbr.aoMap = (mat as any).aoMap
+            if ((mat as any).emissiveMap) pbr.emissiveMap = (mat as any).emissiveMap
+            if ((mat as any).displacementMap) pbr.displacementMap = (mat as any).displacementMap
+            pbr.roughness = (mat as any).roughness ?? 0.5
+            pbr.metalness = (mat as any).metalness ?? 0.5
+            pbr.emissive.copy((mat as any).emissive || new THREE.Color(0x000000))
+            pbr.opacity = (mat as any).opacity ?? 1
+            pbr.transparent = (mat as any).transparent ?? false
+            pbr.side = THREE.DoubleSide
+            if (Array.isArray(mesh.material)) {
+              mesh.material[i] = pbr
+            } else {
+              mesh.material = pbr
+            }
+            mat.dispose()
+            mat = pbr
+          }
+
+          // 确保 PBR 纹理正确初始化
+          for (const key of ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'displacementMap']) {
+            const tex = (mat as any)[key] as THREE.Texture | undefined
+            if (tex) {
+              tex.needsUpdate = true
+              if (!tex.wrapS) tex.wrapS = THREE.RepeatWrapping
+              if (!tex.wrapT) tex.wrapT = THREE.RepeatWrapping
+              if (key === 'normalMap') {
+                tex.colorSpace = THREE.LinearSRGBColorSpace
+              }
+            }
+          }
+          if ((mat as any).normalScale) {
+            const ns = (mat as any).normalScale as THREE.Vector2
+            if (ns.x === 0 && ns.y === 0) ns.set(1, 1)
+          }
+        }
+      }
+    })
+    qushouGroup = gltf.scene
+    scene.add(gltf.scene)
+
+    controls.target.set(0, 0, 0)
+    camera.position.set(188, 67, 16)
+    camera.lookAt(0, 0, 0)
+  } catch (e) {
+    console.error('渠首模型加载失败:', e)
+  }
+}
+
+function onMouseClick(event: MouseEvent) {
+  const target = event.target as HTMLElement
+  if (target.closest('.panel') || target.closest('.toolbar')) return
+  if (!containerRef.value) return
+  const rect = containerRef.value.getBoundingClientRect()
+  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
+  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+
+  raycaster.setFromCamera(mouse, camera)
+  const intersects = raycaster.intersectObject(scene, true)
+  if (intersects.length > 0) {
+    const point = intersects[0].point
+    pickedPosition.value = { x: Math.round(point.x * 100) / 100, y: Math.round(point.y * 100) / 100, z: Math.round(point.z * 100) / 100 }
+  }
+}
+
+function animate() {
+  animationId = requestAnimationFrame(animate)
+
+  cameraInfo.value.position = {
+    x: Number(camera.position.x.toFixed(3)),
+    y: Number(camera.position.y.toFixed(3)),
+    z: Number(camera.position.z.toFixed(3)),
+  }
+  cameraInfo.value.target = {
+    x: Number(controls.target.x.toFixed(3)),
+    y: Number(controls.target.y.toFixed(3)),
+    z: Number(controls.target.z.toFixed(3)),
+  }
+  cameraInfo.value.distance = Number(camera.position.distanceTo(controls.target).toFixed(3))
+
+  renderer.render(scene, camera)
+}
+
+function onResize() {
+  if (!containerRef.value) return
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+  camera.aspect = width / height
+  camera.updateProjectionMatrix()
+  renderer.setSize(width, height)
+}
+
+onMounted(() => {
+  initScene()
+  window.addEventListener('resize', onResize)
+  window.addEventListener('click', onMouseClick)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', onResize)
+  window.removeEventListener('click', onMouseClick)
+  disposeScene()
+})
+</script>
+
+<template>
+  <div ref="containerRef" class="scene-container" />
+  <div class="toolbar">
+    <button class="toolbar-btn" :class="{ active: showCoordinatePanel }" @click="showCoordinatePanel = !showCoordinatePanel">坐标</button>
+    <button class="toolbar-btn" :class="{ active: showCameraPanel }" @click="showCameraPanel = !showCameraPanel">相机</button>
+    <button class="toolbar-btn" :class="{ active: showLightPanel }" @click="showLightPanel = !showLightPanel">光照</button>
+  </div>
+
+  <div v-if="showCoordinatePanel && pickedPosition" class="panel coordinate-panel">
+    <div class="panel-header">
+      <span class="panel-title">拾取坐标 (m)</span>
+      <button class="toggle-btn" @click="showCoordinatePanel = false">×</button>
+    </div>
+    <div class="coordinate-item">
+      <span class="coordinate-label">X:</span>
+      <span class="coordinate-value">{{ pickedPosition.x }}</span>
+    </div>
+    <div class="coordinate-item">
+      <span class="coordinate-label">Y:</span>
+      <span class="coordinate-value">{{ pickedPosition.y }}</span>
+    </div>
+    <div class="coordinate-item">
+      <span class="coordinate-label">Z:</span>
+      <span class="coordinate-value">{{ pickedPosition.z }}</span>
+    </div>
+  </div>
+
+  <div v-if="showCameraPanel" class="panel camera-panel">
+    <div class="panel-header">
+      <span class="panel-title">相机信息</span>
+      <button class="toggle-btn" @click="showCameraPanel = false">×</button>
+    </div>
+    <div class="camera-section">
+      <div class="section-label">位置 (m)</div>
+      <div class="camera-item">
+        <span class="camera-label">X:</span>
+        <span class="camera-value">{{ cameraInfo.position.x }}</span>
+      </div>
+      <div class="camera-item">
+        <span class="camera-label">Y:</span>
+        <span class="camera-value">{{ cameraInfo.position.y }}</span>
+      </div>
+      <div class="camera-item">
+        <span class="camera-label">Z:</span>
+        <span class="camera-value">{{ cameraInfo.position.z }}</span>
+      </div>
+    </div>
+    <div class="camera-section">
+      <div class="section-label">目标点 (m)</div>
+      <div class="camera-item">
+        <span class="camera-label">X:</span>
+        <span class="camera-value">{{ cameraInfo.target.x }}</span>
+      </div>
+      <div class="camera-item">
+        <span class="camera-label">Y:</span>
+        <span class="camera-value">{{ cameraInfo.target.y }}</span>
+      </div>
+      <div class="camera-item">
+        <span class="camera-label">Z:</span>
+        <span class="camera-value">{{ cameraInfo.target.z }}</span>
+      </div>
+    </div>
+    <div class="camera-section">
+      <div class="section-label">缩放距离 (m)</div>
+      <div class="camera-item">
+        <span class="camera-label">当前:</span>
+        <span class="camera-value">{{ cameraInfo.distance }}</span>
+      </div>
+      <div class="camera-item">
+        <span class="camera-label">最近:</span>
+        <span class="camera-value">{{ cameraInfo.minDistance }}m</span>
+      </div>
+      <div class="camera-item">
+        <span class="camera-label">最远:</span>
+        <span class="camera-value">{{ cameraInfo.maxDistance }}m</span>
+      </div>
+    </div>
+  </div>
+
+  <div v-if="showLightPanel" class="panel light-panel">
+    <div class="panel-header">
+      <span class="panel-title">光照旋转</span>
+      <button class="toggle-btn" @click="showLightPanel = false">×</button>
+    </div>
+    <div class="light-section">
+      <div class="section-label">绕 X 轴</div>
+      <div class="slider-item">
+        <input type="range" v-model.number="lightRotation.x" min="-180" max="180" step="1" />
+        <span class="slider-value">{{ lightRotation.x }}°</span>
+      </div>
+    </div>
+    <div class="light-section">
+      <div class="section-label">绕 Y 轴</div>
+      <div class="slider-item">
+        <input type="range" v-model.number="lightRotation.y" min="-180" max="180" step="1" />
+        <span class="slider-value">{{ lightRotation.y }}°</span>
+      </div>
+    </div>
+    <div class="light-section">
+      <div class="section-label">绕 Z 轴</div>
+      <div class="slider-item">
+        <input type="range" v-model.number="lightRotation.z" min="-180" max="180" step="1" />
+        <span class="slider-value">{{ lightRotation.z }}°</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.scene-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+}
+
+.toolbar {
+  position: fixed;
+  top: 20px;
+  right: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  z-index: 1001;
+}
+
+.toolbar-btn {
+  padding: 10px 16px;
+  border: none;
+  border-radius: 8px;
+  background: rgba(0, 0, 0, 0.85);
+  color: #888;
+  font-size: 13px;
+  font-weight: bold;
+  cursor: pointer;
+  transition: all 0.2s;
+  min-width: 70px;
+}
+
+.toolbar-btn:hover {
+  background: rgba(30, 30, 30, 0.9);
+  color: #fff;
+}
+
+.toolbar-btn.active {
+  color: #fff;
+  box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
+}
+
+.toolbar-btn:nth-child(1).active { color: #4fc3f7; }
+.toolbar-btn:nth-child(2).active { color: #ff9800; }
+.toolbar-btn:nth-child(3).active { color: #ffeb3b; }
+
+.panel {
+  position: fixed;
+  top: 20px;
+  left: 20px;
+  background: rgba(0, 0, 0, 0.85);
+  color: white;
+  padding: 15px;
+  border-radius: 8px;
+  font-family: 'Courier New', monospace;
+  z-index: 1000;
+  max-height: calc(100vh - 40px);
+  overflow-y: auto;
+}
+
+.coordinate-panel { min-width: 200px; }
+.camera-panel { min-width: 200px; }
+.light-panel { min-width: 240px; }
+
+.light-panel .slider-item input[type="range"]::-webkit-slider-thumb {
+  background: #ffeb3b;
+}
+
+.panel-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
+}
+
+.panel-title {
+  font-weight: bold;
+  font-size: 14px;
+}
+
+.coordinate-panel .panel-title { color: #4fc3f7; }
+.camera-panel .panel-title { color: #ff9800; }
+.light-panel .panel-title { color: #ffeb3b; }
+
+.toggle-btn {
+  width: 22px;
+  height: 22px;
+  border: none;
+  border-radius: 4px;
+  background: rgba(255, 255, 255, 0.15);
+  color: white;
+  font-size: 14px;
+  line-height: 1;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.2s;
+}
+
+.toggle-btn:hover {
+  background: rgba(255, 255, 255, 0.3);
+}
+
+.coordinate-item {
+  display: flex;
+  justify-content: space-between;
+  margin: 5px 0;
+}
+
+.coordinate-label {
+  color: #81c784;
+  font-weight: bold;
+}
+
+.coordinate-value {
+  color: #fff;
+}
+
+.camera-section,
+.light-section {
+  margin-bottom: 10px;
+}
+
+.section-label {
+  color: #81c784;
+  font-size: 11px;
+  margin-bottom: 5px;
+  font-weight: bold;
+}
+
+.camera-item {
+  display: flex;
+  justify-content: space-between;
+  margin: 3px 0;
+}
+
+.camera-label {
+  color: #4fc3f7;
+}
+
+.camera-value {
+  color: #fff;
+}
+</style>

+ 18 - 14
src/scenes/Scene3D.vue

@@ -407,12 +407,12 @@ function initLabelData() {
 initLabelData()
 
 // ========== Three.js 核心变量声明 ==========
-let scene: THREE.Scene
-let camera: THREE.PerspectiveCamera
-let renderer: THREE.WebGLRenderer
-let controls: OrbitControls
-let sky: Sky
-let waterMesh: THREE.Mesh
+let scene!: THREE.Scene
+let camera!: THREE.PerspectiveCamera
+let renderer!: THREE.WebGLRenderer
+let controls!: OrbitControls
+let sky!: Sky
+let waterMesh!: THREE.Mesh
 let water2Mesh: THREE.Mesh | null = null
 let foamMesh: THREE.Mesh | null = null
 let foamMaterial: THREE.ShaderMaterial | null = null
@@ -429,7 +429,7 @@ let animationId: number
 let tilesRenderer: SuperMapTilesRenderer | null = null
 let raycaster: THREE.Raycaster
 let mouse: THREE.Vector2
-let depthRenderTarget: THREE.WebGLRenderTarget
+let depthRenderTarget!: THREE.WebGLRenderTarget
 let sceneInitialized = false
 
 // 创建天空背景(含体积云效果)
@@ -855,7 +855,7 @@ function disposeScene() {
   if (waterMesh) {
     if (scene) scene.remove(waterMesh)
     waterMesh.geometry.dispose()
-    waterMesh = null
+    waterMesh = null as any
   }
 
   if (water2Mesh) {
@@ -871,18 +871,18 @@ function disposeScene() {
   if (sky) {
     if (scene) scene.remove(sky)
     sky.material.dispose()
-    sky = null
+    sky = null as any
   }
 
   if (depthRenderTarget) {
     depthRenderTarget.depthTexture?.dispose()
     depthRenderTarget.dispose()
-    depthRenderTarget = null
+    depthRenderTarget = null as any
   }
 
   if (controls) {
     controls.dispose()
-    controls = null
+    controls = null as any
   }
 
   if (renderer) {
@@ -892,17 +892,17 @@ function disposeScene() {
     }
     renderer.forceContextLoss()
     renderer.dispose()
-    renderer = null
+    renderer = null as any
   }
 
   if (scene) {
     while (scene.children.length > 0) {
       scene.remove(scene.children[0])
     }
-    scene = null
+    scene = null as any
   }
 
-  camera = null
+  camera = null as any
   sceneInitialized = false
 }
 
@@ -1297,6 +1297,9 @@ function flyToPreset(presetId: string, durationMs: number = 1500) {
   )
 }
 
+// Suppress TS6133 for functions not directly used in template
+void [loadFlowDefaults, loadFoamDefaults, loadWaterDefaults, loadWater02Defaults, loadCscwaterDefaults]
+
 defineExpose({
   labelDataList,
   labelValues,
@@ -1317,6 +1320,7 @@ defineExpose({
     :key="data.config.id"
     :ref="(el: any) => { if (el) data.componentRef.value = el }"
     :label-id="data.config.id"
+    :name="data.config.name"
     :type="data.config.type"
     :position-x="data.config.positionX"
     :position-y="data.config.positionY"

+ 3 - 2
src/scenes/WaterLevelLabel.vue

@@ -184,7 +184,7 @@ function createTooltip() {
   tooltipSprite.scale.set(0, 0, 1)
   tooltipSprite.renderOrder = 1000
   tooltipSprite.name = `tooltip_${props.labelId}`
-  scene.add(tooltipSprite)
+  if (scene) scene.add(tooltipSprite)
 }
 
 function init(s: THREE.Scene, c: THREE.PerspectiveCamera) {
@@ -213,7 +213,8 @@ function tick() {
 
   if (tooltipSprite) {
     const tooltipScaleH = s * 1.0
-    const tooltipScaleW = tooltipScaleH * (tooltipSprite.material.map!.image.width / tooltipSprite.material.map!.image.height) * (ICON_H / ICON_W)
+    const img = tooltipSprite.material.map!.image as HTMLImageElement
+    const tooltipScaleW = tooltipScaleH * (img.width / img.height) * (ICON_H / ICON_W)
     if (isHovered) {
       tooltipSprite.scale.set(tooltipScaleW, tooltipScaleH, 1)
       tooltipSprite.position.y = props.positionY + (s * 2.2) + tooltipScaleH / 2 + 0.3

+ 1 - 1
src/utils/coordinateDebug.ts

@@ -102,7 +102,7 @@ export function testCoordinateConversions(model: THREE.Object3D): Record<string,
 /**
  * 创建坐标系调试面板
  */
-export function createDebugPanel(info: CoordinateDebugInfo): THREE.Group {
+export function createDebugPanel(_info: CoordinateDebugInfo): THREE.Group {
   const panel = new THREE.Group()
   
   // 创建信息文本(使用简单的几何体表示)

+ 9 - 0
src/utils/geoCoord.ts

@@ -1,6 +1,15 @@
 import * as THREE from 'three'
+// @ts-ignore
 import LatLon, { Cartesian } from 'geodesy/latlon-ellipsoidal.js'
 
+/** POI 配置 */
+export interface POIConfig {
+  lng: number
+  lat: number
+  label: string
+  color: string
+}
+
 /**
  * 经纬度 → ECEF 地心直角坐标 (WGS84)
  * 使用 geodesy 库进行精确的椭球体计算