资讯详情

Cesium二次开发:Ellipse椭圆绘图工具的完整实现与封装指南

📅 2026/10/7 20:09:50 | 华诺云谱 👁 阅读
Cesium二次开发:Ellipse椭圆绘图工具的完整实现与封装指南
1. Ellipse 在 Cesium 绘图工具中的定位在做 Cesium 二次开发时绘图工具几乎是每个 GIS 项目都绕不开的模块。点、线、面、矩形、圆、椭圆这些基础图形看似简单真正落地时却会牵扯出一堆问题坐标怎么采集、图形怎么预览、参数怎么回调、样式怎么管理、编辑态怎么处理……而 Ellipse椭圆/圆作为基础图形中比较有代表性的一个特别适合拿来做“由浅入深”的完整拆解。很多初学者会问Cesium 不是自带EllipseGeometry和EllipseGraphics吗为什么还要自己封装绘图工具这个问题问得很好。自带 API 解决的是“如何渲染一个椭圆”的问题而绘图工具解决的是“用户如何在地图上交互式地画出一个椭圆”的问题。前者偏底层几何后者偏业务交互。两者结合才是一条完整的链路。本文将以 Ellipse 为例围绕 Cesium 绘图工具的完整实现展开内容包括Ellipse 相关 API 的核心概念与参数体系从鼠标交互到图形落地的完整绘制流程支持椭圆、圆、扇形可扩展的代码设计常见交互 Bug 与工程化避坑方案绘图工具在 Vue3 环境下的接入方式。无论你是刚接触 Cesium 的新手还是正在做项目内绘图模块的进阶开发者这篇文章都会给你一份可以照着写、照着改、照着排查的参考实现。2. Ellipse 相关 API 基础2.1 认识 Cesium 中的 Ellipse 体系在 Cesium 中Ellipse 相关能力分布在两条技术路线上Entity 路线面向对象推荐业务使用Cesium.EntityCesium.EllipseGraphics适合以数据驱动方式描述业务对象。它的特点是代码结构清晰、易于维护并且天然支持拾取、弹出框、属性绑定等高级能力。const ellipseEntity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.9), ellipse: { semiMajorAxis: 1000.0, // 长半轴单位米 semiMinorAxis: 500.0, // 短半轴单位米 material: Cesium.Color.YELLOW.withAlpha(0.6), outline: true } });Primitive 路线面向底层适合海量/高性能场景Cesium.GeometryInstanceCesium.EllipseGeometryCesium.Primitive适合需要频繁更新、批量绘制的场景绘制性能优于 Entity。const geometry new Cesium.EllipseGeometry({ center: Cesium.Cartesian3.fromDegrees(116.39, 39.9), semiMajorAxis: 1000.0, semiMinorAxis: 500.0, vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT }); const instance new Cesium.GeometryInstance({ geometry: geometry, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( Cesium.Color.YELLOW.withAlpha(0.6) ) } }); viewer.scene.primitives.add(new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.PerInstanceColorAppearance({ closed: false }) }));两种路线在本文后续的绘图工具封装中都会用到。Entity 路线用于“绘制预览和最终图形”Primitive 路线可以保留给进阶优化例如大量标绘场景的渲染合并。2.2 EllipseGeometry 参数详解EllipseGeometry是 Cesium 中通过几何参数描述椭圆的核心类。在编写绘图工具之前有必要将它的完整参数列出来因为很多 Bug 都源于对参数的误解。表格 1EllipseGeometry构造参数参数名类型必填说明centerCartesian3是椭圆中心点坐标必须为世界坐标semiMajorAxisNumber是椭圆长半轴长度单位米semiMinorAxisNumber是椭圆短半轴长度单位米rotationNumber否长轴与正北方向的夹角弧度制默认 0heightNumber否椭圆所在高度默认 0extrudedHeightNumber否拉伸高度配合高度值可实现柱体效果granularityNumber否三角剖分粒度弧度值越小越精细vertexFormatVertexFormat否顶点格式定义默认包含位置和贴图坐标stRotationNumber否贴图旋转角度在这份参数表中最容易出错的三个点是rotation 是弧度制不是角度制。很多初学者直接把 45 填进去结果椭圆旋转方向完全不对。如果需要按角度设置可以这样转换Cesium.Math.toRadians(45)。center 要求是世界坐标。如果拿经纬度直接传会得到“坐标位置完全不对”的诡异结果。正确做法是通过Cesium.Cartesian3.fromDegrees(lng, lat)转换。半轴单位是米。在经纬度小范围场景下没有直观问题但如果做跨经纬度的全球场景涉及到大地测量精度时需要使用更精确的椭球计算方法。这里也给出一个扇形的实现示例方便后续设计绘图工具时做“圆/椭圆/扇形”的统一扩展。扇形实际上就是CircleGeometry增加起始角度和终止角度参数。const sectorGeo new Cesium.CircleGeometry({ center: Cesium.Cartesian3.fromDegrees(116.39, 39.9), radius: 500.0, startAngle: Cesium.Math.toRadians(0), // 起始角 endAngle: Cesium.Math.toRadians(90), // 终止角 granularity: Cesium.Math.toRadians(5) });2.3 EllipseGraphics 参数与回调机制如果在 Entity 路线下使用ellipse属性传入的就是EllipseGraphics或其配置对象。它支持数据回调CallbackProperty这是绘图工具实现“鼠标悬浮动态预览”的关键机制。const ellipseEntity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.9), ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() { return currentSemiMajorAxis; // 动态值 }, false), semiMinorAxis: maxRadius, material: Cesium.Color.RED.withAlpha(0.5) } });CallbackProperty(callback, isConstant)的第二个参数表示该值是否为常量。在交互绘制过程中鼠标每一帧移动都希望图形跟着刷新因此设置为false。当绘制完成、不再需要变化后建议将isConstant改为true或直接替换为静态数值以减少 Cesium 每一帧的重复计算开销。3. 绘图工具的交互设计思路3.1 从 “点、线、面” 抽象绘图基类一个健壮的 Cesium 绘图工具不应该只支持 Ellipse。更好的方式是抽象一组通用的绘图基础能力然后由具体图形去实现自己的“绘制逻辑”。我们先从需求层面做一个抽象。以交互式绘制 Ellipse 为例流程如下鼠标单击地图确定椭圆的中心点移动鼠标时实时计算半轴参数并生成预览图形再次单击确定椭圆的边界长半轴或半径若要生成圆可以 Enter/双击结束若要生成椭圆还需要移动鼠标确定短半轴后结束。因此封装时绘图工具至少需要具备几个能力进入绘制模式与退出绘制模式鼠标左击事件确定点位鼠标移动事件动态计算预览鼠标右击/双击事件结束绘制键盘事件Esc 取消、Enter 确认按业务需求提供“完成回调”。下面先给一个通用的绘图工具类骨架后续的 Ellipse 绘制逻辑都是在这个类上进行扩展。class BaseDrawer { constructor(viewer) { this.viewer viewer; this.isDrawing false; this._activeShapePoints []; this._activeShape undefined; this._handlers undefined; this._options {}; } // 开始绘制 startDraw(options {}) { this._options Object.assign({}, this._defaultOptions, options); this.isDrawing true; this._activeShapePoints []; this._createHandlers(); this._bindEvents(); } // 创建鼠标事件处理器 _createHandlers() { this._handlers new Cesium.ScreenSpaceEventHandler( this.viewer.scene.canvas ); } // 绑定事件子类可重写 _bindEvents() {} // 左击 _onLeftClick(event) {} // 移动 _onMouseMove(event) {} // 右击/双击结束 _onRightClick(event) {} // 清空当前绘制的临时图形 _clearActiveShape() { if (this._activeShape) { this.viewer.entities.remove(this._activeShape); this._activeShape undefined; } } // 取消绘制 cancelDraw() { this.isDrawing false; if (this._handlers) { this._handlers.destroy(); this._handlers undefined; } this._clearActiveShape(); } }注意这段代码是基类骨架不是完整可运行代码。真正落地时每个事件方法由子类去实现。这个设计可以避免在同一个类中堆满if (type ellipse)之类的分支判断也让后续增加“矩形、多边形、折线”变得自然。3.2 屏幕坐标与地理坐标转换绘图过程中涉及两类坐标屏幕坐标和地理坐标。鼠标事件拿到的是screenPosition也就是 Canvas 上的像素坐标。而绘制图形需要的是经纬度或世界坐标。Cesium 提供了几种转换方式// 方式一射线拾取地球表面推荐 const ray viewer.camera.getPickRay(windowPosition); const target viewer.scene.globe.pick(ray, viewer.scene); if (Cesium.defined(target)) { const cartographic Cesium.Cartographic.fromCartesian(target); const lng Cesium.Math.toDegrees(cartographic.longitude); const lat Cesium.Math.toDegrees(cartographic.latitude); } // 方式二scene.pickPosition需要开启深度检测常用于模型表面 const cartesian viewer.scene.pickPosition(windowPosition); // 方式三通过 ellipsoid 求交 const cartesian viewer.camera.pickEllipsoid( windowPosition, viewer.scene.globe.ellipsoid );日常业务中优先选择方式一或方式三。原因是scene.pickPosition依赖深度缓冲区如果场景中没有 3D Tiles、模型等可拾取物体它会返回undefined。而地表工具绘制图形绝大多数场景只需要取地球表面坐标即可。4. 实现一个 Cesium Ellipse 绘制工具4.1 项目结构准备为了贴近真实的工程环境下面将演示一个相对完整的模块设计建议按下面的目录组织代码src/ ├── components/ │ └── DrawTools/ │ ├── index.js // 对外导出 │ ├── DrawBase.js // 绘图基类 │ ├── DrawEllipse.js // 椭圆/圆形绘制逻辑 │ └── DrawCircle.js // 圆形绘制逻辑继承 DrawEllipse如果你使用原生 HTML JS 快速验证可以把DrawBase.js和DrawEllipse.js当普通 script 引入。核心代码逻辑保持一致。4.2 DrawEllipse 类实现先写一个最核心的DrawEllipse类。它继承上面的BaseDrawer并实现椭圆绘制事件。// 文件路径src/components/DrawTools/DrawEllipse.js import DrawBase from ./DrawBase.js; class DrawEllipse extends DrawBase { constructor(viewer) { super(viewer); // 记录椭圆的中心点地理坐标 this._center null; // 临时半径对象 this._radius null; } _bindEvents() { // 左击确定中心点 / 确定长半轴和短半轴 this._handlers.setInputAction((event) { this._onLeftClick(event); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); // 鼠标移动绘制预览 this._handlers.setInputAction((event) { this._onMouseMove(event); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // 右击结束 this._handlers.setInputAction((event) { this._onRightClick(event); }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); } _getLatLngFromScreen(position) { const ray this.viewer.camera.getPickRay(position); const target this.viewer.scene.globe.pick(ray, this.viewer.scene); if (!Cesium.defined(target)) return null; const cartographic Cesium.Cartographic.fromCartesian(target); return { lng: Cesium.Math.toDegrees(cartographic.longitude), lat: Cesium.Math.toDegrees(cartographic.latitude), height: cartographic.height }; } _distanceBetween(latlng1, latlng2) { const c1 Cesium.Cartesian3.fromDegrees(latlng1.lng, latlng1.lat); const c2 Cesium.Cartesian3.fromDegrees(latlng2.lng, latlng2.lat); return Cesium.Cartesian3.distance(c1, c2); } _onLeftClick(event) { if (!this.isDrawing) return; const position event.position; const latlng this._getLatLngFromScreen(position); if (!latlng) return; this._activeShapePoints.push(latlng); if (this._activeShapePoints.length 1) { // 第一次点击确定中心点 this._center latlng; } else if (this._activeShapePoints.length 2) { // 第二次点击确定长半轴或圆的半径 const farPoint this._activeShapePoints[1]; const semiMajorAxis this._distanceBetween(this._center, farPoint); // 业务约定isCircle 由外部配置如果画圆则直接结束 if (this._options.isCircle) { this._finishEllipse(semiMajorAxis, semiMajorAxis); } else { // 椭圆模式下第二次点击暂时只确定长半轴 // 后续在移动事件中动态计算短半轴 this._radius { semiMajorAxis, semiMinorAxis: semiMajorAxis }; } } else if (this._activeShapePoints.length 3) { // 第三次点击确定短半轴 const point3 this._activeShapePoints[2]; const semiMinorAxis this._distanceBetween(this._center, point3); const semiMajorAxis this._radius ? this._radius.semiMajorAxis : semiMinorAxis; this._finishEllipse(semiMajorAxis, semiMinorAxis); } } _onMouseMove(event) { if (!this.isDrawing) return; // 没有中心点时不需要绘制预览 if (this._activeShapePoints.length 1) return; const latlng this._getLatLngFromScreen(event.endPosition); if (!latlng) return; if (this._activeShapePoints.length 1) { // 围绕中心点鼠标拉出一个动态圆 const radius this._distanceBetween(this._center, latlng); this._createPreview(radius, radius); } else if (this._activeShapePoints.length 2) { // 椭圆模式用当前鼠标点作为短半轴 const semiMinorAxis this._distanceBetween(this._center, latlng); const semiMajorAxis this._radius.semiMajorAxis; this._createPreview(semiMajorAxis, semiMinorAxis); } } _onRightClick(event) { if (!this.isDrawing) return; // 至少有一个临时椭圆时尝试结束 if (this._activeShapePoints.length 1) { // 如果没有第二次点击则取消 if (this._activeShapePoints.length 2) { this.cancelDraw(); return; } // 如果椭圆模式下 // 已确定长半轴但未手动确定短半轴采用最后一次鼠标位置作为短半轴 const lastLatlng this._activeShapePoints[this._activeShapePoints.length - 1]; const semiMinorAxis this._distanceBetween(this._center, lastLatlng); const semiMajorAxis this._radius ? this._radius.semiMajorAxis : semiMinorAxis; this._finishEllipse(semiMajorAxis, semiMinorAxis); } } _createPreview(semiMajorAxis, semiMinorAxis) { this._clearActiveShape(); if (this._center) { this._activeShape this.viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees( this._center.lng, this._center.lat ), ellipse: { semiMajorAxis: semiMajorAxis, semiMinorAxis: semiMinorAxis, material: this._options.previewMaterial || Cesium.Color.YELLOW.withAlpha(0.4), outline: true, outlineColor: Cesium.Color.YELLOW } }); } } _finishEllipse(semiMajorAxis, semiMinorAxis) { const center this._center; // 把预览中的动态椭圆改为静态可优化直接不删除替换参数 this._clearActiveShape(); const finishedEntity this.viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(center.lng, center.lat), ellipse: { semiMajorAxis: semiMajorAxis, semiMinorAxis: semiMinorAxis, material: this._options.material || Cesium.Color.CYAN.withAlpha(0.6), outline: this._options.outline ! false, outlineColor: this._options.outlineColor || Cesium.Color.BLACK, height: this._options.height || 0, rotation: this._options.rotation || 0 } }); // 把自己作为额外参数传出去 if (this._options.onDrawEnd) { this._options.onDrawEnd({ type: ellipse, center: center, semiMajorAxis: semiMajorAxis, semiMinorAxis: semiMinorAxis, entity: finishedEntity }); } this.cancelDraw(); } } export default DrawEllipse;上面的类包含了完整的绘制流程。有几个实现细节值得展开说明为什么第二次点击在椭圆模式下不立即结束因为椭圆由长半轴和短半轴两个参数确定。第二次点击只确定了长半轴方向上的一个点计算机此时无法唯一确定椭圆还需要让用户移动鼠标给出短半轴长度。这样一种交互虽然比“拖动长轴再拖动短轴”略显繁琐但对新手更容易理解。为什么移动事件中每次都重新创建 entity示例中为了便于理解和保证样式可修改直接调用_clearActiveShape()删除旧 entity 再新建。这是一个有效的实现但在高频鼠标移动场景下频繁创建和销毁 entity 会造成一定的性能开销。一个更优秀的方案是在第一次创建 entity 后复用同一个 entity但将其semiMajorAxis、semiMinorAxis设置为CallbackProperty或者在鼠标移动时直接修改entity.ellipse.semiMajorAxis的值。后者的问题是 entity 的Geometry缓存可能不会自动刷新因此可以使用下面的方式// 更推荐的预览优化只创建一次 entity持续更新参数 _createPreview(semiMajorAxis, semiMinorAxis) { if (!this._activeShape) { this._activeShape this.viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(this._center.lng, this._center.lat), ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() { return this._currentMajorAxis; }, false), semiMinorAxis: new Cesium.CallbackProperty(() { return this._currentMinorAxis; }, false), material: this._options.previewMaterial || Cesium.Color.YELLOW.withAlpha(0.5) } }); } this._currentMajorAxis semiMajorAxis; this._currentMinorAxis semiMinorAxis; }通过CallbackProperty让 Cesium 内部在渲染时读取最新的半径值不需要删除重建 entity性能更好。绘制结束后再将动态 entity 替换为静态 entity即可避免后续无意义的回调计算。4.3 绘制圆形继承还是配置在实际工具中圆形和椭圆是两个极其相似的图形。圆只是椭圆的长半轴和短半轴相等。若单独写一个DrawCircle类会导致大量重复代码。推荐两种方案方案一通过配置参数区分在DrawEllipse类中传入{ isCircle: true }第二次点击后直接把semiMinorAxis semiMajorAxis并结束绘制。方案二继承 DrawEllipse// 文件路径src/components/DrawTools/DrawCircle.js import DrawEllipse from ./DrawEllipse.js; class DrawCircle extends DrawEllipse { constructor(viewer, options {}) { super(viewer); this._forceCircle true; } // 重写左击事件第二步即结束 _onLeftClick(event) { if (!this.isDrawing) return; const position event.position; const latlng this._getLatLngFromScreen(position); if (!latlng) return; this._activeShapePoints.push(latlng); if (this._activeShapePoints.length 1) { this._center latlng; } else if (this._activeShapePoints.length 2) { const radius this._distanceBetween(this._center, latlng); this._finishEllipse(radius, radius); } } } export default DrawCircle;两种方案都可以在实际项目中看到。方案一代码最少方案二类型语义更清晰便于后续针对圆增加半径标注等专属逻辑。建议根据团队代码风格选择。4.4 测量半轴距离的精度说明示例代码中用Cesium.Cartesian3.distance(c1, c2)来计算两个经纬度点之间的距离。在跨度只有几公里到几十公里的场景内这个近似结果足够准确。如果做的是大范围绘制例如长轴跨越上百公里那么地球曲率影响会变大。此时最好改用椭圆体上的测地线距离function computeGeodesicDistance(startCarto, endCarto) { return Cesium.Cartographic.geodesicDistance(startCarto, endCarto); }Cartographic.geodesicDistance方法使用 WGS84 椭球模型比三维空间直线距离更接近真实地表距离。但由于Cartesian3.distance计算的是空间直线距离semiAxis在 Cesium 内部实际是平面定义后再贴到椭球上两者在常规项目中误差可以接受。实际开发中建议先用简单方案实现功能再做精度优化避免过度设计。5. 在 Vue3 中集成绘图组件现代前端项目使用 Vue3 Cesium 已经是主流组合。在项目里接入上述绘图工具时需要处理好几个问题Cesium 的引入方式、组件的生命周期销毁、回调函数中的this指向。5.1 最小示例下面是一个 Vue3 单文件组件的示例封装了 Ellipse 绘制按钮和组件销毁逻辑。!-- 文件路径src/components/EllipseDrawer.vue -- template div classdraw-toolbar button :class{ active: isDrawing } clickstartDrawEllipse 绘制椭圆/button button clickcancelDraw取消/button /div /template script setup import { ref, onBeforeUnmount } from vue; import * as Cesium from cesium; import DrawEllipse from ./DrawTools/DrawEllipse.js; const props defineProps({ viewer: { type: Object, required: true } }); const isDrawing ref(false); let drawer null; const startDrawEllipse () { cancelDraw(); drawer new DrawEllipse(props.viewer); drawer.startDraw({ material: Cesium.Color.ORANGE.withAlpha(0.5), outlineColor: Cesium.Color.WHITE, onDrawEnd: (result) { isDrawing.value false; console.log(绘制完成, result); } }); isDrawing.value true; }; const cancelDraw () { if (drawer) { drawer.cancelDraw(); drawer null; } isDrawing.value false; }; onBeforeUnmount(() { cancelDraw(); }); /script style scoped .draw-toolbar { position: absolute; top: 10px; left: 10px; z-index: 100; background: #fff; padding: 8px 12px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .draw-toolbar button { margin-right: 8px; padding: 4px 12px; cursor: pointer; } .draw-toolbar button.active { background: #1976d2; color: #fff; border-color: #1976d2; } /style5.2 Viewer 实例的生命周期坑点在 Vue3 中常见写法是在onMounted中初始化 Cesium Viewer然后在模板中通过 ref 获取容器。但父组件创建 Viewer 后传给子组件时一定要确认子组件挂载时 Viewer 已经初始化完成。template div refcesiumContainer classcesium-container/div EllipseDrawer v-ifviewer :viewerviewer / /template script setup import { ref, onMounted } from vue; import * as Cesium from cesium; import EllipseDrawer from ./components/EllipseDrawer.vue; const cesiumContainer ref(null); const viewer ref(null); onMounted(() { viewer.value new Cesium.Viewer(cesiumContainer.value, { infoBox: false, selectionIndicator: false, animation: false, timeline: false }); }); /script如果直接在onMounted里同步调用子组件并传入尚未创建完成的 Viewer会看到类似Cannot read properties of undefined的报错。因此上面用v-ifviewer保证 Viewer 创建完成后再渲染绘图子组件。5.3 销毁事件监听Cesium 的ScreenSpaceEventHandler如果不在组件销毁时释放会持续监听鼠标事件。绘图工具已经实现了cancelDraw()内部销毁 handler在组件销毁时记得调用否则会出现“切页面后仍然能画图”的诡异现象。onBeforeUnmount(() { if (viewer.value) { viewer.value.destroy(); viewer.value undefined; } });如果只是组件内临时绘制并不需要销毁整个 Viewer上面这段代码只用于整个页面离开时释放资源。6. 绘制完成后如何二次编辑与参数回显绘图工具做完之后业务上最常见的需求是把绘制的椭圆保存到后端下一次加载时回显或者用户要求拖动椭圆、调整长半轴和短半轴。6.1 数据序列化与回显保存椭圆非常简单核心抽象成一个 GeoJSON 风格的业务数据结构const ellipseData { type: Feature, geometry: { type: Point, coordinates: [center.lng, center.lat] }, properties: { shapeType: ellipse, semiMajorAxis: 1200, semiMinorAxis: 600, rotation: 0 } };后续回显时可以写一个独立的方法根据已保存的参数直接绘制静态 entityfunction showSavedEllipse(viewer, ellipseData) { const coord ellipseData.geometry.coordinates; const props ellipseData.properties; return viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(coord[0], coord[1]), ellipse: { semiMajorAxis: props.semiMajorAxis, semiMinorAxis: props.semiMinorAxis, rotation: Cesium.Math.toRadians(props.rotation || 0), material: Cesium.Color.fromCssColorString(props.color || #3388ff) .withAlpha(0.6) } }); }需要注意rotation在数据中如果保存为角度加载时一定要先转弧度。很多项目出现“椭圆方向错误”都是因为保存了角度值、加载时没有转换。6.2 点击拾取与高亮选中如果需要点击图形后显示“编辑”或“删除”按钮可以用ScreenSpaceEventHandler监听LEFT_CLICK然后通过viewer.pick拾取 entity。const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) { const pickedObject viewer.scene.pick(movement.position); if (Cesium.defined(pickedObject) pickedObject.id) { const entity pickedObject.id; if (entity.ellipse) { // 打开编辑面板 console.log(pick ellipse:, entity); } } else { // 点击空白处取消选中 } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);viewer.scene.pick返回的是一个{ primitive, id }对象。Cesium Entity 拾取时id就是当初加入的 entity。在做选中高亮时可以临时给 entity 替换材质例如改成高饱和颜色并在取消选中时恢复。关于椭圆拖动编辑最轻量的方式是提供一个“编辑模式”用几个拖拽点center、长半轴端点、短半轴端点实现图形调整。该逻辑更像一个独立的拖拽编辑器建议在绘图工具稳定后再增加避免把绘制与编辑逻辑耦合进同一个类。7. 绘制工具中的常见问题与排查思路在实际运行 Ellipse 绘图工具时会遇到几类高频问题。下面整理成表格便于查阅。问题现象常见原因解决思路鼠标点击后没有出现图形预览屏幕坐标未正确转为地球坐标或 globe.pick 返回 undefined检查中心点是否转换成功在非地球场景或地下视角时改用 camera.pickEllipsoid椭圆位置严重偏移传入了经纬度给 center而不是 Cartesian3统一用Cesium.Cartesian3.fromDegrees转换绘制出来的椭圆不是预期大小半轴单位误填为经纬度确认半轴单位是米使用Cartesian3.distance计算两点距离图形方向旋转不符合预期rotation 使用角度而非弧度统一用弧度或封装toRadians工具鼠标移动时页面掉帧明显每次移动都删除并创建 Entity且回调中做复杂计算改为一次创建 Entity参数使用 CallbackProperty缩小粒度计算范围绘制完成后点击其他按钮仍会触发绘图ScreenSpaceEventHandler 未销毁在 cancelDraw 中销毁 handler组件卸载时调用 cancelDrawVue3 中绘制工具拿到 viewer 为 undefined父组件 Viewer 尚未创建完成子组件已渲染使用 v-if 或异步组件确保 Viewer 已存在圆形绘制成椭圆半轴计算逻辑错误或结束时机不对检查绘制圆时是否第二次点击后直接传入相同半径值结束除了表格中的问题还有一类很容易忽略的异常**Camera 视角在地下时射线无法和地球相交导致拾取为空。**在三维地下模式或建筑物内部漫游时绘制图形建议先做相机视角判断或提示用户切回地表视角。再有一个容易被忽略的坐标问题高德、百度地图等常见底图的坐标系不同。Cesium 默认底图坐标是基于 WGS84 的经纬度。如果业务数据来自 GCJ-02 或 BD-09 坐标系需要先做坐标纠偏转换否则绘制图形会整体偏移几十到几百米。这是一个典型的“不是代码有问题而是坐标基准不一致”的场景。8. 最佳实践与进阶建议8.1 绘图工具的工程化规范随着图纸标绘需求变多绘图工具很容易变成“上帝类”。建议从一开始就做好三点约束多图形继承同一个基类。基类管理事件生命周期、绘制状态和通用对象子类只处理自己的图形生成。这样新增“矩形、多边形”时不需要改动已有 Ellipse 逻辑。把绘制配置独立成参数对象。包括材质颜色、描边颜色、透明度、是否开启贴地、高度值等。不要把这些配置写死在 entity 创建逻辑里否则 UI 换色时要靠查找替换。每个图形都输出统一结构的数据。格式建议是{ type: ellipse, center: {lng, lat}, semiMajorAxis: xx, semiMinorAxis: xx, rotation: xx, entity: ... }。这样方便后续序列化、保存、加载、统计面积等。8.2 绘图性能优化建议绘图过程中预览图形的刷新频率和交互流畅度是用户最直接的体验指标。推荐策略鼠标移动事件不要直接操作 DOM 或添加大量临时 Entity。一次绘制过程中预览 Entity 只创建一次后续修改其 geometry 参数。如果图形数量很大例如同时显示几千个标绘结果建议把静态图形从 Entity 迁移到 Primitive或者使用CustomDataSource管理 Entity并在数据量极大时使用entity.show false做视锥裁剪。计算两点距离时可以先做粗筛对超出最大半径的鼠标位置直接 clip减少不必要计算。8.3 扩展更多图形能力的思路了解了 Ellipse 的绘制工具实现后扩展其他图形无非是替换“图形生成”这一层逻辑矩形需要记录两个对角点然后通过RectangleGraphics或RectangleGeometry创建。多边形点击添加多个顶点移动事件中动态连接最后一个顶点与鼠标位置最终闭合。折线用PolylineGraphics同时维护顶点数组。箭头本质上是由三角形和梯形组合的多边形。在鼠标绘制的过程中生成箭头轮廓坐标再按多边形渲染。可视域分析以某点为起点计算扇形扫描范围生成多个圆弧顶点构成多边形。绘制交互工具本身一致只是生成的 geometry 不同。这也是为什么文章前面强调“抽象基类”的价值当工具库积累到第 4 个、第 5 个图形时你会发现大部分代码都在复用只需要新增图形算法和参数。8.4 从 Cesium 官方 API 到动态材质扩展如果不想用Entity默认的纯色材质可以通过CustomMaterial或者纹理贴图实现类似“雷达波纹”“动态箭头”等效果。以 Ellipse 填充雷达圈为例ellipse: { semiMajorAxis: 500, semiMinorAxis: 500, material: new Cesium.ImageMaterialProperty({ image: /images/radar.png, transparent: true, color: Cesium.Color.CYAN }) }上述代码用一张雷达图片作为纹理填充椭圆。如果希望雷达波纹随时间扩散则需要引入自定义 Shader 材质或基于时间回调更新贴图的 uv 坐标。Cesium 自带的Material支持ImageMaterialProperty、ColorMaterialProperty、PolylineArrowMaterialProperty、PolylineDashMaterialProperty等。业务中比较常用的“椭圆扫描”效果可以使用Cesium.Material的自定义 Fabric 材质实现const customMaterial new Cesium.Material({ fabric: { type: EllipseRadar, uniforms: { color: Cesium.Color.CYAN, speed: 1.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; // 在这里根据 st 坐标实现扫描渐变 material.diffuse color.rgb; material.alpha color.a * (1.0 - distance(st, vec2(0.5, 0.5))); return material; } } });这只是思路片段。具体编写 Shader 时需要理解 Cesium 材质坐标系和内置函数。放在这里面是为了提醒读者绘图工具画出来的不只是“静态纯色椭圆”还可以承载动态可视化效果。实际项目做“雷达扫描”“信号覆盖范围”“缓冲区分析”时Ellipse 往往是最适合承载这些效果的图形容器。9. 写在最后的建议Ellipse 的绘制工具是整个 Cesium 标绘能力的一个切片。把它吃透以后你会发现后面画矩形、画多边形、画扇形甚至做可视域分析本质上都在解决同一个问题鼠标如何转成地理坐标、状态机如何切换、预览图如何渲染最终如何生成一个可保存、可编辑的业务数据对象。如果当前项目只需要一个“画圈”的功能建议先按本文的DrawEllipse最小实现去跑通流程如果项目规划了多图形标绘、编辑、图层管理那么一定要抽出DrawBase基类并在数据格式上尽早统一。绘图工具的后期维护成本往往在数据结构设计和交互状态管理上而不是在某一个图形算法的实现上。Cesium 版本更迭较快不同版本的 API 细节会有差异。本文以主流稳定版 Cesium 为基础编写示例代码中的 API 基本保持稳定但你在接入项目时仍应结合自己的实际版本查阅官方文档。渲染效果、事件机制和几何参数建议在本地做一次最小 demo 验证后再集成进入业务系统。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