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