news 2026/9/4 4:42:24

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cesium二次开发:Ellipse椭圆绘图工具的完整实现与封装指南

1. Ellipse 在 Cesium 绘图工具中的定位

在做 Cesium 二次开发时,绘图工具几乎是每个 GIS 项目都绕不开的模块。点、线、面、矩形、圆、椭圆,这些基础图形看似简单,真正落地时却会牵扯出一堆问题:坐标怎么采集、图形怎么预览、参数怎么回调、样式怎么管理、编辑态怎么处理……而 Ellipse(椭圆/圆)作为基础图形中比较有代表性的一个,特别适合拿来做“由浅入深”的完整拆解。

很多初学者会问:Cesium 不是自带EllipseGeometryEllipseGraphics吗,为什么还要自己封装绘图工具?这个问题问得很好。自带 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构造参数

参数名类型必填说明
centerCartesian3椭圆中心点坐标,必须为世界坐标
semiMajorAxisNumber椭圆长半轴长度,单位:米
semiMinorAxisNumber椭圆短半轴长度,单位:米
rotationNumber长轴与正北方向的夹角,弧度制,默认 0
heightNumber椭圆所在高度,默认 0
extrudedHeightNumber拉伸高度,配合高度值可实现柱体效果
granularityNumber三角剖分粒度,弧度值越小越精细
vertexFormatVertexFormat顶点格式定义,默认包含位置和贴图坐标
stRotationNumber贴图旋转角度

在这份参数表中,最容易出错的三个点是:

  1. rotation 是弧度制,不是角度制。很多初学者直接把 45 填进去,结果椭圆旋转方向完全不对。如果需要按角度设置,可以这样转换:Cesium.Math.toRadians(45)
  2. center 要求是世界坐标。如果拿经纬度直接传,会得到“坐标位置完全不对”的诡异结果。正确做法是通过Cesium.Cartesian3.fromDegrees(lng, lat)转换。
  3. 半轴单位是米。在经纬度小范围场景下没有直观问题,但如果做跨经纬度的全球场景,涉及到大地测量精度时需要使用更精确的椭球计算方法。

这里也给出一个扇形的实现示例,方便后续设计绘图工具时做“圆/椭圆/扇形”的统一扩展。扇形实际上就是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 为例,流程如下:

  1. 鼠标单击地图,确定椭圆的中心点;
  2. 移动鼠标时,实时计算半轴参数并生成预览图形;
  3. 再次单击,确定椭圆的边界(长半轴或半径);
  4. 若要生成圆,可以 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.jsDrawEllipse.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,但将其semiMajorAxissemiMinorAxis设置为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 绘图工具的工程化规范

随着图纸标绘需求变多,绘图工具很容易变成“上帝类”。建议从一开始就做好三点约束:

  1. 多图形继承同一个基类。基类管理事件生命周期、绘制状态和通用对象,子类只处理自己的图形生成。这样新增“矩形、多边形”时,不需要改动已有 Ellipse 逻辑。
  2. 把绘制配置独立成参数对象。包括材质颜色、描边颜色、透明度、是否开启贴地、高度值等。不要把这些配置写死在 entity 创建逻辑里,否则 UI 换色时要靠查找替换。
  3. 每个图形都输出统一结构的数据。格式建议是{ 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 的绘制工具实现后,扩展其他图形无非是替换“图形生成”这一层逻辑:

  • 矩形:需要记录两个对角点,然后通过RectangleGraphicsRectangleGeometry创建。
  • 多边形:点击添加多个顶点,移动事件中动态连接最后一个顶点与鼠标位置,最终闭合。
  • 折线:用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支持ImageMaterialPropertyColorMaterialPropertyPolylineArrowMaterialPropertyPolylineDashMaterialProperty等。业务中比较常用的“椭圆扫描”效果,可以使用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 验证后,再集成进入业务系统。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 4:42:16

基于OpenCV的传统车牌识别系统:从图像处理到完整工程实现

简介&#xff1a;本资源是一个基于OpenCV与Python实现的完整车牌识别项目&#xff0c;面向计算机视觉初学者、高校课程实践者及图像处理入门开发者&#xff0c;解决车辆牌照自动检测与字符识别这一典型工业应用问题。压缩包共26个文件&#xff0c;包含5个核心Python脚本&#x…

作者头像 李华
网站建设 2026/9/4 4:40:12

Python办公自动化实战:Word文档批量生成、修改与格式转换

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/4 4:38:43

aigcbiye|AI5.0学术智能平台,一站式搞定论文全流程写作难题

官网 www.aigcbiye.com &#xff0c;微信公众号 搜一搜 AIGCbiye 学术写作是学子与科研人员的必经之路&#xff0c;从开题构思、文献梳理到正文撰写、数据论证&#xff0c;再到查重降重、答辩筹备&#xff0c;完整的论文创作流程繁琐复杂、专业性极强。多数创作者常陷入思路…

作者头像 李华
网站建设 2026/9/4 4:36:57

星盘接口开发文档:计算时区夏令时接口指南

星盘接口开发文档&#xff1a;计算时区夏令时接口指南 1. 引言 本文档详细介绍了占星系统的计算时区夏令时接口的使用方法&#xff0c;包括请求参数详解、响应数据结构、错误处理机制以及最佳实践建议。 2. 接口基础信息 接口名称: 计算时区夏令时 请求方式: POSTContent-Type:…

作者头像 李华
网站建设 2026/9/4 4:36:11

基于SpringBoot+Vue的智慧农业物联网平台全栈开发实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/4 4:35:59

沉浸式洗摩托车全流程:从精洗工位到预约工单管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华