用Cesium.js和CZML手搓一个无人机航线编辑器(附完整代码)
在无人机应用日益普及的今天,航线规划工具成为了开发者和操作人员不可或缺的助手。不同于商业软件的黑箱操作,自己动手构建一个轻量级航线编辑器不仅能满足特定需求,还能深入理解地理信息系统(GIS)与无人机飞控的结合原理。本文将带您从零开始,使用开源的Cesium.js三维地球库和CZML数据格式,打造一个功能完备的无人机航线编辑器。
这个工具将具备以下核心功能:
- 交互式地图上的航线绘制与编辑
- 航点高度、速度等参数的可视化调整
- CZML格式的导入导出能力
- 实时3D飞行预览
1. 环境准备与Cesium基础配置
1.1 项目初始化
首先创建一个标准的Web项目结构:
/drone-path-editor ├── index.html ├── scripts/ │ └── main.js ├── styles/ │ └── main.css └── assets/ └── czml/安装Cesium.js的最简单方式是通过CDN引入,在index.html中添加:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>无人机航线编辑器</title> <link href="https://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Widgets/widgets.css" rel="stylesheet"> <link href="styles/main.css" rel="stylesheet"> </head> <body> <div id="cesiumContainer"></div> <div id="controlPanel" class="control-panel"> <!-- 控制界面将在这里动态生成 --> </div> <script src="https://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Cesium.js"></script> <script src="scripts/main.js" type="module"></script> </body> </html>1.2 Cesium Viewer初始化
在main.js中配置基础地图视图:
Cesium.Ion.defaultAccessToken = '您的Cesium Ion访问令牌'; const viewer = new Cesium.Viewer('cesiumContainer', { terrainProvider: Cesium.createWorldTerrain(), timeline: true, animation: true, sceneModePicker: true, baseLayerPicker: false, imageryProvider: new Cesium.IonImageryProvider({ assetId: 3845 }), shouldAnimate: true }); // 禁用默认的地图操作冲突 viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;提示:获取Cesium Ion访问令牌需要注册免费账户,开发阶段可以使用测试令牌。
2. CZML数据结构解析与航线建模
2.1 理解CZML格式
CZML(发音为"Cee-Zee-M-L")是Cesium团队专门为动态场景设计的JSON格式。一个典型的航线CZML文档包含以下结构:
[ { "id": "document", "name": "无人机航线", "version": "1.0" }, { "id": "path1", "name": "测绘航线", "polyline": { "positions": { "cartographicDegrees": [ -75.0, 35.0, 100, -75.1, 35.1, 120, -75.2, 35.2, 100 ] }, "material": { "polylineGlow": { "color": {"rgba": [0, 255, 255, 255]} } }, "width": 8, "clampToGround": false } } ]关键参数说明:
| 参数 | 类型 | 描述 |
|---|---|---|
| positions.cartographicDegrees | Array | 经度、纬度、高度(米)的三元组序列 |
| material | Object | 定义线条的渲染样式 |
| width | Number | 线宽(像素) |
| clampToGround | Boolean | 是否贴地 |
2.2 动态生成CZML
我们需要创建一个函数,将用户交互生成的航点转换为CZML:
function generateCZML(waypoints) { const czml = [ { id: "document", name: "无人机航线", version: "1.0" } ]; const positions = []; waypoints.forEach(wp => { positions.push(wp.longitude, wp.latitude, wp.altitude); }); czml.push({ id: "flightPath", name: "飞行路径", polyline: { positions: { cartographicDegrees: positions }, material: { polylineGlow: { color: { rgba: [0, 255, 255, 200] }, glowPower: 0.2 } }, width: 6, clampToGround: false } }); return czml; }3. 交互式航线编辑实现
3.1 航点绘制逻辑
实现鼠标点击添加航点的核心代码:
let waypoints = []; let activePoint = null; viewer.screenSpaceEventHandler.setInputAction((movement) => { const ray = viewer.camera.getPickRay(movement.position); const position = viewer.scene.globe.pick(ray, viewer.scene); if (position) { const cartographic = Cesium.Cartographic.fromCartesian(position); const longitude = Cesium.Math.toDegrees(cartographic.longitude); const latitude = Cesium.Math.toDegrees(cartographic.latitude); const altitude = cartographic.height; const newPoint = { longitude, latitude, altitude: altitude + 50 // 默认离地50米 }; waypoints.push(newPoint); updateFlightPath(); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);3.2 航点拖拽编辑
为航点添加可拖拽功能:
function createDraggablePoint(point) { const entity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees( point.longitude, point.latitude, point.altitude ), point: { pixelSize: 15, color: Cesium.Color.RED, outlineColor: Cesium.Color.WHITE, outlineWidth: 2, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } }); // 拖拽交互处理 viewer.screenSpaceEventHandler.setInputAction((movement) => { const pickedObject = viewer.scene.pick(movement.position); if (pickedObject && pickedObject.id === entity) { activePoint = entity; } }, Cesium.ScreenSpaceEventType.LEFT_DOWN); viewer.screenSpaceEventHandler.setInputAction((movement) => { if (activePoint) { const ray = viewer.camera.getPickRay(movement.endPosition); const position = viewer.scene.globe.pick(ray, viewer.scene); if (position) { activePoint.position = position; const cartographic = Cesium.Cartographic.fromCartesian(position); const index = waypoints.findIndex( wp => wp.longitude === point.longitude && wp.latitude === point.latitude ); if (index !== -1) { waypoints[index] = { longitude: Cesium.Math.toDegrees(cartographic.longitude), latitude: Cesium.Math.toDegrees(cartographic.latitude), altitude: cartographic.height }; updateFlightPath(); } } } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); viewer.screenSpaceEventHandler.setInputAction(() => { activePoint = null; }, Cesium.ScreenSpaceEventType.LEFT_UP); return entity; }4. 高级功能实现
4.1 禁飞区检测
实现简单的圆形禁飞区检测:
function isInNoFlyZone(point, noFlyZones) { return noFlyZones.some(zone => { const distance = Cesium.Cartesian3.distance( Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, 0), Cesium.Cartesian3.fromDegrees(zone.longitude, zone.latitude, 0) ); return distance < zone.radius; }); } // 示例禁飞区数据 const noFlyZones = [ { longitude: -75.2, latitude: 35.1, radius: 1000 }, { longitude: -75.3, latitude: 35.3, radius: 1500 } ]; // 在添加航点时检查 viewer.screenSpaceEventHandler.setInputAction((movement) => { // ... 原有代码 ... if (isInNoFlyZone(newPoint, noFlyZones)) { viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees( newPoint.longitude, newPoint.latitude, newPoint.altitude ), label: { text: "禁飞区!", font: '14pt sans-serif', style: Cesium.LabelStyle.FILL_AND_OUTLINE, outlineWidth: 2, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -20), fillColor: Cesium.Color.RED } }); return; } // ... 继续原有逻辑 ... }, Cesium.ScreenSpaceEventType.LEFT_CLICK);4.2 3D飞行预览
使用Cesium的相机飞行动画实现航线预览:
function previewFlightPath() { if (waypoints.length < 2) return; const positions = waypoints.map(wp => Cesium.Cartesian3.fromDegrees(wp.longitude, wp.latitude, wp.altitude) ); const property = new Cesium.SampledPositionProperty(); const startTime = Cesium.JulianDate.fromDate(new Date()); const stopTime = Cesium.JulianDate.addSeconds( startTime, waypoints.length * 5, // 假设每个航点间飞行5秒 new Cesium.JulianDate() ); viewer.clock.startTime = startTime.clone(); viewer.clock.stopTime = stopTime.clone(); viewer.clock.currentTime = startTime.clone(); viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; viewer.clock.multiplier = 1; viewer.timeline.zoomTo(startTime, stopTime); // 创建采样点 waypoints.forEach((wp, index) => { const time = Cesium.JulianDate.addSeconds( startTime, index * 5, new Cesium.JulianDate() ); const position = Cesium.Cartesian3.fromDegrees( wp.longitude, wp.latitude, wp.altitude ); property.addSample(time, position); // 添加航点时间标记 viewer.entities.add({ position: position, point: { pixelSize: 10, color: Cesium.Color.YELLOW, outlineColor: Cesium.Color.BLACK, outlineWidth: 1 }, label: { text: `航点 ${index + 1}`, font: '12pt sans-serif', style: Cesium.LabelStyle.FILL_AND_OUTLINE, outlineWidth: 2, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -15) } }); }); // 设置相机跟随 viewer.trackedEntity = viewer.entities.add({ position: property, model: { uri: 'assets/models/drone.glb', minimumPixelSize: 64 }, path: { resolution: 1, material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.2, color: Cesium.Color.CYAN }), width: 10 } }); }5. 完整代码整合与优化
5.1 UI控制面板实现
在controlPanel div中添加交互控件:
function initControlPanel() { const panel = document.getElementById('controlPanel'); panel.innerHTML = ` <div class="control-group"> <h3>航线编辑</h3> <button id="clearPath">清除航线</button> <button id="exportCZML">导出CZML</button> <button id="importCZML">导入CZML</button> <button id="previewFlight">预览飞行</button> </div> <div class="control-group"> <h3>航点参数</h3> <div id="waypointParams" style="display:none"> <label>高度(米): <input type="number" id="wpAltitude" min="0"></label> <label>速度(m/s): <input type="number" id="wpSpeed" min="1" value="5"></label> </div> </div> `; document.getElementById('clearPath').addEventListener('click', () => { viewer.entities.removeAll(); waypoints = []; }); document.getElementById('exportCZML').addEventListener('click', () => { const czml = generateCZML(waypoints); const blob = new Blob([JSON.stringify(czml)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'flight_path.czml'; document.body.appendChild(a); a.click(); document.body.removeChild(a); }); // 其他事件监听... }5.2 性能优化建议
当处理大量航点时,需要考虑以下优化措施:
简化实体渲染:
viewer.scene.globe.showGroundAtmosphere = false; viewer.scene.fog.enabled = false; viewer.scene.skyAtmosphere.show = false;使用Web Worker处理复杂计算:
// 在worker.js中 self.onmessage = function(e) { const { waypoints } = e.data; // 执行耗时的路径计算 const result = complexCalculation(waypoints); postMessage(result); }; // 在主线程中 const worker = new Worker('scripts/worker.js'); worker.postMessage({ waypoints }); worker.onmessage = function(e) { // 处理计算结果 };实现LOD(细节层次)控制:
viewer.scene.screenSpaceCameraController.minimumZoomDistance = 100; viewer.scene.screenSpaceCameraController.maximumZoomDistance = 5000000;
6. 实际应用案例
6.1 农业测绘场景
假设我们需要规划一个农田测绘航线,要求:
- 飞行高度100米
- 航向重叠率80%
- 旁向重叠率60%
实现代码示例:
function generateFarmSurveyPath(area) { const { north, south, east, west } = area; const altitude = 100; const path = []; // 计算航线条数和间距 const latDistance = Cesium.Cartesian3.distance( Cesium.Cartesian3.fromDegrees(west, north, 0), Cesium.Cartesian3.fromDegrees(west, south, 0) ); const lineCount = Math.ceil(latDistance / (altitude * 0.4)); // 60%旁向重叠 const latStep = (north - south) / lineCount; // 生成航线 for (let i = 0; i <= lineCount; i++) { const lat = south + i * latStep; if (i % 2 === 0) { path.push({ longitude: west, latitude: lat, altitude }); path.push({ longitude: east, latitude: lat, altitude }); } else { path.push({ longitude: east, latitude: lat, altitude }); path.push({ longitude: west, latitude: lat, altitude }); } } return path; }6.2 电力巡检场景
对于电力线巡检,需要沿线路保持固定距离:
function generatePowerLinePath(points, distance) { const path = []; for (let i = 0; i < points.length - 1; i++) { const start = Cesium.Cartesian3.fromDegrees( points[i].longitude, points[i].latitude, points[i].altitude ); const end = Cesium.Cartesian3.fromDegrees( points[i+1].longitude, points[i+1].latitude, points[i+1].altitude ); const direction = Cesium.Cartesian3.subtract(end, start, new Cesium.Cartesian3()); const length = Cesium.Cartesian3.magnitude(direction); Cesium.Cartesian3.normalize(direction, direction); const steps = Math.ceil(length / distance); const stepSize = length / steps; for (let j = 0; j <= steps; j++) { const position = Cesium.Cartesian3.add( start, Cesium.Cartesian3.multiplyByScalar(direction, j * stepSize, new Cesium.Cartesian3()), new Cesium.Cartesian3() ); const cartographic = Cesium.Cartographic.fromCartesian(position); path.push({ longitude: Cesium.Math.toDegrees(cartographic.longitude), latitude: Cesium.Math.toDegrees(cartographic.latitude), altitude: cartographic.height }); } } return path; }