BAI 4 dní pred
rodič
commit
e4b9dfc48f

BIN
XinJiang.7z


BIN
dist.7z


+ 45 - 0
package-lock.json

@@ -13,11 +13,13 @@
         "@loaders.gl/gltf": "^4.4.1",
         "@loaders.gl/terrain": "^4.4.2",
         "3d-tiles-renderer": "^0.4.24",
+        "d3-geo": "^3.1.1",
         "three": "^0.184.0",
         "three-tile": "^0.11.14",
         "vue": "^3.5.32"
       },
       "devDependencies": {
+        "@types/d3-geo": "^3.1.0",
         "@types/node": "^24.12.2",
         "@types/three": "^0.184.0",
         "@vitejs/plugin-vue": "^6.0.6",
@@ -832,6 +834,16 @@
       "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
       "license": "MIT"
     },
+    "node_modules/@types/d3-geo": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+      "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
     "node_modules/@types/geojson": {
       "version": "7946.0.16",
       "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -1305,6 +1317,30 @@
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "license": "MIT"
     },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-geo": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz",
+      "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.5.0 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1435,6 +1471,15 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "license": "ISC"
     },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/is-buffer": {
       "version": "1.1.6",
       "resolved": "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz",

+ 2 - 0
package.json

@@ -14,11 +14,13 @@
     "@loaders.gl/gltf": "^4.4.1",
     "@loaders.gl/terrain": "^4.4.2",
     "3d-tiles-renderer": "^0.4.24",
+    "d3-geo": "^3.1.1",
     "three": "^0.184.0",
     "three-tile": "^0.11.14",
     "vue": "^3.5.32"
   },
   "devDependencies": {
+    "@types/d3-geo": "^3.1.0",
     "@types/node": "^24.12.2",
     "@types/three": "^0.184.0",
     "@vitejs/plugin-vue": "^6.0.6",

BIN
public/assets/DEM.png


BIN
public/assets/DOM.png


BIN
public/assets/Normal.png


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 5 - 0
public/assets/json/38团范围.geojson


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/json/昆山.json


BIN
public/assets/xinjiangdiban.glb


+ 11 - 2
src/App.vue

@@ -2,13 +2,21 @@
 import { ref } from 'vue'
 import Scene3D from './scenes/Scene3D.vue'
 import DucaoScene from './scenes/DucaoScene.vue'
+import Map3DScene from './scenes/Map3DScene.vue'
 
-const currentScene = ref<'scene3d' | 'ducao'>('scene3d')
+const currentScene = ref<'scene3d' | 'ducao' | 'map3d'>('map3d')
 </script>
 
 <template>
   <div class="app-root">
     <div class="scene-switcher">
+      <button
+        class="switch-btn"
+        :class="{ active: currentScene === 'map3d' }"
+        @click="currentScene = 'map3d'"
+      >
+        3D地图
+      </button>
       <button
         class="switch-btn"
         :class="{ active: currentScene === 'scene3d' }"
@@ -24,7 +32,8 @@ const currentScene = ref<'scene3d' | 'ducao'>('scene3d')
         渡槽场景
       </button>
     </div>
-    <Scene3D v-if="currentScene === 'scene3d'" :show-debug-tools="true" />
+    <Map3DScene v-if="currentScene === 'map3d'" />
+    <Scene3D v-else-if="currentScene === 'scene3d'" :show-debug-tools="true" />
     <DucaoScene v-else :show-debug-tools="true" />
   </div>
 </template>

BIN
src/assets/xinjiangdiban.glb


+ 3 - 3
src/materials/Water.ts

@@ -372,10 +372,10 @@ export class Water extends THREE.Object3D {
   }
 }
 
