瀏覽代碼

项目结构优化 新增渡槽场景

BAI 4 周之前
父節點
當前提交
b6c3ea47e2

+ 79 - 1
package-lock.json

@@ -11,8 +11,10 @@
         "@loaders.gl/3d-tiles": "^4.4.1",
         "@loaders.gl/core": "^4.4.1",
         "@loaders.gl/gltf": "^4.4.1",
+        "@loaders.gl/terrain": "^4.4.2",
         "3d-tiles-renderer": "^0.4.24",
         "three": "^0.184.0",
+        "three-tile": "^0.11.14",
         "vue": "^3.5.32"
       },
       "devDependencies": {
@@ -292,6 +294,64 @@
         "@loaders.gl/core": "~4.4.0"
       }
     },
+    "node_modules/@loaders.gl/terrain": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmmirror.com/@loaders.gl/terrain/-/terrain-4.4.2.tgz",
+      "integrity": "sha512-ScE90mhUrIOOf+248+G8bxgg5xfLptE94gVxtYsLysyG8b4Ne2WEb6J2gpvQqmaLz3k9OqgPR7M8F1zI5BVO0w==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/images": "4.4.2",
+        "@loaders.gl/loader-utils": "4.4.2",
+        "@loaders.gl/schema": "4.4.2",
+        "@mapbox/martini": "^0.2.0"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "~4.4.0"
+      }
+    },
+    "node_modules/@loaders.gl/terrain/node_modules/@loaders.gl/images": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmmirror.com/@loaders.gl/images/-/images-4.4.2.tgz",
+      "integrity": "sha512-b+1keNvPlyLniWtX4ZaThz2dF2aohi8Q+OEsDF2hJNZYyZJOqP9b/72UhlVk+inxTJfTLRBNARs2TJ2ssBlelg==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/loader-utils": "4.4.2"
+      },
+      "peerDependencies": {
+        "@loaders.gl/core": "~4.4.0"
+      }
+    },
+    "node_modules/@loaders.gl/terrain/node_modules/@loaders.gl/loader-utils": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmmirror.com/@loaders.gl/loader-utils/-/loader-utils-4.4.2.tgz",
+      "integrity": "sha512-kqwBbyRC7rrQVsnJyKeoaig9hxaa5oj91OKqWm27HPuVn4q2dD67SEhiG0ND62eRp0tLY6jTqEcI5kDzHBZ6MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@loaders.gl/schema": "4.4.2",
+        "@loaders.gl/worker-utils": "4.4.2",
+        "@probe.gl/log": "^4.1.1",
+        "@probe.gl/stats": "^4.1.1"
+      }
+    },
+    "node_modules/@loaders.gl/terrain/node_modules/@loaders.gl/schema": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmmirror.com/@loaders.gl/schema/-/schema-4.4.2.tgz",
+      "integrity": "sha512-mJTZehTHIFl8ed+03nebuPAMnLP8Yp00DKTzCnKT2HNy/uV4+Sw+GrGIuhPHGU8tdQmtBXRURGM2ZxUAxMfGKg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "^7946.0.7",
+        "apache-arrow": ">= 17.0.0"
+      }
+    },
+    "node_modules/@loaders.gl/terrain/node_modules/@loaders.gl/worker-utils": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmmirror.com/@loaders.gl/worker-utils/-/worker-utils-4.4.2.tgz",
+      "integrity": "sha512-oiZ0SoC1QKrOkhYPlVZ6Q06CtmuFRyZw2rwzmT08ZyaGtOArIJHDjlhxzwWiv+6fdws47Ub5uIGsdI1Ab1xYsA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@loaders.gl/core": "~4.4.0"
+      }
+    },
     "node_modules/@loaders.gl/textures": {
       "version": "4.4.1",
       "resolved": "https://registry.npmmirror.com/@loaders.gl/textures/-/textures-4.4.1.tgz",
@@ -353,6 +413,12 @@
         "@loaders.gl/core": "~4.4.0"
       }
     },
+    "node_modules/@mapbox/martini": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/@mapbox/martini/-/martini-0.2.0.tgz",
+      "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==",
+      "license": "ISC"
+    },
     "node_modules/@math.gl/core": {
       "version": "4.1.0",
       "resolved": "https://registry.npmmirror.com/@math.gl/core/-/core-4.1.0.tgz",
@@ -1956,6 +2022,18 @@
       "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
       "license": "MIT"
     },
