Map3DScene.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. <script setup lang="ts">
  2. import { ref, onMounted, onUnmounted, reactive, watch } from 'vue'
  3. import * as THREE from 'three'
  4. import * as tt from 'three-tile'
  5. import * as plugin from 'three-tile/plugin'
  6. import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
  7. import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
  8. import iconUrl from '../assets/icon/shuiliang.png'
  9. const GLB_URL = '/assets/xinjiangdiban.glb'
  10. const containerRef = ref<HTMLDivElement>()
  11. let scene: THREE.Scene
  12. let camera: THREE.PerspectiveCamera
  13. let renderer: THREE.WebGLRenderer
  14. let controls: OrbitControls
  15. let map: tt.TileMap
  16. let modelGroup: THREE.Group | null = null
  17. let animId = 0
  18. const showTransformPanel = ref(false)
  19. const transform = reactive({
  20. positionX: 0, positionY: 0, positionZ: 0,
  21. rotationX: 0, rotationY: 5, rotationZ: 0,
  22. scaleX: 1, scaleY: 1, scaleZ: 1,
  23. })
  24. watch(transform, () => {
  25. if (!modelGroup) return
  26. const t = transform
  27. modelGroup.position.set(t.positionX, t.positionY, t.positionZ)
  28. modelGroup.rotation.set(
  29. THREE.MathUtils.degToRad(t.rotationX),
  30. THREE.MathUtils.degToRad(t.rotationY),
  31. THREE.MathUtils.degToRad(t.rotationZ)
  32. )
  33. modelGroup.scale.set(t.scaleX, t.scaleY, t.scaleZ)
  34. }, { deep: true })
  35. function initScene() {
  36. const container = containerRef.value!
  37. const w = container.clientWidth
  38. const h = container.clientHeight
  39. scene = new THREE.Scene()
  40. scene.background = new THREE.Color(0x87ceeb)
  41. camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 5000000)
  42. renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true })
  43. renderer.setSize(w, h)
  44. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  45. container.appendChild(renderer.domElement)
  46. controls = new OrbitControls(camera, renderer.domElement)
  47. controls.enableDamping = true
  48. controls.dampingFactor = 0.08
  49. controls.minDistance = 10
  50. controls.maxDistance = 3000000
  51. scene.add(new THREE.AmbientLight(0xffffff, 0.6))
  52. const dl = new THREE.DirectionalLight(0xffffff, 1.5)
  53. dl.position.set(100, 100, 50)
  54. scene.add(dl)
  55. const dl2 = new THREE.DirectionalLight(0x88ccff, 0.4)
  56. dl2.position.set(-50, 80, -30)
  57. scene.add(dl2)
  58. map = tt.TileMap.create({
  59. imgSource: new plugin.ArcGisSource(),
  60. demSource: new plugin.ArcGisDemSource(),
  61. lon0: 90,
  62. bounds: [84.0, 37.0, 86.0, 38.0],
  63. minLevel: 8,
  64. })
  65. map.rotateX(-Math.PI / 2)
  66. scene.add(map)
  67. loadModel()
  68. addIcon()
  69. animate()
  70. }
  71. async function loadModel() {
  72. try {
  73. const loader = new GLTFLoader()
  74. const gltf = await loader.loadAsync(GLB_URL)
  75. const model = gltf.scene
  76. // 模型 EPSG:4326,顶点单位是度
  77. // 用 geo2map 转地图本地坐标,直接放到地图上
  78. const box = new THREE.Box3().setFromObject(model)
  79. const centerDeg = box.getCenter(new THREE.Vector3())
  80. // 模型中心经纬度 → 地图本地坐标(map 已 rotateX,坐标在 XY 平面)
  81. const mapPos = map.geo2map(new THREE.Vector3(centerDeg.x, centerDeg.y, 0))
  82. // 模型居中后放入 group
  83. model.position.sub(centerDeg)
  84. const group = new THREE.Group()
  85. group.add(model)
  86. group.position.set(mapPos.x, mapPos.y, 0)
  87. group.rotation.y = THREE.MathUtils.degToRad(5)
  88. // 添加到地图组,跟随地图旋转
  89. map.add(group)
  90. modelGroup = group
  91. // 辅助红色球体贴地
  92. const marker = new THREE.Mesh(
  93. new THREE.SphereGeometry(50, 16, 16),
  94. new THREE.MeshBasicMaterial({ color: 0xff0000 })
  95. )
  96. marker.position.set(mapPos.x, mapPos.y, 0)
  97. map.add(marker)
  98. // 同步 transform
  99. transform.positionX = mapPos.x
  100. transform.positionY = mapPos.y
  101. transform.positionZ = 0
  102. transform.rotationY = 5
  103. // 相机对准
  104. const sizeDeg = box.getSize(new THREE.Vector3())
  105. const latRad = THREE.MathUtils.degToRad(centerDeg.y)
  106. const metersPerDeg = 111320 * Math.cos(latRad)
  107. const maxMeters = Math.max(sizeDeg.x, sizeDeg.y) * metersPerDeg
  108. const dist = maxMeters * 2 || 5000
  109. controls.target.set(mapPos.x, mapPos.y, 0)
  110. camera.position.set(mapPos.x + dist * 0.3, mapPos.y + dist * 0.5, dist)
  111. controls.update()
  112. console.log(`[Map3D] 模型加载完成`)
  113. console.log(`[Map3D] 中心经纬度: (${centerDeg.x.toFixed(5)}, ${centerDeg.y.toFixed(5)})`)
  114. console.log(`[Map3D] 地图坐标: (${mapPos.x.toFixed(1)}, ${mapPos.y.toFixed(1)})`)
  115. console.log(`[Map3D] 尺寸(度): ${sizeDeg.x.toFixed(5)} x ${sizeDeg.y.toFixed(5)}`)
  116. } catch (err) {
  117. console.error('[Map3D] 模型加载失败:', err)
  118. }
  119. }
  120. function addIcon() {
  121. const LON = 84.232272
  122. const LAT = 37.613076
  123. const texture = new THREE.TextureLoader().load(iconUrl)
  124. const material = new THREE.SpriteMaterial({
  125. map: texture,
  126. sizeAttenuation: false,
  127. transparent: true,
  128. })
  129. const icon = new THREE.Sprite(material)
  130. icon.renderOrder = 999
  131. icon.center.set(0.5, 0)
  132. icon.scale.setScalar(100)
  133. const pos = map.geo2map(new THREE.Vector3(LON, LAT, 0))
  134. icon.position.set(pos.x, pos.y, 0)
  135. map.add(icon)
  136. }
  137. function animate() {
  138. animId = requestAnimationFrame(animate)
  139. map?.update(camera)
  140. controls?.update()
  141. renderer?.render(scene, camera)
  142. }
  143. function onResize() {
  144. if (!containerRef.value) return
  145. const w = containerRef.value.clientWidth
  146. const h = containerRef.value.clientHeight
  147. camera.aspect = w / h
  148. camera.updateProjectionMatrix()
  149. renderer.setSize(w, h)
  150. }
  151. onMounted(() => {
  152. initScene()
  153. window.addEventListener('resize', onResize)
  154. })
  155. onUnmounted(() => {
  156. window.removeEventListener('resize', onResize)
  157. cancelAnimationFrame(animId)
  158. renderer?.dispose()
  159. })
  160. </script>
  161. <template>
  162. <div ref="containerRef" class="map-container" />
  163. <div class="toolbar">
  164. <button class="toolbar-btn" :class="{ active: showTransformPanel }" @click="showTransformPanel = !showTransformPanel">
  165. 模型变换
  166. </button>
  167. </div>
  168. <div v-if="showTransformPanel" class="panel transform-panel">
  169. <div class="panel-header">
  170. <span class="panel-title">模型变换</span>
  171. <button class="toggle-btn" @click="showTransformPanel = false">×</button>
  172. </div>
  173. <div class="section">
  174. <div class="section-label">位置</div>
  175. <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>
  176. <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>
  177. <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>
  178. </div>
  179. <div class="section">
  180. <div class="section-label">旋转 (度)</div>
  181. <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>
  182. <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>
  183. <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>
  184. </div>
  185. <div class="section">
  186. <div class="section-label">缩放</div>
  187. <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>
  188. <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>
  189. <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>
  190. </div>
  191. </div>
  192. </template>
  193. <style scoped>
  194. .map-container {
  195. width: 100vw;
  196. height: 100vh;
  197. overflow: hidden;
  198. }
  199. .toolbar {
  200. position: fixed;
  201. top: 20px;
  202. right: 20px;
  203. display: flex;
  204. flex-direction: column;
  205. gap: 8px;
  206. z-index: 1001;
  207. }
  208. .toolbar-btn {
  209. padding: 10px 16px;
  210. border: none;
  211. border-radius: 8px;
  212. background: rgba(0, 0, 0, 0.85);
  213. color: #888;
  214. font-size: 13px;
  215. font-weight: bold;
  216. cursor: pointer;
  217. transition: all 0.2s;
  218. min-width: 70px;
  219. }
  220. .toolbar-btn:hover {
  221. background: rgba(30, 30, 30, 0.9);
  222. color: #fff;
  223. }
  224. .toolbar-btn.active {
  225. color: #4fc3f7;
  226. box-shadow: 0 0 10px rgba(79, 195, 247, 0.3);
  227. }
  228. .panel {
  229. position: fixed;
  230. top: 20px;
  231. left: 20px;
  232. background: rgba(0, 0, 0, 0.85);
  233. color: white;
  234. padding: 15px;
  235. border-radius: 8px;
  236. font-family: 'Courier New', monospace;
  237. z-index: 1000;
  238. max-height: calc(100vh - 40px);
  239. overflow-y: auto;
  240. min-width: 280px;
  241. }
  242. .panel-header {
  243. display: flex;
  244. justify-content: space-between;
  245. align-items: center;
  246. margin-bottom: 12px;
  247. padding-bottom: 8px;
  248. border-bottom: 1px solid rgba(255, 255, 255, 0.3);
  249. }
  250. .panel-title {
  251. font-weight: bold;
  252. font-size: 14px;
  253. color: #4fc3f7;
  254. }
  255. .toggle-btn {
  256. width: 22px;
  257. height: 22px;
  258. border: none;
  259. border-radius: 4px;
  260. background: rgba(255, 255, 255, 0.15);
  261. color: white;
  262. font-size: 14px;
  263. cursor: pointer;
  264. display: flex;
  265. align-items: center;
  266. justify-content: center;
  267. }
  268. .toggle-btn:hover {
  269. background: rgba(255, 255, 255, 0.3);
  270. }
  271. .section {
  272. margin-bottom: 12px;
  273. }
  274. .section-label {
  275. color: #81c784;
  276. font-size: 11px;
  277. margin-bottom: 6px;
  278. font-weight: bold;
  279. }
  280. .row {
  281. display: flex;
  282. align-items: center;
  283. gap: 8px;
  284. margin: 4px 0;
  285. }
  286. .label {
  287. color: #4fc3f7;
  288. width: 16px;
  289. font-size: 12px;
  290. font-weight: bold;
  291. }
  292. .slider {
  293. flex: 1;
  294. height: 4px;
  295. cursor: pointer;
  296. accent-color: #4fc3f7;
  297. }
  298. .val {
  299. color: #fff;
  300. width: 70px;
  301. text-align: right;
  302. font-size: 12px;
  303. }
  304. </style>