|
@@ -1,13 +1,15 @@
|
|
|
-<script setup lang="ts">
|
|
|
|
|
-import { ref, onMounted, onUnmounted, reactive, watch } from 'vue'
|
|
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { onMounted, onUnmounted, ref } from 'vue'
|
|
|
import * as THREE from 'three'
|
|
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 { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
|
|
-import iconUrl from '../assets/icon/shuiliang.png'
|
|
|
|
|
-
|
|
|
|
|
-const GLB_URL = '/assets/xinjiangdiban.glb'
|
|
|
|
|
|
|
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|
|
|
|
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
|
|
|
|
|
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
|
|
|
|
|
+import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
|
|
|
|
|
+import { latLonToLocalENU, latLonToECEF, latLonToScenePosition, ecefToLatLon } from '../utils/geoCoord'
|
|
|
|
|
+import { createRimFlowMaterial } from '../materials/rimFlow'
|
|
|
|
|
+import { createTechFloorMaterial } from '../materials/techFloor'
|
|
|
|
|
+import xinjiangdibanGLB from '../assets/xinjiangdiban.glb'
|
|
|
|
|
|
|
|
const containerRef = ref<HTMLDivElement>()
|
|
const containerRef = ref<HTMLDivElement>()
|
|
|
|
|
|
|
@@ -15,337 +17,392 @@ let scene: THREE.Scene
|
|
|
let camera: THREE.PerspectiveCamera
|
|
let camera: THREE.PerspectiveCamera
|
|
|
let renderer: THREE.WebGLRenderer
|
|
let renderer: THREE.WebGLRenderer
|
|
|
let controls: OrbitControls
|
|
let controls: OrbitControls
|
|
|
-let map: tt.TileMap
|
|
|
|
|
|
|
+let composer: EffectComposer
|
|
|
|
|
+let animationId: number
|
|
|
|
|
+
|
|
|
let modelGroup: THREE.Group | null = null
|
|
let modelGroup: THREE.Group | null = null
|
|
|
-let animId = 0
|
|
|
|
|
|
|
|
|
|
-const showTransformPanel = ref(false)
|
|
|
|
|
|
|
+/** 存储标记点位置,供聚焦按钮使用 */
|
|
|
|
|
+const markerPositions: Record<string, THREE.Vector3> = {}
|
|
|
|
|
+const focusTarget = ref('原点')
|
|
|
|
|
|
|
|
-const transform = reactive({
|
|
|
|
|
- positionX: 0, positionY: 0, positionZ: 0,
|
|
|
|
|
- rotationX: 0, rotationY: 5, rotationZ: 0,
|
|
|
|
|
- scaleX: 1, scaleY: 1, scaleZ: 1,
|
|
|
|
|
-})
|
|
|
|
|
|
|
+// 缓动函数
|
|
|
|
|
+function easeInOutCubic(t: number): number {
|
|
|
|
|
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-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 flyTo(targetName: string, duration = 1000) {
|
|
|
|
|
+ const pos = markerPositions[targetName]
|
|
|
|
|
+ if (!pos) return
|
|
|
|
|
+
|
|
|
|
|
+ focusTarget.value = targetName
|
|
|
|
|
+
|
|
|
|
|
+ const startPos = camera.position.clone()
|
|
|
|
|
+ const startTarget = controls.target.clone()
|
|
|
|
|
+ const endPos = new THREE.Vector3(pos.x, pos.y + 50000, pos.z + 80000)
|
|
|
|
|
+ const endTarget = pos.clone()
|
|
|
|
|
+ endTarget.y += 20000
|
|
|
|
|
+
|
|
|
|
|
+ const startTime = performance.now()
|
|
|
|
|
+
|
|
|
|
|
+ function animateFly(time: number) {
|
|
|
|
|
+ const elapsed = time - startTime
|
|
|
|
|
+ const t = Math.min(elapsed / duration, 1)
|
|
|
|
|
+ const e = easeInOutCubic(t)
|
|
|
|
|
+
|
|
|
|
|
+ camera.position.lerpVectors(startPos, endPos, e)
|
|
|
|
|
+ controls.target.lerpVectors(startTarget, endTarget, e)
|
|
|
|
|
+ controls.update()
|
|
|
|
|
+
|
|
|
|
|
+ if (t < 1) {
|
|
|
|
|
+ requestAnimationFrame(animateFly)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ requestAnimationFrame(animateFly)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 测试用经纬度点 ----------
|
|
|
|
|
+// 以指定坐标为原点 (84.79211°E, 37.52110°N)
|
|
|
|
|
+const ORIGIN_LAT = 37.52110
|
|
|
|
|
+const ORIGIN_LON = 84.79211
|
|
|
|
|
+
|
|
|
|
|
+const testPoints = [
|
|
|
|
|
+ { lat: 37.52110, lon: 84.79211, label: '原点' },
|
|
|
|
|
+ { lat: 37.53110, lon: 84.81211, label: '东北方向' },
|
|
|
|
|
+ { lat: 37.51110, lon: 84.77211, label: '西南方向' },
|
|
|
|
|
+ { lat: 37.53110, lon: 84.79211, label: '正北方向' },
|
|
|
|
|
+ { lat: 37.51110, lon: 84.81211, label: '东南方向' },
|
|
|
|
|
+]
|
|
|
|
|
|
|
|
function initScene() {
|
|
function initScene() {
|
|
|
- const container = containerRef.value!
|
|
|
|
|
- const w = container.clientWidth
|
|
|
|
|
- const h = container.clientHeight
|
|
|
|
|
|
|
+ if (!containerRef.value) return
|
|
|
|
|
|
|
|
|
|
+ // Scene
|
|
|
scene = new THREE.Scene()
|
|
scene = new THREE.Scene()
|
|
|
- scene.background = new THREE.Color(0x87ceeb)
|
|
|
|
|
-
|
|
|
|
|
- camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 5000000)
|
|
|
|
|
|
|
+ scene.background = new THREE.Color(0x111122)
|
|
|
|
|
+
|
|
|
|
|
+ // Camera
|
|
|
|
|
+ camera = new THREE.PerspectiveCamera(
|
|
|
|
|
+ 60,
|
|
|
|
|
+ containerRef.value.clientWidth / containerRef.value.clientHeight,
|
|
|
|
|
+ 1,
|
|
|
|
|
+ 500000,
|
|
|
|
|
+ )
|
|
|
|
|
+ camera.position.set(100000, 80000, 150000)
|
|
|
|
|
|
|
|
- renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true })
|
|
|
|
|
- renderer.setSize(w, h)
|
|
|
|
|
|
|
+ // Renderer
|
|
|
|
|
+ renderer = new THREE.WebGLRenderer({
|
|
|
|
|
+ antialias: true,
|
|
|
|
|
+ logarithmicDepthBuffer: true,
|
|
|
|
|
+ powerPreference: 'high-performance',
|
|
|
|
|
+ })
|
|
|
|
|
+ renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight)
|
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
|
- container.appendChild(renderer.domElement)
|
|
|
|
|
|
|
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
|
|
|
|
|
+ renderer.toneMappingExposure = 1.2
|
|
|
|
|
+ containerRef.value.appendChild(renderer.domElement)
|
|
|
|
|
|
|
|
|
|
+ // Controls
|
|
|
controls = new OrbitControls(camera, renderer.domElement)
|
|
controls = new OrbitControls(camera, renderer.domElement)
|
|
|
controls.enableDamping = true
|
|
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)
|
|
|
|
|
|
|
+ controls.dampingFactor = 0.1
|
|
|
|
|
+ controls.target.set(0, 0, 0)
|
|
|
|
|
+
|
|
|
|
|
+ // Effect Composer (Bloom)
|
|
|
|
|
+ composer = new EffectComposer(renderer)
|
|
|
|
|
+ const renderPass = new RenderPass(scene, camera)
|
|
|
|
|
+ composer.addPass(renderPass)
|
|
|
|
|
+ const bloomPass = new UnrealBloomPass(
|
|
|
|
|
+ new THREE.Vector2(containerRef.value.clientWidth, containerRef.value.clientHeight),
|
|
|
|
|
+ 0.6, // strength
|
|
|
|
|
+ 0.4, // radius
|
|
|
|
|
+ 0.85, // threshold
|
|
|
|
|
+ )
|
|
|
|
|
+ composer.addPass(bloomPass)
|
|
|
|
|
|
|
|
- loadModel()
|
|
|
|
|
- addIcon()
|
|
|
|
|
|
|
+ // Helpers
|
|
|
|
|
+ scene.add(new THREE.GridHelper(2000, 20))
|
|
|
|
|
+ scene.add(new THREE.AxesHelper(500))
|
|
|
|
|
+
|
|
|
|
|
+ // Lighting
|
|
|
|
|
+ const ambientLight = new THREE.AmbientLight(0x333333)
|
|
|
|
|
+ scene.add(ambientLight)
|
|
|
|
|
+
|
|
|
|
|
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
|
|
|
|
|
+ directionalLight.position.set(5000, 10000, 5000)
|
|
|
|
|
+ scene.add(directionalLight)
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 科技背景地面 ----------
|
|
|
|
|
+ createTechFloor()
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 加载底板模型 ----------
|
|
|
|
|
+ loadBaseModel()
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 展示 geodesy 坐标转换结果 ----------
|
|
|
|
|
+ addGeodesyTestMarkers()
|
|
|
|
|
+
|
|
|
|
|
+ // Resize
|
|
|
|
|
+ window.addEventListener('resize', onResize)
|
|
|
|
|
+
|
|
|
|
|
+ // Start loop
|
|
|
animate()
|
|
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()
|
|
|
|
|
|
|
+/** 加载 xinjiangdiban.glb */
|
|
|
|
|
+function loadBaseModel() {
|
|
|
|
|
+ const loader = new GLTFLoader()
|
|
|
|
|
+ loader.load(xinjiangdibanGLB, (gltf) => {
|
|
|
|
|
+ modelGroup = gltf.scene
|
|
|
|
|
+
|
|
|
|
|
+ modelGroup.name = 'xinjiangdiban'
|
|
|
|
|
+
|
|
|
|
|
+ modelGroup.traverse((child) => {
|
|
|
|
|
+ if ((child as THREE.Mesh).isMesh) {
|
|
|
|
|
+ const mesh = child as THREE.Mesh
|
|
|
|
|
+ mesh.castShadow = true
|
|
|
|
|
+ mesh.receiveShadow = true
|
|
|
|
|
+ mesh.frustumCulled = false
|
|
|
|
|
+
|
|
|
|
|
+ // 给名为 "di" 的部件替换流光材质
|
|
|
|
|
+ if (mesh.name === 'di') {
|
|
|
|
|
+ console.log('[流光] 给 "di" 部件应用流光材质')
|
|
|
|
|
+ const flowMat = createRimFlowMaterial()
|
|
|
|
|
+ mesh.material = flowMat
|
|
|
|
|
+ mesh.renderOrder = 1
|
|
|
|
|
+ flowMaterials.push(flowMat)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 给名为 "bian" 的部件应用发光材质
|
|
|
|
|
+ if (mesh.name === 'bian') {
|
|
|
|
|
+ console.log('[发光] 给 "bian" 部件应用发光材质')
|
|
|
|
|
+ mesh.material = new THREE.MeshStandardMaterial({
|
|
|
|
|
+ color: 0x004488,
|
|
|
|
|
+ emissive: 0x0088ff,
|
|
|
|
|
+ emissiveIntensity: 1.5,
|
|
|
|
|
+ metalness: 0.3,
|
|
|
|
|
+ roughness: 0.4,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ scene.add(modelGroup)
|
|
|
|
|
+ console.log('[加载] xinjiangdiban.glb 加载完成')
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- 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 createTechFloor() {
|
|
|
|
|
+ const floorY = -200
|
|
|
|
|
+ const mat = createTechFloorMaterial()
|
|
|
|
|
+
|
|
|
|
|
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(800000, 800000), mat)
|
|
|
|
|
+ mesh.rotation.x = -Math.PI / 2
|
|
|
|
|
+ mesh.position.y = floorY
|
|
|
|
|
+ scene.add(mesh)
|
|
|
|
|
+
|
|
|
|
|
+ // 每帧更新 uTime
|
|
|
|
|
+ const origRender = composer.render.bind(composer)
|
|
|
|
|
+ const renderWrapper = () => {
|
|
|
|
|
+ mat.uniforms.uTime.value = performance.now() * 0.001
|
|
|
|
|
+ origRender()
|
|
|
}
|
|
}
|
|
|
|
|
+ composer.render = renderWrapper
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function addIcon() {
|
|
|
|
|
- const LON = 84.232272
|
|
|
|
|
- const LAT = 37.613076
|
|
|
|
|
|
|
+let flowMaterials: THREE.ShaderMaterial[] = []
|
|
|
|
|
|
|
|
- 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() {
|
|
|
|
|
+ animationId = requestAnimationFrame(animate)
|
|
|
|
|
+
|
|
|
|
|
+ // 更新流光材质的 uTime
|
|
|
|
|
+ if (flowMaterials.length > 0) {
|
|
|
|
|
+ const time = performance.now() * 0.001
|
|
|
|
|
+ flowMaterials.forEach((mat) => {
|
|
|
|
|
+ mat.uniforms.uTime.value = time
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ controls.update()
|
|
|
|
|
+ composer.render()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function animate() {
|
|
|
|
|
- animId = requestAnimationFrame(animate)
|
|
|
|
|
- map?.update(camera)
|
|
|
|
|
- controls?.update()
|
|
|
|
|
- renderer?.render(scene, camera)
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 使用 geodesy 将经纬度转为局部 ENU 坐标并标记到场景中
|
|
|
|
|
+ */
|
|
|
|
|
+function addGeodesyTestMarkers() {
|
|
|
|
|
+ // 输出 ECEF 坐标示例
|
|
|
|
|
+ const originECEF = latLonToECEF(ORIGIN_LAT, ORIGIN_LON)
|
|
|
|
|
+ console.log(`[geodesy] 原点 ECEF: (${originECEF.x.toFixed(2)}, ${originECEF.y.toFixed(2)}, ${originECEF.z.toFixed(2)})`)
|
|
|
|
|
+
|
|
|
|
|
+ // 验证双向转换
|
|
|
|
|
+ const backToGeo = ecefToLatLon(originECEF)
|
|
|
|
|
+ console.log(`[geodesy] ECEF→经纬度: (${backToGeo.lat.toFixed(6)}, ${backToGeo.lon.toFixed(6)}, height=${backToGeo.height.toFixed(2)})`)
|
|
|
|
|
+
|
|
|
|
|
+ // 在场景中标记测试点
|
|
|
|
|
+ testPoints.forEach((pt) => {
|
|
|
|
|
+ // 使用 ENU 局部坐标 (适合局部场景)
|
|
|
|
|
+ const localPos = latLonToLocalENU(pt.lat, pt.lon, 0, ORIGIN_LAT, ORIGIN_LON)
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[geodesy] ${pt.label} (${pt.lat}, ${pt.lon}) → 局部坐标: (${localPos.x.toFixed(2)}, ${localPos.y.toFixed(2)}, ${localPos.z.toFixed(2)})`)
|
|
|
|
|
+
|
|
|
|
|
+ // 标记点 - 红色小球
|
|
|
|
|
+ const sphere = new THREE.Mesh(
|
|
|
|
|
+ new THREE.SphereGeometry(50, 16, 16),
|
|
|
|
|
+ new THREE.MeshStandardMaterial({ color: 0xff3333 }),
|
|
|
|
|
+ )
|
|
|
|
|
+ sphere.position.copy(localPos)
|
|
|
|
|
+ scene.add(sphere)
|
|
|
|
|
+ markerPositions[pt.label] = localPos.clone()
|
|
|
|
|
+
|
|
|
|
|
+ // 标签 - 使用 Sprite
|
|
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
|
|
+ canvas.width = 256
|
|
|
|
|
+ canvas.height = 64
|
|
|
|
|
+ const ctx = canvas.getContext('2d')!
|
|
|
|
|
+ ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
|
|
|
|
+ ctx.roundRect(0, 0, 256, 64, 8)
|
|
|
|
|
+ ctx.fill()
|
|
|
|
|
+ ctx.fillStyle = '#ffffff'
|
|
|
|
|
+ ctx.font = 'bold 20px sans-serif'
|
|
|
|
|
+ ctx.textAlign = 'center'
|
|
|
|
|
+ ctx.textBaseline = 'middle'
|
|
|
|
|
+ ctx.fillText(pt.label, 128, 32)
|
|
|
|
|
+
|
|
|
|
|
+ const texture = new THREE.CanvasTexture(canvas)
|
|
|
|
|
+ const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false })
|
|
|
|
|
+ const sprite = new THREE.Sprite(spriteMat)
|
|
|
|
|
+ sprite.position.copy(localPos)
|
|
|
|
|
+ sprite.position.y += 120
|
|
|
|
|
+ sprite.scale.set(400, 100, 1)
|
|
|
|
|
+ scene.add(sprite)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 测试指定坐标 (84.233001, 37.595023) ----------
|
|
|
|
|
+ const testLat = 37.595023
|
|
|
|
|
+ const testLon = 84.233001
|
|
|
|
|
+ const testPos = latLonToLocalENU(testLat, testLon, 0, ORIGIN_LAT, ORIGIN_LON)
|
|
|
|
|
+ console.log(`[标记] (${testLat}, ${testLon}) → 局部坐标: (${testPos.x.toFixed(2)}, ${testPos.y.toFixed(2)}, ${testPos.z.toFixed(2)})`)
|
|
|
|
|
+
|
|
|
|
|
+ const sphere = new THREE.Mesh(
|
|
|
|
|
+ new THREE.SphereGeometry(50, 16, 16),
|
|
|
|
|
+ new THREE.MeshStandardMaterial({ color: 0xff3333 }),
|
|
|
|
|
+ )
|
|
|
|
|
+ sphere.position.copy(testPos)
|
|
|
|
|
+ scene.add(sphere)
|
|
|
|
|
+ markerPositions['38团'] = testPos.clone()
|
|
|
|
|
+
|
|
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
|
|
+ canvas.width = 256
|
|
|
|
|
+ canvas.height = 64
|
|
|
|
|
+ const ctx = canvas.getContext('2d')!
|
|
|
|
|
+ ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
|
|
|
|
+ ctx.roundRect(0, 0, 256, 64, 8)
|
|
|
|
|
+ ctx.fill()
|
|
|
|
|
+ ctx.fillStyle = '#ffffff'
|
|
|
|
|
+ ctx.font = 'bold 20px sans-serif'
|
|
|
|
|
+ ctx.textAlign = 'center'
|
|
|
|
|
+ ctx.textBaseline = 'middle'
|
|
|
|
|
+ ctx.fillText('38团', 128, 32)
|
|
|
|
|
+
|
|
|
|
|
+ const texture = new THREE.CanvasTexture(canvas)
|
|
|
|
|
+ const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false })
|
|
|
|
|
+ const sprite = new THREE.Sprite(spriteMat)
|
|
|
|
|
+ sprite.position.copy(testPos)
|
|
|
|
|
+ sprite.position.y += 120
|
|
|
|
|
+ sprite.scale.set(400, 100, 1)
|
|
|
|
|
+ scene.add(sprite)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function onResize() {
|
|
function onResize() {
|
|
|
if (!containerRef.value) return
|
|
if (!containerRef.value) return
|
|
|
- const w = containerRef.value.clientWidth
|
|
|
|
|
- const h = containerRef.value.clientHeight
|
|
|
|
|
- camera.aspect = w / h
|
|
|
|
|
|
|
+ const width = containerRef.value.clientWidth
|
|
|
|
|
+ const height = containerRef.value.clientHeight
|
|
|
|
|
+ camera.aspect = width / height
|
|
|
camera.updateProjectionMatrix()
|
|
camera.updateProjectionMatrix()
|
|
|
- renderer.setSize(w, h)
|
|
|
|
|
|
|
+ renderer.setSize(width, height)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function disposeScene() {
|
|
|
|
|
+ window.removeEventListener('resize', onResize)
|
|
|
|
|
+ cancelAnimationFrame(animationId)
|
|
|
|
|
+ controls.dispose()
|
|
|
|
|
+ if (composer) composer.dispose()
|
|
|
|
|
+ renderer.dispose()
|
|
|
|
|
+ if (containerRef.value && renderer.domElement.parentElement) {
|
|
|
|
|
+ containerRef.value.removeChild(renderer.domElement)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
initScene()
|
|
initScene()
|
|
|
- window.addEventListener('resize', onResize)
|
|
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
- window.removeEventListener('resize', onResize)
|
|
|
|
|
- cancelAnimationFrame(animId)
|
|
|
|
|
- renderer?.dispose()
|
|
|
|
|
|
|
+ disposeScene()
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<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 ref="containerRef" class="scene-container">
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-for="pt in testPoints"
|
|
|
|
|
+ :key="pt.label"
|
|
|
|
|
+ class="btn"
|
|
|
|
|
+ :class="{ active: focusTarget === pt.label }"
|
|
|
|
|
+ @click="flyTo(pt.label)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ pt.label }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="btn"
|
|
|
|
|
+ :class="{ active: focusTarget === '38团' }"
|
|
|
|
|
+ @click="flyTo('38团')"
|
|
|
|
|
+ >
|
|
|
|
|
+ 38团
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<style scoped>
|
|
<style scoped>
|
|
|
-.map-container {
|
|
|
|
|
- width: 100vw;
|
|
|
|
|
- height: 100vh;
|
|
|
|
|
|
|
+.scene-container {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
|
|
+ position: relative;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
.toolbar {
|
|
.toolbar {
|
|
|
- position: fixed;
|
|
|
|
|
- top: 20px;
|
|
|
|
|
- right: 20px;
|
|
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 16px;
|
|
|
|
|
+ left: 16px;
|
|
|
|
|
+ z-index: 10;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
- flex-direction: column;
|
|
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
gap: 8px;
|
|
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;
|
|
|
|
|
|
|
+.btn {
|
|
|
|
|
+ padding: 6px 14px;
|
|
|
|
|
+ border: 1px solid rgba(255,255,255,0.3);
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ background: rgba(0,0,0,0.5);
|
|
|
|
|
+ color: #ccc;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ backdrop-filter: blur(4px);
|
|
|
transition: all 0.2s;
|
|
transition: all 0.2s;
|
|
|
- min-width: 70px;
|
|
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
-.toolbar-btn:hover {
|
|
|
|
|
- background: rgba(30, 30, 30, 0.9);
|
|
|
|
|
|
|
+.btn:hover {
|
|
|
|
|
+ background: rgba(255,255,255,0.15);
|
|
|
color: #fff;
|
|
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 {
|
|
|
|
|
|
|
+.btn.active {
|
|
|
|
|
+ background: #2196f3;
|
|
|
|
|
+ border-color: #2196f3;
|
|
|
color: #fff;
|
|
color: #fff;
|
|
|
- width: 70px;
|
|
|
|
|
- text-align: right;
|
|
|
|
|
- font-size: 12px;
|
|
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|