
有一次需求方给我提了一个需求在 Cesium 场景里加一个绘图按钮画出来的效果要像规划软件里的椭圆。我第一反应是“这有什么难的Entity 里不是有ellipse吗”。真正动手之后才发现Cesium 绘图工具里的 Ellipse难点从来不在“显示一个椭圆”而在于用户从按下鼠标到屏幕上出现一个可落地椭圆之间Cesium 到底需要哪些参数参数又是怎么从鼠标手势里换算出来的。这个判断是我今天想重点讲的Ellipse 绘图工具的核心不是往viewer.entities.add()里塞一个ellipse而是把用户的鼠标操作正确翻译成一组椭圆几何参数同时把预览、落点、取消、编辑、持久化这几个交互状态管理起来。如果你只是想在 Entity 里放一个静态椭圆那很简单。但如果你要做一个真正能用的“绘图工具”哪怕只是里面一个 Ellipse 按钮背后也需要一整套状态机。下面我会从参数定义、最小绘制流程、状态管理、常见坑点、长期演进五个方面展开。1. Ellipse 出现在绘图工具里最先暴露的不是画法是几何定义1.1 一个坐标点、两个半径和一个方向角在开始写代码之前先回到一个基本问题Cesium 里是怎么定义“椭圆”的Cesium 的ellipse不是 SVG 那种路径也不是图片素材。它是一组几何参数在三维地球上的计算结果。常见的 Entity 写法是这样viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.2, 39.9), ellipse: { semiMajorAxis: 800.0, semiMinorAxis: 500.0, rotation: Cesium.Math.toRadians(30), material: Cesium.Color.RED.withAlpha(0.3) } });这里最关键的是四个量参数含义在绘图工具里通常怎么来position椭圆中心的椭球坐标用户第一次点击屏幕后拾取得到semiMajorAxis长半轴长度单位是米用户拖拽距离或输入值semiMinorAxis短半轴长度单位是米用户第二个拖拽距离或输入值rotation长轴相对正北方向的旋转角单位是弧度用户鼠标相对中心的方向角很多人在这第一步就会产生误解以为鼠标从 A 点拖到 B 点B 点的经纬度减去 A 点的经纬度就能当半径。实际上 Cesium 的椭圆半径单位是米不是度也不是像素。你必须在中心点建立一套本地坐标然后计算鼠标落在中心的东、北方向上有多少米。所以绘图工具的难点不是“会不会调 API”而是“能不能把用户手势变成准确的米制参数”。1.2 Entity、Geometry 与交互工具的差异Cesium 里显示一个椭圆至少有两条路高层 API用Entity上的ellipse适合数量少、需要动态修改、需要点击拾取的场景。底层 API用Cesium.EllipseGeometry或Cesium.EllipseOutlineGeometry合成为Primitive适合数量多、静态展示、需要控制的场景。绘图工具里最常见的组合是绘制过程中用EntityCallbackProperty做动态预览因为鼠标每动一下半径和旋转角都要变。绘制完成后根据需要把数据转成正式 Entity或者生成静态 Geometry再放到业务图层里。只写一个静态实体不叫绘图工具因为静态实体没法响应用户的拖拽动作。真正要处理的是“绘制中”和“绘制完成”两个阶段的状态切换。而这也是很多人第一次写 Cesium 绘图类功能时代码越写越乱的原因把预览实体和最终实体的生命周期混在了一起。2. 从鼠标拖拽到可落地实体最小绘制流程2.1 先把屏幕拾取转成本地方向坐标我给你写一个教学版的最小实现。它不算产品级但足够说明核心逻辑鼠标按下确定中心拖拽过程中动态更新椭圆参数抬起后生成正式实体。先看核心函数function startEllipseDraw(viewer) { // 准备会话状态避免每次回调里自己找状态 const state { center: undefined, // 椭圆中心 Cartesian3 majorAxis: 0, // 长半轴单位米 minorAxis: 0, // 短半轴单位米 rotation: 0, // 长轴方向单位弧度 previewEntity: undefined, // 预览用实体 active: false }; // 一个绘图任务只保留一个 handler const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); // 1. 鼠标按下作为椭圆中心 handler.setInputAction((movement) { const cartesian viewer.scene.pickEllipsoid( movement.position, viewer.scene.globe.ellipsoid ); if (!Cesium.defined(cartesian)) return; state.center cartesian; state.majorAxis 0; state.minorAxis 0; state.rotation 0; state.active true; // 先加一个预览实体后续通过回调属性更新 state.previewEntity createPreviewEntity(viewer, state); }, Cesium.ScreenSpaceEventType.LEFT_DOWN); // 2. 鼠标移动计算中心到当前点的方向和距离 handler.setInputAction((movement) { if (!state.active || !state.center) return; const current viewer.scene.pickEllipsoid( movement.endPosition, viewer.scene.globe.ellipsoid ); if (!Cesium.defined(current)) return; // 以中心点为原点建立东-北-上本地坐标 const frame Cesium.Transforms.eastNorthUpToFixedFrame(state.center); const inverseFrame Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()); const local Cesium.Matrix4.multiplyByPoint(inverseFrame, current, new Cesium.Cartesian3()); // local.x 是东方向local.y 是北方向 const east local.x; const north local.y; const len Math.hypot(east, north); if (len 0.01) return; // 这里为了演示先让长轴方向永远指向鼠标方向 // 短轴按 0.62 比例生成真实工具往往用三步手势自由定短轴。 state.majorAxis len; state.minorAxis len * 0.62; state.rotation Math.atan2(east, north); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // 3. 鼠标抬起生成正式实体并清理预览 handler.setInputAction(() { if (!state.active) return; // 太小的椭圆可能只是误点击直接放弃 if (state.majorAxis 0.5) { cancelDraw(viewer, state, handler); return; } // 生成正式业务实体 viewer.entities.add({ name: ellipse-drawn, position: state.center, ellipse: { semiMajorAxis: state.majorAxis, semiMinorAxis: state.minorAxis, rotation: state.rotation, material: Cesium.Color.LIME.withAlpha(0.35), outline: true, outlineColor: Cesium.Color.DARKGREEN } }); cancelDraw(viewer, state, handler); }, Cesium.ScreenSpaceEventType.LEFT_UP); // 4. 右键取消当前绘制 handler.setInputAction(() { cancelDraw(viewer, state, handler); }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); return handler; } function createPreviewEntity(viewer, state) { return viewer.entities.add({ id: ellipse-drawing-preview, position: state.center, ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() state.majorAxis, false), semiMinorAxis: new Cesium.CallbackProperty(() state.minorAxis, false), rotation: new Cesium.CallbackProperty(() state.rotation, false), material: Cesium.Color.CYAN.withAlpha(0.25), outline: true, outlineColor: Cesium.Color.CYAN } }); } function cancelDraw(viewer, state, handler) { if (state.previewEntity) { viewer.entities.remove(state.previewEntity); } state.center undefined; state.majorAxis 0; state.minorAxis 0; state.rotation 0; state.active false; if (handler !handler.isDestroyed()) { handler.destroy(); } }这段代码里最重要的不是 API 本身而是“屏幕点击变成 Cartesian3Cartesian3 变成东-北本地坐标再变成半轴和旋转角”这条链路。链路断了后面不管怎么写都容易出问题。2.2 预览实体与最终实体的状态切换你会发现上面的版本有一个特点绘制过程中全部用CallbackProperty所以鼠标每移动一次椭圆的半径和方向都会实时更新。这很适合做体验反馈。但最终实体不能直接用预览实体的CallbackProperty原因有两个预览实体会在结束时被移除如果最终实体引用了同一个回调状态清理时可能导致状态突然无效。业务数据应该是稳定值而不是“依赖外部状态、每帧求值”的动态属性。保存数据、导出 JSON、传给后端时动态回调会非常麻烦。所以我的建议是预览实体和最终实体严格分开。预览阶段用动态属性落点时复制成静态数值然后移除预览。2.3 绘制结果落地时的清理动作很多人在实际项目里写绘图工具会漏掉清理动作结果出现三种问题绘制完了预览实体还在。上一次的 handler 没销毁下一次再进入绘图模式左键事件叠加画面同时出现多个中心点。右键取消时没有移除实体临时图形永久留在场景里。所以无论你的绘图工具是“左键拖拽”还是“三点式点击”都要养成一个习惯把绘图过程封装成一次任务任务必须有start、commit、cancel三个出口。代码里常见的cancelDraw就是负责把 handler、临时实体、临时状态全部清干净。3. 把这个流程做成真正可用的绘图功能要处理什么3.1 明确绘图状态机而不是散落的布尔变量最小示例能跑通但它只支持“按下中心、松开完成”这一种手势。真实需求往往更复杂比如用户希望先定中心再拖长轴方向再单独拖短轴长度。用户希望按住某个键变成正圆。用户希望长轴画到一半可以右键撤销重来。用户希望绘制的椭圆落在指定地形高度或贴地。这些需求如果还用一堆boolean变量去管代码很快会乱。应该改成状态机。比如IDLE → 点击“画椭圆” → WAIT_CENTER WAIT_CENTER → 左键点击中心 → WAIT_MAJOR_AXIS WAIT_MAJOR_AXIS → 鼠标移动更新长轴方向/长度左键确认 → WAIT_MINOR_AXIS WAIT_MINOR_AXIS → 鼠标移动更新短轴长度左键确认 → DONE DONE → 落正式实体回 IDLE这个流程比“按一下、拖一下、松开”更符合人对椭圆的理解椭圆是有两个独立轴长度的用户在屏幕上总能画出一个正确的长轴也应该能控制短轴。实现本质还是把鼠标位置换算成东、北方向长度只不过每个阶段只更新对应的轴。采用状态机之后右键取消、键盘取消、切换绘图工具都会变得清晰。你只需要在状态为WAIT_CENTER、WAIT_MAJOR_AXIS等阶段统一响应取消事件然后回到IDLE不需要在十几个事件回调里各自处理。3.2 编辑椭圆把几何参数重新映射回屏幕绘图工具做到中后期一定会遇到编辑需求用户发现椭圆位置不对、长轴短轴要改、旋转角要重新调整。Cesium 没有现成的“拖拽编辑 Ellipse”控件你要自己做。常见做法是在椭圆上放置几个控制点中心点控制移动。长轴端点控制长轴长度和旋转方向。短轴端点控制短轴长度和旋转方向。编辑逻辑本质上就是绘制逻辑的反向过程。绘制时从鼠标手势计算出position、semiMajorAxis、semiMinorAxis、rotation编辑时则要把这几个几何参数再投射回屏幕上的控制点位置然后用控制点的拖拽变化反向更新参数。很多开源绘图示例把精力放在“怎么画出一个形”上忽略了编辑。但一旦放到电力、管线、规划、军事标绘项目里编辑往往是刚需。如果你准备做一个长期使用的绘图工具建议把编辑功能纳入架构设计而不是等用户提需求再临时加。3.3 性能与数据问题临时实体、回调属性、成百上千个图形用 Entity 画椭圆天然比较好写但 Entity 数量上去之后会面临性能问题。这里的经验判断是绘图预览阶段只有一两个临时实体用CallbackProperty完全没问题。如果最终会批量生成几百个椭圆建议把 Entity 转成 Geometry Primitive或者对非动态图层做合并绘制。如果椭圆只是作为区域范围不参与业务查询可以考虑直接画边界线例如用EllipseOutlineGeometry减少填充面的渲染压力。另外Entity 里的ellipse是否要贴地、是否要设置高度也会影响渲染路径。clampToGround: true时Cesium 会把它当作贴地图形处理不再使用普通的高度属性这类图形通常更贴合 GIS 业务习惯但不是所有浏览器和环境都完全一致需要做兼容验证。4. 参数相关的高发踩坑与排查顺序4.1 先判断变量单位米、度、像素我见过最多的问题是“为什么画出来的椭圆跑到了奇怪的地方”。排查第一步永远先看单位。semiMajorAxis和semiMinorAxis的单位是米。position的单位是 Cartesian3通常来自Cesium.Cartesian3.fromDegrees()或拾取函数。rotation的单位是弧度不是角度。屏幕坐标movement.position是像素不能直接作为长度。如果你在代码里发现用了经纬度差值当作半径或者把Cesium.Math.toRadians(30)的值当成米来设置那图形大概率会错。一个很直接的验证方法是先打印出当前状态里的east、north、majorAxis、minorAxis看看你在某个位置的鼠标移动是否产生了一个合理的米制数值。如果数值忽大忽小问题通常出在“拾取的不是同一个平面”。4.2 再验证 rotation 的参考系Cesium 中ellipse的rotation有明确参考系它表示半长轴从正北方向顺时针旋转的角度单位是弧度。很多人在 Web 地图里习惯了“角度从正东逆时针算”的数学坐标系结果写出来方向总是差 90 度或反转。如果你用我在前面示例里的 ENU 本地坐标local.x是东方向。local.y是北方向。那么从正北顺时针到鼠标方向的旋转角可以这样算const rotation Math.atan2(east, north);注意不是Math.atan2(north, east)。你可以用一个简单测试让鼠标沿正东方向拖期望椭圆长轴朝东 90 度。如果方向朝南或朝西就说明角度公式写反了。4.3 最后检查拾取命中的层椭球、地形、模型viewer.scene.pickEllipsoid只能拾取“椭球面”。如果你在三维场景里开启了地形、加载了 3D Tiles或者视线被模型遮挡这个函数返回的点可能不是你预期画在表面上的点。更好的做法要分场景判断如果需要精确拾取地形或模型表面并且启用了深度检测可以优先使用viewer.scene.pickPosition()。如果只是画地表示意范围不想被地形搞乱可能更稳定的方式是直接限定椭圆中心高度或者在中心点做一个地面点采样。如果开启了地形又希望椭圆完全贴地形可以考虑让正式实体的clampToGround: true但绘制预览阶段仍要处理中心点拾取不一致的问题。排查时不要盯着画面看太久先问一句你点中的到底是地表、3D Tiles 表面还是空中的某个点4.4 一个可直接复用的排查表格异常现象优先排查方向常见原因点击没有出现预览拾取函数是否有返回值没有命中椭球、地形、模型表面椭圆总是画在错误位置position 参数来源用了屏幕像素坐标或没有做坐标转换半径值异常图形忽大忽小单位与拾取面度/米/像素混用或拾取的层不连续椭圆方向不对rotation 参考系角度公式用反或把角度当弧度传绘制完成后预览又出现清理逻辑预览实体没有移除handler 没有销毁第二次绘图行为异常handler 生命周期上一次 handler 未销毁事件回调叠加这张表不是万能排查手册但覆盖了绘图工具开发中最常见的一批问题。遇到新问题时也建议按“输入 → 环境 → 参数 → 工具边界”的顺序去查不要一上来就改材质或调样式。5. Ellipse 画完之后绘图工具才真正开始5.1 数据导出与图层管理如果你只是要“画完能看到”项目很快会结束。但大多数 Cesium 绘图工具是要对接业务系统的比如绘制区域、做空间量测、存档、同步给后端。一个很现实的问题是Cesium 的 Entity 结构并不是标准 GIS 数据。semiMajorAxis、semiMinorAxis、rotation这些参数无法直接放进纯 GeoJSON 的 Feature 属性里被广泛支持。常见处理方式有两类自定义数据协议把ellipse的字段存成业务 JSON后端能理解前端再反序列化为 Entity。转成多边形 Polygon用Cesium.EllipseGeometry.createGeometry()采样出边界顶点再导出为 Polygon 坐标。这样做可以兼容 GeoJSON但会丢失“椭圆参数”这种语义编辑时需要反向识别。我建议在绘图工具设计初期就明确“要保存成什么格式”而不是等把椭圆画出来之后再纠结。否则后续每次加编辑、加图层管理都要重新处理数据模型。5.2 从图形绘制到区域分析Ellipse 在 Cesium 绘图工具里不只是一个图形按钮。放大一点看它是很多业务场景的基础图元。比如在标绘工具里你画一个 Ellipse可能代表一个信号覆盖范围在规划场景里它可能代表一个设施影响区在雷达或视域分析场景里椭圆加一个扫动效果就变成了动态雷达范围。搜索材料里那些“雷达光波”“动态水面”“天际线分析”“可视域分析”等需求本质上都是把基础图元叠加到了业务分析和可视化里。但这个演进是有先后顺序的。绘图工具必须先稳定支持基础图形的“绘制、编辑、保存、加载”才能谈得上动态材质、动态光照、分析与联动。如果基础图形的坐标系和半径计算都不稳定后面加再多动态效果都会在同一个地方翻车。5.3 我的建议先跑完一条最小闭环如果你现在正准备做一个 Cesium 绘图工具里的 Ellipse我的建议不是先去研究各种炫酷效果而是先跑完这样一条最小闭环在工具栏