+import waterNormalsUrl from '../assets/waternormals.jpg'
+
 export function createWaterNormalTexture(loader: THREE.TextureLoader): THREE.Texture {
-  const texture = loader.load(
-    new URL('../assets/waternormals.jpg', import.meta.url).href
-  )
+  const texture = loader.load(waterNormalsUrl)
   texture.wrapS = THREE.RepeatWrapping
   texture.wrapT = THREE.RepeatWrapping
   return texture

+ 10 - 5
src/materials/waterNew.ts

@@ -293,20 +293,25 @@ void main() {
 }
 `;
 
+import waterNormal1Url from '../assets/texture/Water_1_Normal.PNG'
+import waterNormal2Url from '../assets/texture/Water_2_Normal.PNG'
+import waterNormal3Url from '../assets/texture/Water_3_Normal.PNG'
+import skyUrl from '../assets/texture/sky.jpg'
+
 const textureLoader = new THREE.TextureLoader();
 
 function loadRepeatTexture(url: string): THREE.Texture {
-  const tex = textureLoader.load(new URL(url, import.meta.url).href)
+  const tex = textureLoader.load(url)
   tex.wrapS = THREE.RepeatWrapping
   tex.wrapT = THREE.RepeatWrapping
   return tex
 }
 
-const waterNormalTex1 = loadRepeatTexture('../assets/texture/Water_1_Normal.PNG')
-const waterNormalTex2 = loadRepeatTexture('../assets/texture/Water_2_Normal.PNG')
-const waterNormalTex3 = loadRepeatTexture('../assets/texture/Water_3_Normal.PNG')
+const waterNormalTex1 = loadRepeatTexture(waterNormal1Url)
+const waterNormalTex2 = loadRepeatTexture(waterNormal2Url)
+const waterNormalTex3 = loadRepeatTexture(waterNormal3Url)
 
-const skyTex = textureLoader.load(new URL('../assets/texture/sky.jpg', import.meta.url).href)
+const skyTex = textureLoader.load(skyUrl)
 skyTex.wrapS = THREE.RepeatWrapping
 skyTex.wrapT = THREE.ClampToEdgeWrapping
 

+ 351 - 0
src/scenes/Map3DScene.vue

@@ -0,0 +1,351 @@
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted, reactive, watch } from 'vue'
+import * as THREE from 'three'
+import * as tt from 'three-tile'
+import * as plugin from 'three-tile/plugin'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import iconUrl from '../assets/icon/shuiliang.png'
+
+const GLB_URL = '/assets/xinjiangdiban.glb'
+
+const containerRef = ref<HTMLDivElement>()
+
+let scene: THREE.Scene
+let camera: THREE.PerspectiveCamera
+let renderer: THREE.WebGLRenderer
+let controls: OrbitControls
+let map: tt.TileMap
+let modelGroup: THREE.Group | null = null
+let animId = 0
+
+const showTransformPanel = ref(false)
+
+const transform = reactive({
+  positionX: 0, positionY: 0, positionZ: 0,
+  rotationX: 0, rotationY: 5, rotationZ: 0,
+  scaleX: 1, scaleY: 1, scaleZ: 1,
+})
+
+watch(transform, () => {
+  if (!modelGroup) return
+  const t = transform
+  modelGroup.position.set(t.positionX, t.positionY, t.positionZ)
+  modelGroup.rotation.set(
+    THREE.MathUtils.degToRad(t.rotationX),
+    THREE.MathUtils.degToRad(t.rotationY),
+    THREE.MathUtils.degToRad(t.rotationZ)
+  )
+  modelGroup.scale.set(t.scaleX, t.scaleY, t.scaleZ)
+}, { deep: true })
+
+function initScene() {
+  const container = containerRef.value!
+  const w = container.clientWidth
+  const h = container.clientHeight
+
+  scene = new THREE.Scene()
+  scene.background = new THREE.Color(0x87ceeb)
+
+  camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 5000000)
+
+  renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true })
+  renderer.setSize(w, h)
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+  container.appendChild(renderer.domElement)
+
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.enableDamping = true
+  controls.dampingFactor = 0.08
+  controls.minDistance = 10
+  controls.maxDistance = 3000000
+
+  scene.add(new THREE.AmbientLight(0xffffff, 0.6))
+  const dl = new THREE.DirectionalLight(0xffffff, 1.5)
+  dl.position.set(100, 100, 50)
+  scene.add(dl)
+  const dl2 = new THREE.DirectionalLight(0x88ccff, 0.4)
+  dl2.position.set(-50, 80, -30)
+  scene.add(dl2)
+
+  map = tt.TileMap.create({
+    imgSource: new plugin.ArcGisSource(),
+    demSource: new plugin.ArcGisDemSource(),
+    lon0: 90,
+    bounds: [84.0, 37.0, 86.0, 38.0],
+    minLevel: 8,
+  })
+  map.rotateX(-Math.PI / 2)
+  scene.add(map)
+
+  loadModel()
+  addIcon()
+  animate()
+}
+
+async function loadModel() {
+  try {
+    const loader = new GLTFLoader()
+    const gltf = await loader.loadAsync(GLB_URL)
+    const model = gltf.scene
+
+    // 模型 EPSG:4326,顶点单位是度
+    // 用 geo2map 转地图本地坐标,直接放到地图上
+    const box = new THREE.Box3().setFromObject(model)
+    const centerDeg = box.getCenter(new THREE.Vector3())
+
+    // 模型中心经纬度 → 地图本地坐标(map 已 rotateX,坐标在 XY 平面)
+    const mapPos = map.geo2map(new THREE.Vector3(centerDeg.x, centerDeg.y, 0))
+
+    // 模型居中后放入 group
+    model.position.sub(centerDeg)
+    const group = new THREE.Group()
+    group.add(model)
+    group.position.set(mapPos.x, mapPos.y, 0)
+    group.rotation.y = THREE.MathUtils.degToRad(5)
+    // 添加到地图组,跟随地图旋转
+    map.add(group)
+    modelGroup = group
+
+    // 辅助红色球体贴地
+    const marker = new THREE.Mesh(
+      new THREE.SphereGeometry(50, 16, 16),
+      new THREE.MeshBasicMaterial({ color: 0xff0000 })
+    )
+    marker.position.set(mapPos.x, mapPos.y, 0)
+    map.add(marker)
+
+    // 同步 transform
+    transform.positionX = mapPos.x
+    transform.positionY = mapPos.y
+    transform.positionZ = 0
+    transform.rotationY = 5
+
+    // 相机对准
+    const sizeDeg = box.getSize(new THREE.Vector3())
+    const latRad = THREE.MathUtils.degToRad(centerDeg.y)
+    const metersPerDeg = 111320 * Math.cos(latRad)
+    const maxMeters = Math.max(sizeDeg.x, sizeDeg.y) * metersPerDeg
+    const dist = maxMeters * 2 || 5000
+
+    controls.target.set(mapPos.x, mapPos.y, 0)
+    camera.position.set(mapPos.x + dist * 0.3, mapPos.y + dist * 0.5, dist)
+    controls.update()
+
+    console.log(`[Map3D] 模型加载完成`)
+    console.log(`[Map3D] 中心经纬度: (${centerDeg.x.toFixed(5)}, ${centerDeg.y.toFixed(5)})`)
+    console.log(`[Map3D] 地图坐标: (${mapPos.x.toFixed(1)}, ${mapPos.y.toFixed(1)})`)
+    console.log(`[Map3D] 尺寸(度): ${sizeDeg.x.toFixed(5)} x ${sizeDeg.y.toFixed(5)}`)
+  } catch (err) {
+    console.error('[Map3D] 模型加载失败:', err)
+  }
+}
+
+function addIcon() {
+  const LON = 84.232272
+  const LAT = 37.613076
+
+  const texture = new THREE.TextureLoader().load(iconUrl)
+  const material = new THREE.SpriteMaterial({
+    map: texture,
+    sizeAttenuation: false,
+    transparent: true,
+  })
+  const icon = new THREE.Sprite(material)
+  icon.renderOrder = 999
+  icon.center.set(0.5, 0)
+  icon.scale.setScalar(100)
+
+  const pos = map.geo2map(new THREE.Vector3(LON, LAT, 0))
+  icon.position.set(pos.x, pos.y, 0)
+  map.add(icon)
+}
+
+function animate() {
+  animId = requestAnimationFrame(animate)
+  map?.update(camera)
+  controls?.update()
+  renderer?.render(scene, camera)
+}
+
+function onResize() {
+  if (!containerRef.value) return
+  const w = containerRef.value.clientWidth
+  const h = containerRef.value.clientHeight
+  camera.aspect = w / h
+  camera.updateProjectionMatrix()
+  renderer.setSize(w, h)
+}
+
+onMounted(() => {
+  initScene()
+  window.addEventListener('resize', onResize)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', onResize)
+  cancelAnimationFrame(animId)
+  renderer?.dispose()
+})
+</script>
+
+<template>
+  <div ref="containerRef" class="map-container" />
+  <div class="toolbar">
+    <button class="toolbar-btn" :class="{ active: showTransformPanel }" @click="showTransformPanel = !showTransformPanel">
+      模型变换
+    </button>
+  </div>
+  <div v-if="showTransformPanel" class="panel transform-panel">
+    <div class="panel-header">
+      <span class="panel-title">模型变换</span>
+      <button class="toggle-btn" @click="showTransformPanel = false">×</button>
+    </div>
+    <div class="section">
+      <div class="section-label">位置</div>
+      <div class="row"><span class="label">X</span><input v-model.number="transform.positionX" type="range" class="slider" :min="transform.positionX - 500" :max="transform.positionX + 500" step="1" /><span class="val">{{ transform.positionX.toFixed(0) }}</span></div>
+      <div class="row"><span class="label">Y</span><input v-model.number="transform.positionY" type="range" class="slider" :min="transform.positionY - 500" :max="transform.positionY + 500" step="1" /><span class="val">{{ transform.positionY.toFixed(0) }}</span></div>
+      <div class="row"><span class="label">Z</span><input v-model.number="transform.positionZ" type="range" class="slider" :min="transform.positionZ - 500" :max="transform.positionZ + 500" step="1" /><span class="val">{{ transform.positionZ.toFixed(0) }}</span></div>
+    </div>
+    <div class="section">
+      <div class="section-label">旋转 (度)</div>
+      <div class="row"><span class="label">X</span><input v-model.number="transform.rotationX" type="range" class="slider" min="-180" max="180" step="1" /><span class="val">{{ transform.rotationX }}</span></div>
+      <div class="row"><span class="label">Y</span><input v-model.number="transform.rotationY" type="range" class="slider" min="-180" max="180" step="1" /><span class="val">{{ transform.rotationY }}</span></div>
+      <div class="row"><span class="label">Z</span><input v-model.number="transform.rotationZ" type="range" class="slider" min="-180" max="180" step="1" /><span class="val">{{ transform.rotationZ }}</span></div>
+    </div>
+    <div class="section">
+      <div class="section-label">缩放</div>
+      <div class="row"><span class="label">X</span><input v-model.number="transform.scaleX" type="range" class="slider" min="0.1" max="10" step="0.1" /><span class="val">{{ transform.scaleX.toFixed(1) }}</span></div>
+      <div class="row"><span class="label">Y</span><input v-model.number="transform.scaleY" type="range" class="slider" min="0.1" max="10" step="0.1" /><span class="val">{{ transform.scaleY.toFixed(1) }}</span></div>
+      <div class="row"><span class="label">Z</span><input v-model.number="transform.scaleZ" type="range" class="slider" min="0.1" max="10" step="0.1" /><span class="val">{{ transform.scaleZ.toFixed(1) }}</span></div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.map-container {
+  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: #4fc3f7;
+  box-shadow: 0 0 10px rgba(79, 195, 247, 0.3);
+}
+
+.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;
+  min-width: 280px;
+}
+
+.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;
+  color: #4fc3f7;
+}
+
+.toggle-btn {
+  width: 22px;
+  height: 22px;
+  border: none;
+  border-radius: 4px;
+  background: rgba(255, 255, 255, 0.15);
+  color: white;
+  font-size: 14px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.toggle-btn:hover {
+  background: rgba(255, 255, 255, 0.3);
+}
+
+.section {
+  margin-bottom: 12px;
+}
+
+.section-label {
+  color: #81c784;
+  font-size: 11px;
+  margin-bottom: 6px;
+  font-weight: bold;
+}
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 4px 0;
+}
+
+.label {
+  color: #4fc3f7;
+  width: 16px;
+  font-size: 12px;
+  font-weight: bold;
+}
+
+.slider {
+  flex: 1;
+  height: 4px;
+  cursor: pointer;
+  accent-color: #4fc3f7;
+}
+
+.val {
+  color: #fff;
+  width: 70px;
+  text-align: right;
+  font-size: 12px;
+}
+</style>

+ 6 - 14
src/style.css

@@ -50,13 +50,17 @@
   }
 }
 
-html, body, #app {
+html, body {
   margin: 0;
   padding: 0;
   width: 100%;
   height: 100%;
   overflow: hidden;
-  background: transparent;
+  background: #060e24;
+}
+#app {
+  width: 100%;
+  height: 100%;
 }
 
 h1,
@@ -161,18 +165,6 @@ code {
   }
 }
 
-#app {
-  width: 1126px;
-  max-width: 100%;
-  margin: 0 auto;
-  text-align: center;
-  border-inline: 1px solid var(--border);
-  min-height: 100svh;
-  display: flex;
-  flex-direction: column;
-  box-sizing: border-box;
-}
-
 #center {
   display: flex;
   flex-direction: column;

+ 5 - 0
src/vite-env.d.ts

@@ -5,6 +5,11 @@ declare module '*.PNG' {
   export default src
 }
 
+declare module '*.jpg' {
+  const src: string
+  export default src
+}
+
 declare module '*.glb' {
   const src: string
   export default src

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov