十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cesium圆形绘图工具封装:从Entity交互到批量渲染与导出

Cesium圆形绘图工具封装:从Entity交互到批量渲染与导出 在 Cesium 应用里圆形绘制是标绘功能里最基础的单元也是最容易踩坑的单元。点一下出圆心、再点一下定半径看上去只有两步但真写到生产环境还要处理椭球拾取、动态半径预览、右键取消、贴地高度、批量圆心数据接入等问题。一个 Circle 绘图工具远比想象中复杂。这篇围绕“Cesium 绘图工具 - Circle”讲三件事第一用 Entity ellipse 实现鼠标交互画圆第二把画圆封装成可复用的 CircleDrawTool 类支持动态半径、右键取消、二次编辑第三补齐批量绘制、GeoJSON 导出、坐标精度和渲染性能这些容易被忽略的细节。先说结论如果只是临时给 Cesium Viewer 加一个画圆功能几十行代码就能跑通如果想做成生产可用的绘图工具建议把状态机分成 idle / center / radius 三个阶段。这个交互模型和地图标绘里常见的“智能拉圆”一致后续扩展矩形、多边形、箭头都基于同一套框架能直接复用事件管理、撤销逻辑和图层管理能力。1. Circle 圆形绘图工具核心能力速览能力项说明项目类型Cesium 前端绘图交互工具不依赖第三方 GIS 库核心接口Entity EllipseGraphics可选 Primitive / GroundPrimitive 批量渲染交互方式第一次点击定圆心移动鼠标动态显示半径第二次点击完成绘制主要功能圆形绘制、半径实时预览、右键取消、精确半径生成、批量圆心加载、GeoJSON 导出运行环境浏览器端运行依赖 Cesium 库和 WebGL与后端技术栈无关图形模式普通椭球面圆、贴地形圆、指定高度圆API 集成可封装成 CircleDrawTool 类对外提供 start / stop / addCircleFromLngLat / exportGeoJSON 方法批量任务支持传入多组经纬度和半径批量生成圆形可统一显隐与清除适用场景三维标绘、二三维一体化标注、范围圈选、缓冲区示意、Cesium 插件开发主要限制精确测量需考虑 WGS84 椭球曲率超大范围圆形建议用测地线计算而非平面距离这套能力在 Cesium 内部并不算新特性核心都是用Entity的ellipse属性渲染圆。真正拉开差距的是工程封装事件是否及时销毁、坐标是否做椭球校正、绘制的圆能不能导出给后端、批量数据是否会卡掉 WebGL。后面几节会按这个顺序展开。2. 适用场景与使用边界圆形绘图工具最常见的落地场景是三维 GIS 标绘。应急指挥场景里用户要在数字孪生底图上圈出事件影响范围园区管理后台里运营人员要在三维场景中标注某个设备的服务半径车联网可视化平台里需要根据车辆实时位置生成圆形缓冲区。这些场景通常只需要视觉化表达对鼠标交互的流畅度和数据导出能力要求高于几何精度。Circle 工具也适合做前端二三维一体化方案。同一组经纬度和半径数据在二维高德底图上是一个圆在 Cesium 三维场景里也应该是一个圆。把画圆逻辑从业务代码里抽出来定义成 addCircleFromLngLat 这类统一方法后端只需要存一组中心点坐标加半径前端负责渲染业务侧不需要感知二维还是三维。不适合的场景也要明确。如果要做基于精确投影的工程测量圆形边界必须由后端或桌面 GIS 按大地线计算前端画出来的圆只能作为示意。在国内生产环境中对底图服务的选择还涉及互联网地图服务合规要求要把 Cesium 默认地形、在线影像更换为有合法授权的底图源不能在生产环境直接引用来源不明的地图瓦片。涉及人员位置、敏感设施等坐标数据时发布前要做脱敏和权限控制。3. 实现方案与核心 API 选择3.1 Entity ellipse 交互方案Entity 是 Cesium 面向业务开发者的高层封装。创建圆形实体时position表示圆心ellipse.semiMajorAxis和ellipse.semiMinorAxis表示半径。把两个轴设置成相同数值椭圆就变成圆。半径在绘制过程中是变化的可以直接给semiMajorAxis赋一个数字也可以传入CallbackProperty让半径属性始终保持响应式。对于动态预览使用普通对象赋值最简单每次鼠标移动更新值即可Cesium 内部会触发图形更新。const circleEntity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.9, 0), ellipse: { semiMajorAxis: 1000, semiMinorAxis: 1000, height: 0, material: Cesium.Color.RED.withAlpha(0.3), outline: true, outlineColor: Cesium.Color.RED, outlineWidth: 2 } });上面的代码是静态画圆。鼠标交互还需要ScreenSpaceEventHandler监听点击和移动事件事件回调里拿到屏幕坐标后用viewer.camera.pickEllipsoid把屏幕点还原成 WGS84 椭球面上的Cartesian3坐标。这一步决定了后续所有圆的圆心精度。3.2 Primitive / GroundPrimitive 批量渲染方案Entity 使用方便但数据量大了之后每个圆都会产生额外的管理和绘制开销。对于成百上千个静态圆形更合适的方案是合并成Primitive或GroundPrimitive。Entity 适合交互绘制阶段Primitive 适合一次性渲染已有数据阶段。实际项目里我建议把 Entity 当成“绘图交互层的解决方案”把 Primitive 当成“大数据批量层的解决方案”。交互画圆时用一个 Entity 做预览等用户确认后再把结果数据交给批量图层渲染。这样既能保证交互流畅又能避免长期积累大量 Entity 导致场景帧率下降。4. 开发环境准备4.1 初始化 Cesium 项目Cesium 工具代码需要运行在支持 WebGL 的浏览器中建议使用 Vite 作为工程化底座。实际接入 Cesium 时可以通过 npm 安装官方最新版本也可以按官方文档使用 CDN 方式。生产环境建议锁住一个验证过的版本避免 Cesium 升级导致 API 变化。npm init -y npm install cesium npm install -D vite4.2 初始化 Viewer在 Vite 项目中引入 Cesium需要同时引入 CSS 和核心库。Cesium 新版 API 对异步地形初始化更友好可以先初始化 Viewer再异步设置地形数据。import * as Cesium from cesium; import cesium/Build/Cesium/Widgets/widgets.css; // 使用 Cesium Ion 时填入自己的 Token Cesium.Ion.defaultAccessToken your-cesium-ion-token; async function initCesium() { const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, infoBox: false, selectionIndicator: false, baseLayerPicker: true, shouldAnimate: true }); // 生产环境请替换为有合法授权的底图和地形服务 try { viewer.terrainProvider await Cesium.createWorldTerrainAsync(); } catch (e) { console.warn(地形服务加载失败继续使用默认椭球体, e); } viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(116.39, 39.9, 20000) }); return viewer; } const viewer await initCesium();如果不需要在线影像和真实地形也可以不填 Ion Token直接使用 Cesium 默认的空白地球圆形绘制核心逻辑不受影响。这样本地开发门槛会低很多只要有一个 HTML 容器和浏览器就能开始验证。5. Circle 圆形绘图工具完整实现5.1 交互状态设计圆形绘制工具的核心是状态机。常见交互方式有几种最符合地图用户习惯的是第一次点击确定圆心移动鼠标时出现半透明预览圆第二次点击确定半径并完成绘制右键取消当前绘制。这三个状态定义成一个mode字段即可不用引入复杂状态管理库。mode: idle // 空闲状态 mode: drawing // 已点圆心正在等待第二次点击代码逻辑上只需要判断两次分支。初次点击时创建 previewEntity后续点击时提交实体并重置状态。如果还要支持连续绘制可以在提交完成后把状态恢复为 idle。若要支持右键删除已经画好的圆再加一个右键点击事件判断当前状态。5.2 圆心拾取与半径计算圆心坐标使用pickEllipsoid获取。这里要注意的是pickEllipsoid返回的是三维世界坐标不是经纬度因此需要利用Cartographic.fromCartesian转成经纬度和高度。半径如果直接使用Cartesian3.distance计算得到的是圆心到目标点的空间直线距离范围小时误差不大范围较大时建议用EllipsoidGeodesic计算椭球面上的测地距离结果更接近真实地表距离。_calcRadius(cartesian) { const cartographic Cesium.Cartographic.fromCartesian(cartesian); const geodesic new Cesium.EllipsoidGeodesic(this.centerCartographic, cartographic); return geodesic.surfaceDistance; }EllipsoidGeodesic.surfaceDistance得到的是沿椭球面的距离单位是米。对于几千公里的跨区域圆形标注这个误差控制远比平面公式可靠。如果只是半径 500 米到 5 公里的城市级圆形用哪种算法差异已经非常小可以在注释里说明。5.3 动态预览实现预览圆的实现方法是第一次点击后添加一个半透明圆实体圆心已经固定半径初始为 0鼠标移动事件触发时不断用当前鼠标位置与圆心计算距离并更新椭圆半轴。_updatePreview(radius) { if (!this.previewEntity) return; this.previewEntity.ellipse.semiMajorAxis Math.max(this.minRadius, radius); this.previewEntity.ellipse.semiMinorAxis Math.max(this.minRadius, radius); }5.4 完整 CircleDrawTool 类把绘制逻辑封装成一个类内部管理事件和数据外部只需要调用构造器和 start 方法。这样在 Vue、React 等框架里都能复用。import * as Cesium from cesium; export class CircleDrawTool { constructor(viewer, options {}) { this.viewer viewer; this.handler null; this.previewEntity null; this.centerCartesian null; this.centerCartographic null; this.radiusMeters 0; this.mode idle; this.minRadius options.minRadius || 1; this.material options.material || Cesium.Color.RED.withAlpha(0.3); this.outlineColor options.outlineColor || Cesium.Color.RED; this.outlineWidth options.outlineWidth || 2; this.onDrawEnd options.onDrawEnd || null; } start() { if (this.handler) return; const handler new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); handler.setInputAction((movement) { this._handleLeftClick(movement); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); handler.setInputAction((movement) { this._handleMouseMove(movement); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); handler.setInputAction(() { this.cancel(); }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); this.handler handler; } stop() { if (!this.handler) return; this.handler.destroy(); this.handler null; this.cancel(); } cancel() { if (this.previewEntity) { this.viewer.entities.remove(this.previewEntity); this.previewEntity null; } this.mode idle; this.centerCartesian null; this.centerCartographic null; this.radiusMeters 0; } _pickCartesian(screenPosition) { if (!Cesium.defined(screenPosition)) return null; return this.viewer.camera.pickEllipsoid(screenPosition, Cesium.Ellipsoid.WGS84); } _handleLeftClick(movement) { const cartesian this._pickCartesian(movement.position); if (!cartesian) return; const cartographic Cesium.Cartographic.fromCartesian(cartesian); if (this.mode idle) { this.mode drawing; this.centerCartesian cartesian; this.centerCartographic cartographic; this.radiusMeters 0; this.previewEntity this._createPreviewEntity(cartographic.height); return; } if (this.mode drawing) { this.radiusMeters Math.max(this.minRadius, this._calcRadius(cartesian)); this._updatePreview(this.radiusMeters); this._commit(); } } _handleMouseMove(movement) { if (this.mode ! drawing) return; const cartesian this._pickCartesian(movement.endPosition); if (!cartesian) return; this.radiusMeters Math.max(this.minRadius, this._calcRadius(cartesian)); this._updatePreview(this.radiusMeters); } _calcRadius(cartesian) { const cartographic Cesium.Cartographic.fromCartesian(cartesian); const geodesic new Cesium.EllipsoidGeodesic(this.centerCartographic, cartographic); return geodesic.surfaceDistance; } _createPreviewEntity(height) { const ellipseOptions { semiMajorAxis: 0, semiMinorAxis: 0, material: this.material, outline: true, outlineColor: this.outlineColor, outlineWidth: this.outlineWidth }; if (height ! undefined) { ellipseOptions.height height; } return this.viewer.entities.add({ position: this.centerCartesian, ellipse: ellipseOptions }); } _commit() { const entity this.previewEntity; this.previewEntity null; const center this.centerCartographic; const lng Cesium.Math.toDegrees(center.longitude); const lat Cesium.Math.toDegrees(center.latitude); const result { entity, lng, lat, radius: this.radiusMeters, centerCartesian: this.centerCartesian }; this.mode idle; this.centerCartesian null; this.centerCartographic null; if (this.onDrawEnd) { this.onDrawEnd(result); } } }调用方式也很直接const circleTool new CircleDrawTool(viewer, { material: Cesium.Color.ORANGE.withAlpha(0.25), outlineColor: Cesium.Color.ORANGE, onDrawEnd: (result) { console.log(圆形完成, result.lng, result.lat, result.radius); } }); circleTool.start();此时在浏览器里可以看到第一次点击出现圆心鼠标拖动时圆形会跟随鼠标实时改变大小第二次点击后圆形成型。右键点击可以取消当前未完成的绘制。6. 交互增强与功能扩展6.1 精确半径绘制鼠标拖拽画出的圆半径不够精确生产系统通常需要支持手动输入半径。增加一个addCircleFromLngLat方法即可它能根据经纬度和半径直接生成圆实体适合做双击定位、经纬度输入框联动和批量数据回显。function addCircleFromLngLat(viewer, lng, lat, radiusMeters, options {}) { const height options.height ! undefined ? options.height : 0; const entity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(lng, lat, height), ellipse: { semiMajorAxis: radiusMeters, semiMinorAxis: radiusMeters, height, material: options.material || Cesium.Color.BLUE.withAlpha(0.25), outline: options.outline ! false, outlineColor: options.outlineColor || Cesium.Color.BLUE, outlineWidth: options.outlineWidth || 2 } }); return entity; }判断成功的标准是在页面上输入经纬度 116.39、39.9 和半径 2000能在对应位置看到半径为 2 公里的圆如果使用底图比例尺对比半径应该与地图测量结果基本一致。6.2 图层管理画完一个圆后最怕用户在场景里找不到删除入口。工具类应该维护一个数组或 CustomDataSource记录所有绘制完成的实体。CustomDataSource的好处是圆可以按业务逻辑分组支持统一显隐、删除和排序。const circleDataSource new Cesium.CustomDataSource(circleLayer); viewer.dataSources.add(circleDataSource); function addCircleToLayer(lng, lat, radiusMeters) { return circleDataSource.entities.add({ position: Cesium.Cartesian3.fromDegrees(lng, lat), ellipse: { semiMajorAxis: radiusMeters, semiMinorAxis: radiusMeters, material: Cesium.Color.GREEN.withAlpha(0.3), outline: true, outlineColor: Cesium.Color.GREEN } }); } function clearCircleLayer() { circleDataSource.entities.removeAll(); }这样工具模块与业务模块解耦CircleDrawTool 只负责画圆和修改临时状态真正管理已绘制圆的是数据源。后面做图层树开关、删除当前选中圆、导出全部圆都变得很容易。6.3 与 Vue3 / React 项目的集成在 Vue3 项目里CircleDrawTool 不应该放在组件内部每次重建。更稳妥的做法是初始化 Cesium Viewer 之后把 viewer 实例保存在一个单例模块中CircleDrawTool 只在组件挂载后创建一次。// circleStore.js export const circleState { tool: null, viewer: null };组件卸载时执行circleState.tool.stop()销毁 ScreenSpaceEventHandler避免事件监听污染。事件绑定过多次数最容易造成“点击一次后圆出现两三个”的问题根因就是组件热更新或重复挂载没有执行 stop。7. 批量绘制、数据接入与导出7.1 批量圆心数据加载实际项目不会只画一两个圆。后端接口返回一组业务对象后前端需要把这些数据统一渲染到场景中。批量加载的套路是先创建统一数据源再循环 add。async function loadCircleData(viewer, apiData) { const dataSource new Cesium.CustomDataSource(businessCircles); viewer.dataSources.add(dataSource); apiData.forEach((item) { dataSource.entities.add({ position: Cesium.Cartesian3.fromDegrees(item.lng, item.lat, item.height || 0), ellipse: { semiMajorAxis: item.radius, semiMinorAxis: item.radius, height: item.height || 0, material: Cesium.Color.fromCssColorString(item.color || #3388ff).withAlpha(0.25), outline: true, outlineColor: Cesium.Color.fromCssColorString(item.color || #3388ff), outlineWidth: 2 } }); }); return dataSource; }接口返回的数据字段可以与后端约定为{ lng, lat, radius, color, height }。这里的radius单位是米与 Cesium 的semiMajorAxis保持一致避免出现单位换算错误。7.2 圆形转 GeoJSON 导出用户画完圆之后如果只是停留在画面上数据无法进入业务系统。让绘图工具具有导出能力才能真正完成“标绘 - 存储 - 回显”闭环。圆的存储通常转成圆形边界多边形后端保存这个多边形即可。function exportCircleToGeoJSON(lng, lat, radiusMeters, segments 64) { const coordinates []; for (let i 0; i segments; i) { const bearing (i / segments) * Math.PI * 2; const deltaLat (radiusMeters * Math.cos(bearing)) / 111320.0; const deltaLng (radiusMeters * Math.sin(bearing)) / (111320.0 * Math.cos((lat * Math.PI) / 180.0)); coordinates.push([lng deltaLng, lat deltaLat]); } coordinates.push(coordinates[0]); return { type: Feature, properties: { center: [lng, lat], radius: radiusMeters }, geometry: { type: Polygon, coordinates: [coordinates] } }; }上面的代码使用的是球面近似公式适合百公里以内的圆形导出注释必须标注精度适用边界。如果项目对精度要求很高应改用 Cesium 的几何计算把圆采样成一组边界点再落库。导出成功后通常还需要一个“清除画布”按钮避免重复画圆导致数据源膨胀。7.3 从后端读取已保存圆形读取已保存圆的核心是坐标解析。后端返回的如果是 GeoJSON前端可以自己遍历 FeatureCollection跳过非 Polygon 类型把 center 和半径恢复成 Cesium 圆实体。如果后端只保存了多边形坐标前端需要计算多边形外接圆圆心和半径这一步更复杂建议业务接口直接约定保存 center 和 radius必要时同时保存 GeoJSON以简化成本。8. 渲染性能与资源占用观察Cesium 是 WebGL 应用浏览器显存占用会随着场景实体、纹理、地形变化而波动。圆形绘制工具本身只产生少量几何体正常情况下不会成为性能瓶颈但以下三个问题值得关注。第一动态预览更新频率。鼠标移动事件在绘制阶段会高频触发每次移动都要更新椭圆半轴。鼠标在场景上快速滑动时事件频率可能达到每秒几十次这种频率下不能执行复杂计算。我在 CircleDrawTool 中的_calcRadius直接使用EllipsoidGeodesic计算几十次计算完全可接受但如果把大量 DOM 操作或请求逻辑放进鼠标移动事件帧率会明显下降。第二实体数量。几十个圆形用 Entity 很轻松几百个也基本流畅上千个就需要观察帧率。鼠标拖拽场景、旋转视角时如果实体调用次数过高应该把静态圆切换到 Primitive。Cesium 官方性能分析里Entity 适合中小数量Primitive 适合大批量复用外观。第三图层清理。圆形的 Outline 和半透明材质都会产生额外的渲染指令。多次重复点击“绘制”却不清理已经提交的实体会导致 Draw Call 不断增加。开发阶段可以在requestAnimationFrame回调里临时输出viewer.scene.primitives.length和viewer.entities.values.length确认实体数量是否符合预期。function checkSceneStatus(viewer) { console.log(entities:, viewer.entities.values.length); console.log(primitives:, viewer.scene.primitives.length); console.log(groundPrimitives:, viewer.scene.groundPrimitives.length); }浏览器 DevTools 的 Performance 面板可以用来录制鼠标移动时的耗时。如果某段脚本执行时间明显超过 16ms说明绘制逻辑里出现了高耗时操作要优先排查事件回调内是否做了多余的坐标反算或数据源查询。9. 常见问题与排查方法问题现象可能原因排查方式解决方案点击不画圆也没有预览实体pickEllipsoid 返回 undefined说明点击位置没有落在椭球表面在点击回调内打印 movement.position 和 pickEllipsoid 返回值先判断返回值为空直接 return关闭场景中鼠标选中干扰或使用 scene.pickPosition 替代圆形总是吸附到某个中心点圆心坐标使用旧版本或重复点击后中心点没有清空检查 mode 状态和中心点重置逻辑在 cancel 和 stop 中统一重置 centerCartesian、centerCartographic圆不是正圆而是椭圆semiMajorAxis 与 semiMinorAxis 不一致或数据单位不是米查看代码中 ellipse 两个半轴赋值画圆时两个半轴都赋 radius不要单独改其中一个半径差很大和底图比例尺对不上使用 Cartesian3.distance 计算三维直线距离导致大范围误差对半径 100 km 以上的圆做对比测试改用 EllipsoidGeodesic.surfaceDistance 计算椭球表面距离圆沉入地形或飘在空中height 设置不正确或底图地形起伏较大检查圆心 Cartographic.height 和 terrainProvider需要贴地时使用 heightReference: CLAMP_TO_GROUND或先处理地形拾取坐标连续点击出现多个重复圆ScreenSpaceEventHandler 未销毁或重复绑定在控制台打印 handler 是否已存在start 方法里判断 handler 是否已创建组件卸载时调用 stop 销毁事件清除圆后页面出现残留半透明色块Entity 删除后 Primitive 或材质缓存未释放或存在隐藏 entity使用 viewer.entities.removeAll 并检查 scene.primitives对非 Entity 对象需要调用 primitive.destroy() 释放 WebGL 资源高分辨率屏幕上拾取坐标偏移没有适配 devicePixelRatio或 Canvas 尺寸与 CSS 尺寸不一致检查浏览器窗口大小和 Canvas 尺寸使用 Cesium 默认的像素坐标处理避免手动换算 window.innerWidth排查时最有效的方式是把事件回调里的关键数据打出来。第一次点击后打印 centerCartographic移动鼠标时打印 radiusMeters第二次点击后打印提交的实体。看到哪一步数据断掉就能判断是事件问题、坐标问题还是渲染问题。10. 最佳实践与下一步建议从这个最基础的 Circle 绘制工具出发后续可以按以下方向迭代。第一形成统一标绘工具链。把 CircleDrawTool 抽出来的状态管理、事件绑定、图层管理逻辑应用到矩形、多边形、折线、箭头等工具中。每个工具都只负责“绘制交互”绘制完成的结果统一提交到同一个标绘图层业务侧不感知当前是圆还是多边形。第二完善撤销与回显能力。给每个绘制完成的实体分配唯一 ID维护一个已绘制数组删除时不仅移除实体还要从数组中移除。这样配合后端批量接口就能实现一次加载、多次回显。第三建立数据精度约定。前端绘图圆形的半径单位统一为米圆心坐标统一为 WGS84 经纬度。后端存储时保留 center 和 radius 两个字段同时提供 GeoJSON 导出接口给其他 GIS 系统使用。第四接入贴地和 WebGL 资源释放场景前先做性能压测。不要直接在一个已经加载了大量倾斜摄影、BIM、点云数据的 Cesium Viewer 里裸跑包含上千个 Entity 圆形的业务应该先用批量接口和CustomDataSource验证交互流程确认帧率可接受再上线。Circle 工具是整个 Cesium 标绘体系里的入门组件但它涉及的问题非常完整屏幕坐标转地理坐标、动态实体更新、角度和距离的单位、图层生命周期、事件销毁、数据导出。把这些坑提前摸一遍后面开发更复杂的标绘功能会顺畅很多。建议在本地直接创建一个 Vite 项目把上面的 CircleDrawTool 类粘贴进去跑一圈先观察动态预览效果再逐步加入精确半径和 GeoJSON 导出就能形成一套可复用的圆形标绘模块。
返回列表