|
@@ -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>
|