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.Entity+Cesium.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.GeometryInstance+Cesium.EllipseGeometry+Cesium.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构造参数
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
center | Cartesian3 | 是 | 椭圆中心点坐标,必须为世界坐标 |
semiMajorAxis | Number | 是 | 椭圆长半轴长度,单位:米 |
semiMinorAxis | Number | 是 | 椭圆短半轴长度,单位:米 |
rotation | Number | 否 | 长轴与正北方向的夹角,弧度制,默认 0 |
height | Number | 否 | 椭圆所在高度,默认 0 |
extrudedHeight | Number | 否 | 拉伸高度,配合高度值可实现柱体效果 |
granularity | Number | 否 | 三角剖分粒度,弧度值越小越精细 |
vertexFormat | VertexFormat | 否 | 顶点格式定义,默认包含位置和贴图坐标 |
stRotation | Number | 否 | 贴图旋转角度 |
在这份参数表中,最容易出错的三个点是:
- 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 class="draw-toolbar"> <button :class="{ active: isDrawing }" @click="startDrawEllipse" >绘制椭圆</button> <button @click="cancelDraw">取消</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; } </style>5.2 Viewer 实例的生命周期坑点
在 Vue3 中,常见写法是在onMounted中初始化 Cesium Viewer,然后在模板中通过 ref 获取容器。但父组件创建 Viewer 后传给子组件时,一定要确认子组件挂载时 Viewer 已经初始化完成。
<template> <div ref="cesiumContainer" class="cesium-container"></div> <EllipseDrawer v-if="viewer" :viewer="viewer" /> </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-if="viewer"保证 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 } };后续回显时,可以写一个独立的方法,根据已保存的参数直接绘制静态 entity:
function 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;组件卸载时调用 cancelDraw |
| Vue3 中绘制工具拿到 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 验证后,再集成进入业务系统。