+    "node_modules/three-tile": {
+      "version": "0.11.14",
+      "resolved": "https://registry.npmmirror.com/three-tile/-/three-tile-0.11.14.tgz",
+      "integrity": "sha512-Bpg43DiUqcZQ4RLFLEzhjwaIm1MORFgSxHfyJ6PrkrmziL+SN6NL46VJl21r/TGsSu/6XfdPaRne8xaeZox3iw==",
+      "license": "MIT",
+      "workspaces": [
+        "packages/*"
+      ],
+      "peerDependencies": {
+        "three": "0.183.1"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.16",
       "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -1983,7 +2061,7 @@
       "version": "6.0.3",
       "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
       "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
-      "devOptional": true,
+      "dev": true,
       "license": "Apache-2.0",
       "bin": {
         "tsc": "bin/tsc",

+ 2 - 0
package.json

@@ -12,8 +12,10 @@
     "@loaders.gl/3d-tiles": "^4.4.1",
     "@loaders.gl/core": "^4.4.1",
     "@loaders.gl/gltf": "^4.4.1",
+    "@loaders.gl/terrain": "^4.4.2",
     "3d-tiles-renderer": "^0.4.24",
     "three": "^0.184.0",
+    "three-tile": "^0.11.14",
     "vue": "^3.5.32"
   },
   "devDependencies": {

二進制
public/ducao/NoLod_0.glb


二進制
public/ducao/NoLod_1.glb


二進制
public/ducao/NoLod_2.glb


二進制
public/ducao/NoLod_3.glb


+ 1 - 0
public/ducao/scenetree.json

@@ -0,0 +1 @@
+{"scenes":[{"children":[{"id":"a1d0c6e83f027327d8461063f4ac58a6","name":"Object017","sphere":[-2177779.0965344813,4388734.532363985,4070039.7289013555,66656.8205045041],"type":"element"}],"id":"45c48cce2e2d7fbdea1afc51c7c6ad26","name":"渡槽场景简化版","sphere":[-2177779.0965344813,4388734.532363985,4070039.7289013555,66656.8205045041],"type":"node"}]}

+ 1 - 0
public/ducao/tileset.json

@@ -0,0 +1 @@
+{"asset":{"generatetool":"cesiumlab3@www.cesiumlab.com/model2tiles","version":"1.1"},"extras":{"scenetree":"scenetree.json"},"geometricError":48000.36314303405,"properties":null,"refine":"REPLACE","root":{"boundingVolume":{"box":[1.1641532182693481e-10,-3.4184252433478832,-31.099897640757263,24000.181571517023,0,0,0,13926.768893517088,0,0,0,35.9544527977705]},"children":[{"boundingVolume":{"box":[-3117.2548828125,-2382.884765624999,2.2618405818936895,20793.6845703125,0,0,0,11530.575195312496,0,0,0,2.599972009658909]},"content":{"uri":"NoLod_0.glb"},"geometricError":0.0,"refine":"REPLACE"},{"boundingVolume":{"box":[-3107.7529296875,-2394.982910156249,-9.598135471343543,20798.7353515625,0,0,0,11535.434082031246,0,0,0,14.460008144378218]},"content":{"uri":"NoLod_1.glb"},"geometricError":0.0,"refine":"REPLACE"},{"boundingVolume":{"box":[-6680.5167236328125,733.6595458984373,5.454745531082603,8985.504760742188,0,0,0,3336.7984619140616,0,0,0,0.59438109397844]},"content":{"uri":"NoLod_2.glb"},"geometricError":0.0,"refine":"REPLACE"},{"boundingVolume":{"box":[0.0,0.0,-0.9957051277160294,23912.7890625,0,0,0,13923.339843749996,0,0,0,5.857591152191333]},"content":{"uri":"NoLod_3.glb"},"geometricError":0.0,"refine":"REPLACE"}],"geometricError":48000.36314303405,"transform":[-0.8957798079969456,-0.44449807151995063,0.0,0.0,0.2851642479174557,-0.5746805028278432,0.7670877859666446,0.0,-0.3409690415486823,0.6871417496300031,0.6415421487484598,0.0,-2177749.4494034126,4388734.4144854965,4070062.242923888,1.0]}}

二進制
public/ducao2.7z


+ 68 - 2
src/App.vue

@@ -1,7 +1,73 @@
 <script setup lang="ts">
-import Scene3D from './components/Scene3D.vue'
+import { ref } from 'vue'
+import Scene3D from './scenes/Scene3D.vue'
+import DucaoScene from './scenes/DucaoScene.vue'
+
+const currentScene = ref<'scene3d' | 'ducao'>('ducao')
 </script>
 
 <template>
-  <Scene3D :show-debug-tools="true" />
+  <div class="app-root">
+    <div class="scene-switcher">
+      <button
+        class="switch-btn"
+        :class="{ active: currentScene === 'scene3d' }"
+        @click="currentScene = 'scene3d'"
+      >
+        主场景
+      </button>
+      <button
+        class="switch-btn"
+        :class="{ active: currentScene === 'ducao' }"
+        @click="currentScene = 'ducao'"
+      >
+        渡槽场景
+      </button>
+    </div>
+    <Scene3D v-if="currentScene === 'scene3d'" :show-debug-tools="true" />
+    <DucaoScene v-else :show-debug-tools="true" />
+  </div>
 </template>
+
+<style scoped>
+.app-root {
+  width: 100vw;
+  height: 100vh;
+  position: relative;
+}
+
+.scene-switcher {
+  position: fixed;
+  bottom: 12px;
+  right: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  z-index: 9999;
+}
+
+.switch-btn {
+  padding: 6px 14px;
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  border-radius: 8px;
+  background: rgba(0, 0, 0, 0.5);
+  color: rgba(255, 255, 255, 0.7);
+  font-size: 13px;
+  cursor: pointer;
+  transition: all 0.2s;
+  backdrop-filter: blur(4px);
+  font-family: 'Microsoft YaHei', sans-serif;
+  white-space: nowrap;
+}
+
+.switch-btn:hover {
+  background: rgba(255, 255, 255, 0.15);
+  color: #fff;
+}
+
+.switch-btn.active {
+  background: rgba(77, 208, 225, 0.2);
+  border-color: #4dd0e1;
+  color: #4dd0e1;
+}
+</style>

二進制
src/assets/icon/05个.png


二進制
src/assets/icon/yingli.png


+ 0 - 95
src/components/HelloWorld.vue

@@ -1,95 +0,0 @@
-<script setup lang="ts">
-import { ref } from 'vue'
-import viteLogo from '../assets/vite.svg'
-import heroImg from '../assets/hero.png'
-import vueLogo from '../assets/vue.svg'
-
-const count = ref(0)
-</script>
-
-<template>
-  <section id="center">
-    <div class="hero">
-      <img :src="heroImg" class="base" width="170" height="179" alt="" />
-      <img :src="vueLogo" class="framework" alt="Vue logo" />
-      <img :src="viteLogo" class="vite" alt="Vite logo" />
-    </div>
-    <div>
-      <h1>Get started</h1>
-      <p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
-    </div>
-    <button type="button" class="counter" @click="count++">
-      Count is {{ count }}
-    </button>
-  </section>
-
-  <div class="ticks"></div>
-
-  <section id="next-steps">
-    <div id="docs">
-      <svg class="icon" role="presentation" aria-hidden="true">
-        <use href="/icons.svg#documentation-icon"></use>
-      </svg>
-      <h2>Documentation</h2>
-      <p>Your questions, answered</p>
-      <ul>
-        <li>
-          <a href="https://vite.dev/" target="_blank">
-            <img class="logo" :src="viteLogo" alt="" />
-            Explore Vite
-          </a>
-        </li>
-        <li>
-          <a href="https://vuejs.org/" target="_blank">
-            <img class="button-icon" :src="vueLogo" alt="" />
-            Learn more
-          </a>
-        </li>
-      </ul>
-    </div>
-    <div id="social">
-      <svg class="icon" role="presentation" aria-hidden="true">
-        <use href="/icons.svg#social-icon"></use>
-      </svg>
-      <h2>Connect with us</h2>
-      <p>Join the Vite community</p>
-      <ul>
-        <li>
-          <a href="https://github.com/vitejs/vite" target="_blank">
-            <svg class="button-icon" role="presentation" aria-hidden="true">
-              <use href="/icons.svg#github-icon"></use>
-            </svg>
-            GitHub
-          </a>
-        </li>
-        <li>
-          <a href="https://chat.vite.dev/" target="_blank">
-            <svg class="button-icon" role="presentation" aria-hidden="true">
-              <use href="/icons.svg#discord-icon"></use>
-            </svg>
-            Discord
-          </a>
-        </li>
-        <li>
-          <a href="https://x.com/vite_js" target="_blank">
-            <svg class="button-icon" role="presentation" aria-hidden="true">
-              <use href="/icons.svg#x-icon"></use>
-            </svg>
-            X.com
-          </a>
-        </li>
-        <li>
-          <a href="https://bsky.app/profile/vite.dev" target="_blank">
-            <svg class="button-icon" role="presentation" aria-hidden="true">
-              <use href="/icons.svg#bluesky-icon"></use>
-            </svg>
-            Bluesky
-          </a>
-        </li>
-      </ul>
-    </div>
-  </section>
-
-  <div class="ticks"></div>
-  <section id="spacer"></section>
-</template>

+ 0 - 359
src/components/TestFoamScene.vue

@@ -1,359 +0,0 @@
-<script setup lang="ts">
-import { onMounted, onUnmounted, ref } from 'vue'
-import * as THREE from 'three'
-import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
-import foamTexUrl from '../assets/texture/T_Waterfall_Foam.PNG'
-import directionalFoamTexUrl from '../assets/texture/T_Waterfall_Foam_Directional.PNG'
-import { createWaterFoamUEMaterial } from './waterFoamUE'
-
-const containerRef = ref<HTMLDivElement>()
-
-const foamColor = ref('#ffffff')
-const opacityValue = ref(0.5)
-const waterfallSpeed = ref(0.5)
-const edgeMaskTiling = ref(0.5)
-const edgeMaskSpeed = ref(2.0)
-const fresnelExponent = ref(6.0)
-
-const directionalFoamIntensity = ref(0.3)
-const directionalFoamContrast = ref(2.5)
-const directionalFoam1Intensity = ref(2.0)
-const directionalFoam2Intensity = ref(2.0)
-const directionalFoam2Tiling = ref(1.0)
-const directionalFoam2Speed = ref(10.0)
-const directionalFoam3Intensity = ref(0.5)
-const foamFalloff = ref(2.0)
-
-const showPanel = ref(true)
-
-let scene: THREE.Scene
-let camera: THREE.PerspectiveCamera
-let renderer: THREE.WebGLRenderer
-let controls: OrbitControls
-let material: THREE.ShaderMaterial
-let animationId: number
-
-function initScene() {
-  const container = containerRef.value!
-
-  scene = new THREE.Scene()
-  scene.background = new THREE.Color(0x1a1a2e)
-
-  camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 100)
-  camera.position.set(0, 0, 2.5)
-
-  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))
-  container.appendChild(renderer.domElement)
-
-  controls = new OrbitControls(camera, renderer.domElement)
-  controls.enableDamping = true
-  controls.dampingFactor = 0.05
-  controls.target.set(0, 0, 0)
-
-  const textureLoader = new THREE.TextureLoader()
-  const foamTexture = textureLoader.load(foamTexUrl)
-  foamTexture.wrapS = THREE.RepeatWrapping
-  foamTexture.wrapT = THREE.RepeatWrapping
-
-  const directionalFoamTexture = textureLoader.load(directionalFoamTexUrl)
-  directionalFoamTexture.wrapS = THREE.RepeatWrapping
-  directionalFoamTexture.wrapT = THREE.RepeatWrapping
-
-  material = createWaterFoamUEMaterial({
-    colour: new THREE.Color(foamColor.value),
-    opacity: opacityValue.value,
-    waterfallSpeed: waterfallSpeed.value,
-    edgeMaskTiling: edgeMaskTiling.value,
-    edgeMaskSpeed: edgeMaskSpeed.value,
-    fresnelExponent: fresnelExponent.value,
-    directionalFoamIntensity: directionalFoamIntensity.value,
-    directionalFoamContrast: directionalFoamContrast.value,
-    directionalFoam1Intensity: directionalFoam1Intensity.value,
-    directionalFoam2Intensity: directionalFoam2Intensity.value,
-    directionalFoam2Tiling: directionalFoam2Tiling.value,
-    directionalFoam2Speed: directionalFoam2Speed.value,
-    directionalFoam3Intensity: directionalFoam3Intensity.value,
-    foamFalloff: foamFalloff.value,
-    foamTexture,
-    directionalFoamTexture,
-  })
-
-  const geometry = new THREE.PlaneGeometry(3, 3)
-  const mesh = new THREE.Mesh(geometry, material)
-  scene.add(mesh)
-
-  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
-  scene.add(ambientLight)
-
-  animate()
-}
-
-function animate() {
-  animationId = requestAnimationFrame(animate)
-  material.uniforms.uTime.value += 0.016
-  controls.update()
-  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)
-}
-
-function updateColor(color: string) {
-  material.uniforms.uColour.value.set(color)
-}
-
-onMounted(() => {
-  initScene()
-  window.addEventListener('resize', onResize)
-})
-
-onUnmounted(() => {
-  cancelAnimationFrame(animationId)
-  window.removeEventListener('resize', onResize)
-  if (containerRef.value) {
-    containerRef.value.removeChild(renderer.domElement)
-  }
-  renderer.dispose()
-  controls.dispose()
-})
-</script>
-
-<template>
-  <div ref="containerRef" class="scene-container" />
-  <div v-if="showPanel" class="control-panel">
-    <div class="panel-header">
-      <span class="panel-title">UE瀑布泡沫材质 · Three.js复刻</span>
-      <button class="close-btn" @click="showPanel = false">×</button>
-    </div>
-
-    <div class="section">基础参数</div>
-    <div class="control-item">
-      <label>颜色</label>
-      <input type="color" :value="foamColor" @input="foamColor = ($event.target as HTMLInputElement).value; updateColor(foamColor)" />
-      <span class="value-display">{{ foamColor }}</span>
-    </div>
-    <div class="control-item">
-      <label>不透明度</label>
-      <input type="range" min="0" max="1" step="0.01" :value="opacityValue" @input="opacityValue = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uOpacity.value = opacityValue" />
-      <span class="value-display">{{ opacityValue.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>瀑布流速</label>
-      <input type="range" min="0" max="2" step="0.01" :value="waterfallSpeed" @input="waterfallSpeed = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uWaterfallSpeed.value = waterfallSpeed" />
-      <span class="value-display">{{ waterfallSpeed.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>菲涅尔指数</label>
-      <input type="range" min="0" max="20" step="0.1" :value="fresnelExponent" @input="fresnelExponent = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uFresnelExponent.value = fresnelExponent" />
-      <span class="value-display">{{ fresnelExponent.toFixed(1) }}</span>
-    </div>
-
-    <div class="section">边缘遮罩</div>
-    <div class="control-item">
-      <label>平铺</label>
-      <input type="range" min="0.1" max="2" step="0.01" :value="edgeMaskTiling" @input="edgeMaskTiling = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uEdgeMaskTiling.value = edgeMaskTiling" />
-      <span class="value-display">{{ edgeMaskTiling.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>速度</label>
-      <input type="range" min="0" max="10" step="0.1" :value="edgeMaskSpeed" @input="edgeMaskSpeed = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uEdgeMaskSpeed.value = edgeMaskSpeed" />
-      <span class="value-display">{{ edgeMaskSpeed.toFixed(1) }}</span>
-    </div>
-
-    <div class="section">方向泡沫</div>
-    <div class="control-item">
-      <label>强度</label>
-      <input type="range" min="0" max="5" step="0.01" :value="directionalFoamIntensity" @input="directionalFoamIntensity = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoamIntensity.value = directionalFoamIntensity" />
-      <span class="value-display">{{ directionalFoamIntensity.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>对比度</label>
-      <input type="range" min="0.1" max="10" step="0.1" :value="directionalFoamContrast" @input="directionalFoamContrast = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoamContrast.value = directionalFoamContrast" />
-      <span class="value-display">{{ directionalFoamContrast.toFixed(1) }}</span>
-    </div>
-    <div class="control-item">
-      <label>层1强度</label>
-      <input type="range" min="0" max="5" step="0.01" :value="directionalFoam1Intensity" @input="directionalFoam1Intensity = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoam1Intensity.value = directionalFoam1Intensity" />
-      <span class="value-display">{{ directionalFoam1Intensity.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>层2强度</label>
-      <input type="range" min="0" max="5" step="0.01" :value="directionalFoam2Intensity" @input="directionalFoam2Intensity = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoam2Intensity.value = directionalFoam2Intensity" />
-      <span class="value-display">{{ directionalFoam2Intensity.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>层2平铺</label>
-      <input type="range" min="0.1" max="5" step="0.01" :value="directionalFoam2Tiling" @input="directionalFoam2Tiling = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoam2Tiling.value = directionalFoam2Tiling" />
-      <span class="value-display">{{ directionalFoam2Tiling.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>层2速度</label>
-      <input type="range" min="0" max="50" step="0.5" :value="directionalFoam2Speed" @input="directionalFoam2Speed = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoam2Speed.value = directionalFoam2Speed" />
-      <span class="value-display">{{ directionalFoam2Speed.toFixed(1) }}</span>
-    </div>
-    <div class="control-item">
-      <label>层3强度</label>
-      <input type="range" min="0" max="5" step="0.01" :value="directionalFoam3Intensity" @input="directionalFoam3Intensity = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uDirectionalFoam3Intensity.value = directionalFoam3Intensity" />
-      <span class="value-display">{{ directionalFoam3Intensity.toFixed(2) }}</span>
-    </div>
-    <div class="control-item">
-      <label>泡沫衰减</label>
-      <input type="range" min="0.1" max="10" step="0.1" :value="foamFalloff" @input="foamFalloff = parseFloat(($event.target as HTMLInputElement).value); material.uniforms.uFoamFalloff.value = foamFalloff" />
-      <span class="value-display">{{ foamFalloff.toFixed(1) }}</span>
-    </div>
-
-    <div class="hint">拖拽旋转 · 滚轮缩放</div>
-  </div>
-</template>
-
-<style scoped>
-.scene-container {
-  width: 100vw;
-  height: 100vh;
-  overflow: hidden;
-}
-
-.control-panel {
-  position: fixed;
-  top: 20px;
-  right: 20px;
-  background: rgba(20, 20, 40, 0.85);
-  backdrop-filter: blur(12px);
-  border: 1px solid rgba(255, 255, 255, 0.1);
-  border-radius: 12px;
-  padding: 16px 20px;
-  min-width: 220px;
-  color: #fff;
-  font-family: 'Segoe UI', system-ui, sans-serif;
-  font-size: 13px;
-  user-select: none;
-  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
-}
-
-.panel-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 8px;
-}
-
-.panel-title {
-  font-weight: 600;
-  font-size: 14px;
-  color: #8cf0ff;
-}
-
-.close-btn {
-  background: none;
-  border: none;
-  color: rgba(255, 255, 255, 0.5);
-  font-size: 18px;
-  cursor: pointer;
-  padding: 0 4px;
-  line-height: 1;
-}
-
-.close-btn:hover {
-  color: #fff;
-}
-
-.section {
-  font-size: 11px;
-  font-weight: 600;
-  color: rgba(255, 255, 255, 0.4);
-  text-transform: uppercase;
-  letter-spacing: 0.5px;
-  margin: 12px 0 8px;
-  padding-top: 8px;
-  border-top: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.section:first-of-type {
-  margin-top: 0;
-  border-top: none;
-  padding-top: 0;
-}
-
-.control-item {
-  margin-bottom: 10px;
-}
-
-.control-item label {
-  display: block;
-  margin-bottom: 4px;
-  color: rgba(255, 255, 255, 0.7);
-  font-size: 12px;
-}
-
-.toggle-label {
-  display: flex !important;
-  align-items: center;
-  gap: 8px;
-  cursor: pointer;
-  color: rgba(255, 255, 255, 0.85) !important;
-  font-size: 12px;
-}
-
-.toggle-label input[type="checkbox"] {
-  width: 16px;
-  height: 16px;
-  accent-color: #8cf0ff;
-  cursor: pointer;
-}
-
-.control-item input[type="range"] {
-  width: 100%;
-  height: 4px;
-  -webkit-appearance: none;
-  appearance: none;
-  background: rgba(255, 255, 255, 0.15);
-  border-radius: 2px;
-  outline: none;
-}
-
-.control-item input[type="range"]::-webkit-slider-thumb {
-  -webkit-appearance: none;
-  appearance: none;
-  width: 14px;
-  height: 14px;
-  border-radius: 50%;
-  background: #8cf0ff;
-  cursor: pointer;
-}
-
-.control-item input[type="color"] {
-  width: 100%;
-  height: 28px;
-  border: none;
-  border-radius: 6px;
-  cursor: pointer;
-  background: none;
-  padding: 0;
-}
-
-.value-display {
-  display: block;
-  text-align: right;
-  color: rgba(255, 255, 255, 0.4);
-  font-size: 11px;
-  margin-top: 2px;
-  font-family: 'Consolas', monospace;
-}
-
-.hint {
-  margin-top: 10px;
-  text-align: center;
-  color: rgba(255, 255, 255, 0.3);
-  font-size: 11px;
-}
-</style>

+ 63 - 10
src/config/sceneConfig.ts

@@ -119,18 +119,29 @@ export const modelTransformMap: Record<string, ModelTransform> = {
     scaleY: 1,
     scaleZ: 1,
   },
+  water2: {
+    positionX: -2185.1,
+    positionY: 24,
+    positionZ: -961,
+    rotationX: 90,
+    rotationY: 11,
+    rotationZ: 0,
+    scaleX: 18,
+    scaleY: 300,
+    scaleZ: 200,
+  },
 }
 
 // ==================== 材质参数默认值 ====================
 
 /** 水面材质参数默认值 */
 export const defaultWaterParams: WaterMaterialParams = {
-  alpha: 0.85,
-  waterColor: '#566c67',
+  alpha: 0.95,
+  waterColor: '#566a6c',
   deepColor: '#0a2a4a',
-  flowSpeed: 0.5,
-  flowDirectionX: 1.0,
-  flowDirectionY: 1.0,
+  flowSpeed: 3.0,
+  flowDirectionX: -2.1,
+  flowDirectionY: -1.0,
   waveHeight: 0.6,
   foamIntensity: 0.25,
   specIntensity: 2.0,
@@ -146,9 +157,9 @@ export const defaultWaterParams: WaterMaterialParams = {
 
 /** CSCwater 材质参数默认值 */
 export const defaultCscwaterParams: CscwaterMaterialParams = {
-  alpha: 0.82,
-  waterColor: '#29537a',
-  deepColor: '#0a2a4a',
+  alpha: 0.92,
+  waterColor: '#296a7a',
+  deepColor: '#0b3660',
   flowSpeed: 0,
   flowDirectionX: 1.0,
   flowDirectionY: 1.0,
@@ -205,7 +216,7 @@ export const defaultFlowParams: FlowMaterialParams = {
 // ==================== 水位标签配置 ====================
 
 /** 标签数据类型 */
-export type LabelDataType = 'waterLevel' | 'flowRate'
+export type LabelDataType = 'waterLevel' | 'flowRate' | 'StressMonitor'
 
 /** 标签类型对应的展示配置 */
 export interface LabelTypeDisplayConfig {
@@ -229,25 +240,36 @@ export const labelTypeRegistry: Record<LabelDataType, LabelTypeDisplayConfig> =
     label: '流 量',
     decimalPlaces: 2,
   },
+  StressMonitor: {
+    icon: 'yingli.png',
+    unit: '',
+    label: '应力监测',
+    decimalPlaces: 2,
+  },
 }
 
+/** 场景类型 */
+export type SceneType = 'main' | 'ducao'
+
 /** 水位标签配置类型 */
 export interface WaterLevelLabelConfig {
   id: string
   name: string
   type: LabelDataType
+  scene: SceneType
   positionX: number
   positionY: number
   positionZ: number
   initialValue: number
 }
 
-/** 水位标签列表(可扩展多个) */
+/** 所有水位标签列表(两个场景的标签都在这里) */
 export const waterLevelLabels: WaterLevelLabelConfig[] = [
   {
     id: '6602380005',
     name: '莫勒切河节制分水闸闸后水量监测',
     type: 'waterLevel',
+    scene: 'main',
     positionX: -797.282,
     positionY: 14,
     positionZ: -2091.159,
@@ -257,6 +279,7 @@ export const waterLevelLabels: WaterLevelLabelConfig[] = [
     id: '6602380006',
     name: '莫勒切河引水渠水量监测',
     type: 'waterLevel',
+    scene: 'main',
     positionX: -921.134,
     positionY: 14,
     positionZ: -2124.428,
@@ -266,6 +289,7 @@ export const waterLevelLabels: WaterLevelLabelConfig[] = [
     id: '6602380003',
     name: '二期沉砂池入库水量监测',
     type: 'waterLevel',
+    scene: 'main',
     positionX: -2162.313,
     positionY: 23.156,
     positionZ: -856.39,
@@ -275,6 +299,7 @@ export const waterLevelLabels: WaterLevelLabelConfig[] = [
     id: '6602380001',
     name: '二期沉砂池库内水位监测',
     type: 'waterLevel',
+    scene: 'main',
     positionX: -781.103,
     positionY: 27.499,
     positionZ: -194.081,
@@ -284,6 +309,7 @@ export const waterLevelLabels: WaterLevelLabelConfig[] = [
     id: '6602380004',
     name: '二期沉砂池出库水量监测',
     type: 'waterLevel',
+    scene: 'main',
     positionX: -416.521,
     positionY: 8,
     positionZ: -188.334,
@@ -293,13 +319,40 @@ export const waterLevelLabels: WaterLevelLabelConfig[] = [
     id: '6602380023',
     name: '引水干渠水量监测103+800',
     type: 'waterLevel',
+    scene: 'main',
     positionX: -3353.116,
     positionY: 23.293,
     positionZ: -1657.653,
     initialValue: 2.80,
   },
+  {
+    id: '渡槽安全监测46+400',
+    name: '渡槽安全监测46+400',
+    type: 'StressMonitor',
+    scene: 'ducao',
+    positionX: -60.3,
+    positionY: 13.87,
+    positionZ: 1827.87,
+    initialValue: 2.80,
+  },
+  {
+    id: '渡槽安全监测46+635',
+    name: '渡槽安全监测46+635',
+    type: 'StressMonitor',
+    scene: 'ducao',
+    positionX: -326.17,
+    positionY: 13.87,
+    positionZ: 1886.87,
+    initialValue: 2.80,
+  },
 ]
 
+/** 按场景类型编组的标签列表 */
+export const sceneLabels: Record<SceneType, WaterLevelLabelConfig[]> = {
+  main: waterLevelLabels.filter(l => l.scene === 'main'),
+  ducao: waterLevelLabels.filter(l => l.scene === 'ducao'),
+}
+
 // ==================== 镜头预设 ====================
 
 /** 镜头预设配置类型 */

+ 5 - 4
src/index.ts

@@ -1,7 +1,8 @@
-import Scene3D from './components/Scene3D.vue'
-import WaterLevelLabel from './components/WaterLevelLabel.vue'
+import Scene3D from './scenes/Scene3D.vue'
+import WaterLevelLabel from './scenes/WaterLevelLabel.vue'
+import DucaoScene from './scenes/DucaoScene.vue'
 export * from './config/sceneConfig'
 
-export { Scene3D, WaterLevelLabel }
+export { Scene3D, WaterLevelLabel, DucaoScene }
 
-export type { WaterLevelLabelConfig } from './config/sceneConfig'
+export type { WaterLevelLabelConfig, SceneType } from './config/sceneConfig'

+ 0 - 0
src/components/Water.ts → src/materials/Water.ts


+ 0 - 0
src/components/waterFlow.ts → src/materials/waterFlow.ts


+ 0 - 0
src/components/waterFoam.ts → src/materials/waterFoam.ts


+ 0 - 0
src/components/waterFoamUE.ts → src/materials/waterFoamUE.ts


+ 0 - 0
src/components/waterNew.ts → src/materials/waterNew.ts


+ 533 - 0
src/scenes/DucaoScene.vue

@@ -0,0 +1,533 @@
+<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 WaterLevelLabel from './WaterLevelLabel.vue'
+import {
+  sceneLabels,
+  type WaterLevelLabelConfig,
+} from '../config/sceneConfig'
+
+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 ducaoGroup: THREE.Group | 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)
+
+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 (!ducaoGroup) return
+  const t = modelTransform
+  ducaoGroup.position.set(t.positionX, t.positionY, t.positionZ)
+  ducaoGroup.rotation.set(THREE.MathUtils.degToRad(t.rotationX), THREE.MathUtils.degToRad(t.rotationY), THREE.MathUtils.degToRad(t.rotationZ))
+  ducaoGroup.scale.set(t.scaleX, t.scaleY, t.scaleZ)
+}, { deep: true })
+
+// ==================== Props(嵌入其他项目时使用)====================
+const props = withDefaults(defineProps<{
+  labelValues?: Record<string, number>
+}>(), {
+  labelValues: () => ({}),
+})
+
+// ==================== 水位标签(仅加载渡槽场景的标签)====================
+interface LabelData {
+  config: WaterLevelLabelConfig
+  componentRef: ReturnType<typeof ref<InstanceType<typeof WaterLevelLabel> | null>>
+}
+const labelDataList: LabelData[] = []
+const labelValues = reactive<Record<string, number>>({})
+
+function initLabelData() {
+  labelDataList.length = 0
+  for (const cfg of sceneLabels.ducao) {
+    const externalValue = props.labelValues[cfg.id]
+    labelValues[cfg.id] = externalValue !== undefined ? externalValue : cfg.initialValue
+    labelDataList.push({
+      config: cfg,
+      componentRef: ref<InstanceType<typeof WaterLevelLabel> | null>(null),
+    })
+  }
+}
+initLabelData()
+
+watch(() => props.labelValues, (levels) => {
+  for (const [id, value] of Object.entries(levels)) {
+    if (labelValues[id] !== undefined) {
+      labelValues[id] = value
+    }
+  }
+}, { deep: true })
+
+function setLabelValue(id: string, value: number) {
+  labelValues[id] = value
+}
+
+function setLabelValues(levels: Record<string, number>) {
+  for (const [id, value] of Object.entries(levels)) {
+    labelValues[id] = value
+  }
+}
+
+// 释放所有 Three.js 资源(可重复调用)
+function disposeScene() {
+  cancelAnimationFrame(animationId)
+  animationId = 0
+
+  labelDataList.forEach(d => d.componentRef.value?.dispose())
+
+  if (ducaoGroup) {
+    if (scene) scene.remove(ducaoGroup)
+    ducaoGroup.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()
+        }
+      }
+    })
+    ducaoGroup = null
+  }
+
+  if (sky) {
+    if (scene) scene.remove(sky)
+    sky.material.dispose()
+    sky = null
+  }
+
+  if (scene) {
+    while (scene.children.length > 0) {
+      scene.remove(scene.children[0])
+    }
+    scene = null
+  }
+
+  if (controls) {
+    controls.dispose()
+    controls = null
+  }
+
+  if (renderer) {
+    const domEl = renderer.domElement
+    if (domEl && domEl.parentNode) {
+      domEl.parentNode.removeChild(domEl)
+    }
+    renderer.forceContextLoss()
+    renderer.dispose()
+    renderer = null
+  }
+
+  camera = null
+  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.1, 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.0
+  container.appendChild(renderer.domElement)
+
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.enableDamping = true
+  controls.dampingFactor = 0.03
+  controls.screenSpacePanning = false
+  controls.mouseButtons = {
+    LEFT: THREE.MOUSE.PAN,
+    MIDDLE: THREE.MOUSE.DOLLY,
+    RIGHT: THREE.MOUSE.ROTATE,
+  }
+  controls.maxPolarAngle = Math.PI / 2.1
+  controls.minDistance = 5
+  controls.maxDistance = 50000
+
+  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
+  skyUniforms.sunPosition.value.set(20, 30, 10)
+
+  const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.8)
+  scene.add(hemisphereLight)
+
+  const dirLight = new THREE.DirectionalLight(0xffffff, 1.2)
+  dirLight.position.set(100, 100, 50)
+  scene.add(dirLight)
+
+  raycaster = new THREE.Raycaster()
+  mouse = new THREE.Vector2()
+
+  loadDucaoModel()
+  labelDataList.forEach(d => d.componentRef.value?.init(scene, camera))
+  sceneInitialized = true
+  animate()
+}
+
+async function loadDucaoModel() {
+  try {
+    const loader = new GLTFLoader()
+    const urls = ['NoLod_0.glb', 'NoLod_1.glb', 'NoLod_2.glb', 'NoLod_3.glb']
+    const group = new THREE.Group()
+
+    for (const url of urls) {
+      try {
+        const gltf = await loader.loadAsync(`/ducao/${url}`)
+        group.add(gltf.scene)
+      } catch (_) {}
+    }
+
+    const box = new THREE.Box3().setFromObject(group)
+    const center = box.getCenter(new THREE.Vector3())
+    group.position.copy(center).multiplyScalar(-1)
+
+    ducaoGroup = group
+    scene.add(group)
+
+    controls.target.set(360.829, 0, 1725.719)
+    camera.position.set(410.009, 23.458, 1740.156)
+    controls.update()
+  } catch (_) {}
+}
+
+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)
+  controls.update()
+  labelDataList.forEach(d => d.componentRef.value?.tick())
+
+  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))
+  cameraInfo.value.minDistance = controls.minDistance
+  cameraInfo.value.maxDistance = controls.maxDistance
+
+  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()
+})
+
+defineExpose({
+  labelDataList,
+  labelValues,
+  setLabelValue,
+  setLabelValues,
+})
+</script>
+
+<template>
+  <div ref="containerRef" class="scene-container" />
+  <WaterLevelLabel
+    v-for="data in labelDataList"
+    :key="data.config.id"
+    :ref="(el: any) => { if (el) data.componentRef.value = el }"
+    :label-id="data.config.id"
+    :type="data.config.type"
+    :position-x="data.config.positionX"
+    :position-y="data.config.positionY"
+    :position-z="data.config.positionZ"
+    :value="labelValues[data.config.id]"
+  />
+  <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>
+  </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>
+</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; }
+
+.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; }
+
+.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; }
+
+.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 {
+  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>

+ 185 - 70
src/components/Scene3D.vue → src/scenes/Scene3D.vue

@@ -3,9 +3,9 @@ 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 { StylizedWaterMaterial } from './waterNew'
-import { createWaterFoamUEMaterial } from './waterFoamUE'
-import { createWaterFlowMaterial } from './waterFlow'
+import { StylizedWaterMaterial } from '../materials/waterNew'
+import { createWaterFoamUEMaterial } from '../materials/waterFoamUE'
+import { createWaterFlowMaterial } from '../materials/waterFlow'
 import foamTexUrl from '../assets/texture/T_Waterfall_Foam.PNG'
 import directionalFoamTexUrl from '../assets/texture/T_Waterfall_Foam_Directional.PNG'
 import flowTexUrl from '../assets/texture/FlowTexture/T_FlowTexture_BC.PNG'
@@ -30,7 +30,7 @@ import {
   type CscwaterMaterialParams,
   type FoamMaterialParams,
   type FlowMaterialParams,
-  waterLevelLabels,
+  sceneLabels,
   type WaterLevelLabelConfig,
   cameraPresets,
 } from '../config/sceneConfig'
@@ -298,6 +298,14 @@ function applyModelTransform(key: string) {
       THREE.MathUtils.degToRad(t.rotationY),
       THREE.MathUtils.degToRad(t.rotationZ)
     )
+  } else if (key === 'water2') {
+    obj.position.set(t.positionX, t.positionY, t.positionZ)
+    obj.rotation.order = 'YXZ'
+    obj.rotation.set(
+      THREE.MathUtils.degToRad(t.rotationX),
+      THREE.MathUtils.degToRad(t.rotationY),
+      THREE.MathUtils.degToRad(t.rotationZ)
+    )
   } else if (key === 'flow') {
     obj.position.set(t.positionX, t.positionY, t.positionZ)
     obj.rotation.order = 'YXZ'
@@ -342,7 +350,7 @@ const labelValues = reactive<Record<string, number>>({})
 
 function initLabelData() {
   labelDataList.length = 0
-  for (const cfg of waterLevelLabels) {
+  for (const cfg of sceneLabels.main) {
     const externalValue = props.labelValues[cfg.id]
     labelValues[cfg.id] = externalValue !== undefined ? externalValue : cfg.initialValue
     labelDataList.push({
@@ -360,6 +368,7 @@ 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
 let flowMesh: THREE.Mesh | null = null
@@ -372,6 +381,7 @@ let tilesRenderer: SuperMapTilesRenderer | null = null
 let raycaster: THREE.Raycaster
 let mouse: THREE.Vector2
 let depthRenderTarget: THREE.WebGLRenderTarget
+let sceneInitialized = false
 
 // 创建天空背景(含体积云效果)
 function createSky() {
@@ -421,6 +431,27 @@ function createWaterSurface() {
   StylizedWaterMaterial.uniforms.iResolution.value.set(container.clientWidth, container.clientHeight)
 }
 
+// 创建第二片面(使用与主水面相同的风格化水材质)
+function createWater2Surface() {
+  const t = modelTransformMap.water2
+  water2Mesh = new THREE.Mesh(
+    new THREE.PlaneGeometry(t.scaleX, t.scaleY, 120, 120),
+    StylizedWaterMaterial
+  )
+  water2Mesh.rotation.order = 'YXZ'
+  water2Mesh.rotation.set(
+    THREE.MathUtils.degToRad(t.rotationX),
+    THREE.MathUtils.degToRad(t.rotationY),
+    THREE.MathUtils.degToRad(t.rotationZ)
+  )
+  water2Mesh.position.set(t.positionX, t.positionY, t.positionZ)
+  water2Mesh.receiveShadow = true
+  water2Mesh.name = 'water2'
+  water2Mesh.renderOrder = 0
+  scene.add(water2Mesh)
+  modelList['water2'] = water2Mesh
+}
+
 // 创建泡沫片面(瀑布泡沫效果)
 function createWaterFoamSurface() {
   const textureLoader = new THREE.TextureLoader()
@@ -598,8 +629,127 @@ async function load3DTiles() {
   console.log('3D Tiles renderer initialized:', tilesetUrl)
 }
 
-// 初始化整个 Three.js 场景
+// 释放所有 Three.js 资源(可在 initScene 之前和 onUnmounted 时反复调用)
+function disposeScene() {
+  cancelAnimationFrame(animationId)
+  animationId = 0
+
+  labelDataList.forEach(d => d.componentRef.value?.dispose())
+
+  if (tilesRenderer) {
+    if (scene) scene.remove(tilesRenderer.group)
+    tilesRenderer.dispose?.()
+    tilesRenderer = null
+  }
+
+  if (foamMesh) {
+    if (scene) scene.remove(foamMesh)
+    foamMesh.geometry.dispose()
+    if (foamMaterial) {
+      for (const key of Object.keys(foamMaterial.uniforms)) {
+        const val = foamMaterial.uniforms[key].value
+        if (val instanceof THREE.Texture) val.dispose()
+      }
+      foamMaterial.dispose()
+    }
+    foamMesh = null
+    foamMaterial = null
+  }
+
+  if (flowMesh) {
+    const parent = flowMesh.parent
+    if (parent && scene) scene.remove(parent)
+    flowMesh.geometry.dispose()
+    if (flowMaterial) {
+      for (const key of Object.keys(flowMaterial.uniforms)) {
+        const val = flowMaterial.uniforms[key].value
+        if (val instanceof THREE.Texture) val.dispose()
+      }
+      flowMaterial.dispose()
+    }
+    flowMesh = null
+    flowMaterial = null
+  }
+
+  if (cscwaterModel) {
+    if (scene) scene.remove(cscwaterModel)
+    cscwaterModel.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()
+        }
+      }
+    })
+    cscwaterModel = null
+  }
+  if (cscwaterMaterial) {
+    cscwaterMaterial.dispose()
+    cscwaterMaterial = null
+  }
+
+  if (waterMesh) {
+    if (scene) scene.remove(waterMesh)
+    waterMesh.geometry.dispose()
+    waterMesh = null
+  }
+
+  if (water2Mesh) {
+    if (scene) scene.remove(water2Mesh)
+    water2Mesh.geometry.dispose()
+    water2Mesh = null
+  }
+
+  if (StylizedWaterMaterial) {
+    StylizedWaterMaterial.dispose()
+  }
+
+  if (sky) {
+    if (scene) scene.remove(sky)
+    sky.material.dispose()
+    sky = null
+  }
+
+  if (depthRenderTarget) {
+    depthRenderTarget.depthTexture?.dispose()
+    depthRenderTarget.dispose()
+    depthRenderTarget = null
+  }
+
+  if (controls) {
+    controls.dispose()
+    controls = null
+  }
+
+  if (renderer) {
+    const domEl = renderer.domElement
+    if (domEl && domEl.parentNode) {
+      domEl.parentNode.removeChild(domEl)
+    }
+    renderer.forceContextLoss()
+    renderer.dispose()
+    renderer = null
+  }
+
+  if (scene) {
+    while (scene.children.length > 0) {
+      scene.remove(scene.children[0])
+    }
+    scene = null
+  }
+
+  camera = null
+  sceneInitialized = false
+}
+
+// 初始化整个 Three.js 场景(带防重入保护)
 function initScene() {
+  if (sceneInitialized) {
+    disposeScene()
+  }
   const container = containerRef.value!
 
   scene = new THREE.Scene()
@@ -623,7 +773,6 @@ function initScene() {
   depthRenderTarget = new THREE.WebGLRenderTarget(pixelWidth, pixelHeight)
   depthRenderTarget.depthTexture = new THREE.DepthTexture(pixelWidth, pixelHeight)
 
-  // 轨道控制器(左键平移/中键缩放/右键旋转)
   controls = new OrbitControls(camera, renderer.domElement)
   controls.enableDamping = true
   controls.dampingFactor = 0.03
@@ -642,13 +791,11 @@ function initScene() {
 
   createSky()
 
-  // 半球光提供环境照明
   const hemisphereLight = new THREE.HemisphereLight(0xd4d4d4, 0x3d6b4a, 0.6)
   scene.add(hemisphereLight)
 
   sunDirection = new THREE.Vector3(20, 30, 10).normalize()
 
-  // 太阳平行光
   const sunLight = new THREE.DirectionalLight(0xffeedd, 2.0)
   sunLight.position.set(20, 30, 10)
   sunLight.castShadow = true
@@ -662,14 +809,15 @@ function initScene() {
   sunLight.shadow.camera.bottom = -20
   scene.add(sunLight)
 
-  // 按顺序创建场景各元素
   createWaterSurface()
+  createWater2Surface()
   createWaterFoamSurface()
   loadWaterFlowModel()
   loadCSCWaterModel()
   load3DTiles()
   labelDataList.forEach(d => d.componentRef.value?.init(scene, camera))
   initRaycaster()
+  sceneInitialized = true
   animate()
 }
 
@@ -683,6 +831,8 @@ function initRaycaster() {
 
 // 鼠标点击获取 3D 场景中的坐标
 function onMouseClick(event: MouseEvent) {
+  const target = event.target as HTMLElement
+  if (target.closest('.panel') || target.closest('.toolbar')) return
   const container = containerRef.value!
   const rect = container.getBoundingClientRect()
   mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
@@ -783,74 +933,38 @@ onMounted(() => {
 
 // 组件销毁时释放所有 Three.js 资源
 onUnmounted(() => {
-  cancelAnimationFrame(animationId)
   window.removeEventListener('resize', onResize)
   if (containerRef.value) {
     containerRef.value.removeEventListener('click', onMouseClick)
   }
-  tilesRenderer?.group.removeFromParent()
-  tilesRenderer?.dispose?.()
-  if (foamMesh) {
-    scene.remove(foamMesh)
-    foamMesh.geometry.dispose()
-    foamMaterial?.dispose()
-    foamMesh = null
-    foamMaterial = null
-  }
-  if (flowMesh) {
-    scene.remove(flowMesh.parent!)
-    flowMesh.geometry.dispose()
-    flowMaterial?.dispose()
-    flowMesh = null
-    flowMaterial = null
-  }
-  if (cscwaterModel) {
-    scene.remove(cscwaterModel)
-    cscwaterModel.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()
-        }
-      }
-    })
-    cscwaterModel = null
-  }
-  if (cscwaterMaterial) {
-    cscwaterMaterial.dispose()
-    cscwaterMaterial = null
-  }
-  labelDataList.forEach(d => d.componentRef.value?.dispose())
-  renderer.dispose()
-  controls.dispose()
-  depthRenderTarget.dispose()
+  disposeScene()
 })
 
 // ========== 材质参数的响应式监听,实时同步到着色器 ==========
 
-watch(() => waterParams.value, (p) => {
-  if (StylizedWaterMaterial && StylizedWaterMaterial.uniforms) {
-    StylizedWaterMaterial.uniforms.alpha.value = p.alpha
-    StylizedWaterMaterial.uniforms.flowSpeed.value = p.flowSpeed
-    StylizedWaterMaterial.uniforms.flowDirection.value.set(p.flowDirectionX, p.flowDirectionY)
-    StylizedWaterMaterial.uniforms.waveHeight.value = p.waveHeight
-    StylizedWaterMaterial.uniforms.shallowColor.value.set(p.waterColor)
-    StylizedWaterMaterial.uniforms.deepColor.value.set(p.deepColor)
-    StylizedWaterMaterial.uniforms.foamIntensity.value = p.foamIntensity
-    StylizedWaterMaterial.uniforms.specIntensity.value = p.specIntensity
-    StylizedWaterMaterial.uniforms.specPower.value = p.specPower
-    StylizedWaterMaterial.uniforms.fresnelPower.value = p.fresnelPower
-    StylizedWaterMaterial.uniforms.fresnelIntensity.value = p.fresnelIntensity
-    StylizedWaterMaterial.uniforms.depthRange.value = p.depthRange
-    StylizedWaterMaterial.uniforms.waterNormalStrength.value = p.waterNormalStrength
-    StylizedWaterMaterial.uniforms.waterNormalTiling.value = p.waterNormalTiling
-    StylizedWaterMaterial.uniforms.collisionFoamThreshold.value = p.collisionFoamThreshold
-    StylizedWaterMaterial.uniforms.collisionFoamStrength.value = p.collisionFoamStrength
-  }
-}, { deep: true })
+function syncWaterParams() {
+  if (!StylizedWaterMaterial || !StylizedWaterMaterial.uniforms) return
+  const p = waterParams.value
+  StylizedWaterMaterial.uniforms.alpha.value = p.alpha
+  StylizedWaterMaterial.uniforms.flowSpeed.value = p.flowSpeed
+  StylizedWaterMaterial.uniforms.flowDirection.value.set(p.flowDirectionX, p.flowDirectionY)
+  StylizedWaterMaterial.uniforms.waveHeight.value = p.waveHeight
+  StylizedWaterMaterial.uniforms.shallowColor.value.set(p.waterColor)
+  StylizedWaterMaterial.uniforms.deepColor.value.set(p.deepColor)
+  StylizedWaterMaterial.uniforms.foamIntensity.value = p.foamIntensity
+  StylizedWaterMaterial.uniforms.specIntensity.value = p.specIntensity
+  StylizedWaterMaterial.uniforms.specPower.value = p.specPower
+  StylizedWaterMaterial.uniforms.fresnelPower.value = p.fresnelPower
+  StylizedWaterMaterial.uniforms.fresnelIntensity.value = p.fresnelIntensity
+  StylizedWaterMaterial.uniforms.depthRange.value = p.depthRange
+  StylizedWaterMaterial.uniforms.waterNormalStrength.value = p.waterNormalStrength
+  StylizedWaterMaterial.uniforms.waterNormalTiling.value = p.waterNormalTiling
+  StylizedWaterMaterial.uniforms.collisionFoamThreshold.value = p.collisionFoamThreshold
+  StylizedWaterMaterial.uniforms.collisionFoamStrength.value = p.collisionFoamStrength
+}
+
+watch(() => waterParams.value, syncWaterParams, { deep: true })
+syncWaterParams()
 
 watch(() => cscwaterParams.value, () => {
   syncCscwaterParams()
@@ -1059,6 +1173,7 @@ defineExpose({
       <div class="section-label">选择模型</div>
       <select v-model="selectedModelKey" class="model-select">
         <option value="water">水面</option>
+        <option value="water2">水面2</option>
         <option value="foam">泡沫片面</option>
         <option value="flow">流动纹理模型</option>
         <option value="cscwater">CSCwater</option>

+ 2 - 0
src/components/WaterLevelLabel.vue → src/scenes/WaterLevelLabel.vue

@@ -3,6 +3,7 @@ import { watch, computed } from 'vue'
 import * as THREE from 'three'
 import { labelTypeRegistry, type LabelDataType } from '../config/sceneConfig'
 import shuiliangIcon from '../assets/icon/shuiliang.png'
+import yingliIcon from '../assets/icon/yingli.png'
 
 const props = defineProps<{
   labelId: string
@@ -15,6 +16,7 @@ const props = defineProps<{
 
 const iconMap: Record<string, string> = {
   'shuiliang.png': shuiliangIcon,
+  'yingli.png': yingliIcon,
 }
 
 const displayConfig = computed(() => labelTypeRegistry[props.type])

+ 199 - 0
src/utils/DucaoTerrain.ts

@@ -0,0 +1,199 @@
+import * as THREE from 'three'
+
+const TILE_SIZE = 256
+const P = 6378137
+const HALF_MAP = Math.PI * P
+
+const DOM_ZOOM = 13
+const TILE_M = (2 * HALF_MAP) / (1 << DOM_ZOOM)
+const DOM_X_MIN = 6020
+const DOM_X_MAX = 6033
+const DOM_Y_MIN = 3171
+const DOM_Y_MAX = 3178
+
+const DOM_COLS = DOM_X_MAX - DOM_X_MIN + 1
+const DOM_ROWS = DOM_Y_MAX - DOM_Y_MIN + 1
+
+const DEM_LON_MIN = 84.5947
+const DEM_LON_MAX = 85.1351
+const DEM_LAT_MIN = 37.3450
+const DEM_LAT_MAX = 37.5968
+
+const DEM_SIZE_X = 47663
+const DEM_SIZE_Z = 27847
+
+const HM_URL = '/tile/ducaoDem/ducao1.png'
+
+function lonToMercX(lon: number): number {
+  return P * lon * (Math.PI / 180)
+}
+
+function latToMercY(lat: number): number {
+  return P * Math.log(Math.tan(Math.PI / 4 + lat * (Math.PI / 180) / 2))
+}
+
+function calcUvBounds(): { u0: number; v0: number; u1: number; v1: number } {
+  const domWest = -HALF_MAP + DOM_X_MIN * TILE_M
+  const domEast = -HALF_MAP + (DOM_X_MAX + 1) * TILE_M
+  const domNorth = HALF_MAP - DOM_Y_MIN * TILE_M
+  const domSouth = HALF_MAP - (DOM_Y_MAX + 1) * TILE_M
+
+  const demWest = lonToMercX(DEM_LON_MIN)
+  const demEast = lonToMercX(DEM_LON_MAX)
+  const demSouth = latToMercY(DEM_LAT_MIN)
+  const demNorth = latToMercY(DEM_LAT_MAX)
+
+  return {
+    u0: (demWest - domWest) / (domEast - domWest),
+    u1: (demEast - domWest) / (domEast - domWest),
+    v0: (demSouth - domSouth) / (domNorth - domSouth),
+    v1: (demNorth - domSouth) / (domNorth - domSouth),
+  }
+}
+
+const UV = calcUvBounds()
+
+async function loadImage(url: string): Promise<HTMLImageElement> {
+  return new Promise((resolve, reject) => {
+    const img = new Image()
+    img.crossOrigin = 'anonymous'
+    img.onload = () => resolve(img)
+    img.onerror = () => reject(new Error(`Failed: ${url}`))
+    img.src = url
+  })
+}
+
+async function stitchDomTiles(): Promise<HTMLCanvasElement> {
+  const canvas = document.createElement('canvas')
+  canvas.width = DOM_COLS * TILE_SIZE
+  canvas.height = DOM_ROWS * TILE_SIZE
+  const ctx = canvas.getContext('2d')!
+
+  const tasks: Promise<void>[] = []
+  for (let tx = DOM_X_MIN; tx <= DOM_X_MAX; tx++) {
+    for (let ty = DOM_Y_MIN; ty <= DOM_Y_MAX; ty++) {
+      const url = `/tile/DucaoDom/${DOM_ZOOM}/${tx}/${ty}.png`
+      const px = (tx - DOM_X_MIN) * TILE_SIZE
+      const py = (ty - DOM_Y_MIN) * TILE_SIZE
+      tasks.push(loadImage(url).then(img => ctx.drawImage(img, px, py)))
+    }
+  }
+  await Promise.all(tasks)
+  return canvas
+}
+
+async function loadHeightmap(): Promise<{
+  data: Uint8Array
+  width: number
+  height: number
+}> {
+  const img = await loadImage(HM_URL)
+  const canvas = document.createElement('canvas')
+  canvas.width = img.width
+  canvas.height = img.height
+  const ctx = canvas.getContext('2d')!
+  ctx.drawImage(img, 0, 0)
+  const imageData = ctx.getImageData(0, 0, img.width, img.height)
+  const data = new Uint8Array(imageData.width * imageData.height)
+  for (let i = 0; i < data.length; i++) {
+    data[i] = imageData.data[i * 4]
+  }
+  return { data, width: img.width, height: img.height }
+}
+
+export interface TerrainOptions {
+  heightScale?: number
+  baseHeight?: number
+  verticalExaggeration?: number
+  segmentsX?: number
+}
+
+export async function createDucaoTerrain(
+  options: TerrainOptions = {},
+): Promise<THREE.Mesh> {
+  const heightScale = options.heightScale ?? 3.8
+  const baseHeight = options.baseHeight ?? -1445
+  const verticalExaggeration = options.verticalExaggeration ?? 0.3
+  const segX = options.segmentsX ?? 512
+  const segZ = Math.round(segX * (DEM_SIZE_Z / DEM_SIZE_X))
+
+  const [texCanvas, hm] = await Promise.all([
+    stitchDomTiles(),
+    loadHeightmap(),
+  ])
+
+  const texture = new THREE.CanvasTexture(texCanvas)
+  texture.colorSpace = THREE.SRGBColorSpace
+  texture.wrapS = THREE.ClampToEdgeWrapping
+  texture.wrapT = THREE.ClampToEdgeWrapping
+
+  const vertCols = segX + 1
+  const vertRows = segZ + 1
+
+  const positions = new Float32Array(vertRows * vertCols * 3)
+  const uvs = new Float32Array(vertRows * vertCols * 2)
+
+  let minH = Infinity
+  let maxH = -Infinity
+
+  for (let row = 0; row < vertRows; row++) {
+    for (let col = 0; col < vertCols; col++) {
+      const i = row * vertCols + col
+
+      const hmX = Math.round((col / segX) * (hm.width - 1))
+      const hmY = Math.round((row / segZ) * (hm.height - 1))
+      const gray = hm.data[hmY * hm.width + hmX]
+
+      const h = (gray * heightScale - baseHeight) * verticalExaggeration
+      if (h < minH) minH = h
+      if (h > maxH) maxH = h
+
+      const x = (col / segX - 0.5) * DEM_SIZE_X
+      const z = (row / segZ - 0.5) * DEM_SIZE_Z
+
+      positions[i * 3] = x
+      positions[i * 3 + 1] = h
+      positions[i * 3 + 2] = z
+
+      const u = UV.u0 + (col / segX) * (UV.u1 - UV.u0)
+      const v = UV.v0 + (1 - row / segZ) * (UV.v1 - UV.v0)
+
+      uvs[i * 2] = u
+      uvs[i * 2 + 1] = v
+    }
+  }
+
+  const indices: number[] = []
+  for (let row = 0; row < vertRows - 1; row++) {
+    for (let col = 0; col < vertCols - 1; col++) {
+      const a = row * vertCols + col
+      const b = row * vertCols + col + 1
+      const c = (row + 1) * vertCols + col
+      const d = (row + 1) * vertCols + col + 1
+      indices.push(a, c, b, b, c, d)
+    }
+  }
+
+  const geo = new THREE.BufferGeometry()
+  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+  geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2))
+  geo.setIndex(indices)
+  geo.computeVertexNormals()
+
+  const mat = new THREE.MeshStandardMaterial({
+    map: texture,
+    roughness: 0.8,
+    metalness: 0.1,
+    side: THREE.DoubleSide,
+  })
+
+  const mesh = new THREE.Mesh(geo, mat)
+  mesh.castShadow = true
+  mesh.receiveShadow = true
+
+  console.log(`[Terrain] UV=[${UV.u0.toFixed(3)},${UV.v0.toFixed(3)}]-[${UV.u1.toFixed(3)},${UV.v1.toFixed(3)}]`)
+  console.log(`[Terrain] size=${DEM_SIZE_X}x${DEM_SIZE_Z}m, grid=${vertCols}x${vertRows}`)
+  console.log(`[Terrain] height=[${minH.toFixed(1)}, ${maxH.toFixed(1)}]`)
+
+  return mesh
+}

+ 12 - 3
vite.config.ts

@@ -5,7 +5,16 @@ export default defineConfig({
   plugins: [vue()],
   assetsInclude: ['**/*.glb', '**/*.fbx', '**/*.FBX'],
   server: {
-    host: '0.0.0.0', // 关键:允许局域网所有设备访问
-    port: 5173
-  }
+    host: '0.0.0.0',
+    port: 5173,
+    proxy: {
+      '/terrain': {
+        target: 'http://localhost:9003',
+        changeOrigin: true,
+      },
+    },
+  },
+  build: {
+    chunkSizeWarningLimit: 500,
+  },
 })