简介:基于Cesium的三维量测插件源码包,面向WebGIS前端开发与三维可视化应用工程师,可在Cesium场景中快速实现距离、面积、高度等基础量测功能。压缩包共731个文件,约7.08MB,以126个JavaScript核心代码、145个PNG与76个GIF演示图、35个CSS样式、29个JSON配置及多类型静态资源组成,同时附带SVG图标、字体文件、HTML示例页面、TypeScript声明与文档,便于直接参考调用。插件使用方式简洁,引入Cesium.js后再加载cesium-measure.js即可接入现有项目,降低三维测量功能开发门槛。资源还包含Cesium的gltf模型与wasm等支撑文件,源码与说明文档分层存放,适合有一定Cesium基础、希望扩展三维编辑能力的开发者研究实现思路。目前已有852人学习下载,是快速集成与二次开发的高性价比参考。
1. 三维量测插件的核心定位:让 Cesium 场景从"看得见"到"量得出"
在智慧城市、水利水电、应急指挥这类三维 GIS 项目里,模型是建出来了,场景也能自由漫游,但业务侧最常问的一句话是:"这个施工区到底有多大""塔吊到高压线的水平距离几米""基坑开挖深度和设计值差多少"。Cesium 引擎擅长渲染三维地球,却不直接提供"点两下出距离"的现成能力。基于 Cesium 的基础三维量测插件,就是把鼠标点击、世界坐标拾取、空间换算、距离面积高差计算、图形与标注绘制这五件事封装成一套可复用的交互组件,外部只需调用 start、clear、destroy 就能获得完整的量测语义。它适合三维平台前端开发、GIS 应用研发工程师拿去直接集成或裁剪扩写,也适合团队在启动新项目时少重复写一遍 ScreenSpaceEventHandler 绑定的样板代码。下文从坐标系换算讲起,逐步展开一个可运行、可扩展的插件实现。
2. 量测能算准的前提:Cesium 坐标系换算与投影边界
2.1 三种坐标形态与一条转换链路
Cesium 的量测插件表面上是在画线,实质上是在维护一条坐标换算管道:屏幕坐标{ x, y }进入事件回调后,先转成世界坐标Cartesian3,再转成地理坐标Cartographic,最后才能被人类读懂并参与距离计算。Cartesian3是 ECEF 地心固定坐标系下的空间点,单位为米;Cartographic包含经度、纬度、高度,但经度和纬度都是弧度制;屏幕坐标则是像素值。三者之间转换并不复杂,可插件里最容易出错的恰恰是弧度与角度的混用。
// 屏幕坐标 -> 世界坐标:优先取真实几何表面,兜底取椭球面 function pickPosition(viewer, windowPosition) { const scene = viewer.scene; if (scene.pickPositionSupported) { const cartesian = scene.pickPosition(windowPosition); if (cartesian) return cartesian; } // 深度拾取不可用时,用射线与椭球体求交 const ray = viewer.camera.getPickRay(windowPosition); return scene.globe.pick(ray, scene); } // 世界坐标 -> 经纬度对象:Cartesian3 与 Cartographic 互转 function cartesianToLonLat(cartesian, ellipsoid) { const carto = ellipsoid.cartesianToCartographic(cartesian); return { lng: Cesium.Math.toDegrees(carto.longitude), lat: Cesium.Math.toDegrees(carto.latitude), height: carto.height, carto, }; }第一段pickPosition先尝试scene.pickPosition,它是基于深度缓冲区反算真实场景表面坐标的,地形、3D Tiles 和模型都能取到贴合表面的交点;当场景为空球或深度拾取被禁用时,再退回到globe.pick,用射线与 WGS84 椭球求交。第二段把Cartesian3转成普通对象的经纬度表示,height是从椭球面起算的椭球高,不是海拔高。Cesium.Math.toDegrees是弧度转角度,显示经纬度时不可省略。
2.2 屏幕拾取的两条路径与参数差异
在 Cesium 里做屏幕拾取,最常见的有三条路径,它们的返回结果和适用条件差别很大:
| 拾取方式 | 返回几何 | 前提条件 | 典型场景 |
|---|---|---|---|
scene.pickPosition | 深度缓冲区中真实可见的几何表面点 | pickPositionSupported为真 | 地形与模型量测,默认推荐 |
camera.pickEllipsoid | 射线与参考椭球的数学交点 | 无需特殊配置 | 空球、白模底图时兜底 |
scene.globe.pick | 射线与地形网格的交点 | 需要先用getPickRay生成射线 | 无模型但有地形时 |
其中camera.pickEllipsoid完全不感知地表起伏,遇到山地会明显量短;scene.pickPosition受depthTestAgainstTerrain影响较大,开启该选项后地形遮挡关系才准确,否则点可能穿透山体取到背面的坐标。量测插件对外暴露一个pickMode参数,默认设为'auto',内部按"深度拾取 → globe 拾取 → 椭球拾取"的顺序逐个降级。这套降级逻辑在大多数业务里都能得到合理结果:有高精度模型时贴合模型,模型缺省时至少有椭球交点兜底,不至于让点击事件返回空坐标。
2.3 投影、椭球与量测精度之间的关系
Cesium 场景默认的参考椭球是 WGS84,国内项目多以 CGCS2000 作为地理基准,两者椭球参数差异在毫米到厘米级,三维量测层面基本不产生可见偏差。真正影响精度的是距离公式的选择。Cesium.Cartesian3.distance(a, b)算的是穿过地壳的三维欧氏直线距离;Cesium.EllipsoidGeodesic计算的是沿椭球面从起点到终点的测地线最短距离,适合表达地表距离。
举个例子:北京到上海的Cartesian3.distance约 1067 公里,而沿椭球面的测地距离约 1084 公里,相差约 1.5%。这个差异在逐点量测时不能忽略,所以插件里的量距功能应暴露distanceMode: 'euclidean' | 'geodesic',由上层调用方按业务口径选择。海拔差大的山区两类公式差异更大,因为高度直接进入Cartesian3的 z 分量。
另一个高频问题是加载 EPSG:3857 Web 墨卡托数据源,如果不做坐标过滤就把 3857 的东西向平面坐标当作经纬度喂给Cartographic,点位就会"飘"到错误区域。量测插件启动时若项目里接了 3857 切片,务必在数据入口用Cesium.Math.toRadians对平面坐标做一次转换过滤,再进入拾取链路。
3. 可复用的 Cesium 基础三维量测插件:类骨架与生命周期
3.1 插件类的职责边界与字段设计
量测插件应该遵循一个原则:只管"输入—状态—计算—输出",不把相机控制、图层加载等功能揽进自己怀里。一个最小的插件类只需维护四类状态:鼠标事件处理器、量测顶点数组、量测模式枚举、临时预览点。下面是一个完整的类骨架,也是整个插件的核心容器。
export default class MeasureTool { constructor(viewer, { mode = 'distance' } = {}) { this._viewer = viewer; this._scene = viewer.scene; this._mode = mode; // 'distance' | 'area' | 'height' this._positions = []; this._handler = null; this._active = false; // 用独立数据源管理量测实体,避免污染业务实体 this._dataSource = new Cesium.CustomDataSource('measure-tool'); viewer.dataSources.add(this._dataSource); } get isActive() { return this._active; } start() { if (this._active) return; this._active = true; this._bindHandler(); } stop() { this._active = false; this._unbindHandler(); } clear() { this._positions.length = 0; this._dataSource.entities.removeAll(); } destroy() { this.stop(); this.clear(); this._viewer.dataSources.remove(this._dataSource, true); this._handler = null; } }viewer.dataSources.add(this._dataSource)是最值得说明的一行:量测产生的所有折线、多边形、点、标注都归属于这个独立数据源,clear()时removeAll()只动派生数据,不影响业务图层。如果直接把量测实体塞进viewer.entities,不仅清理要遍历大量实体,业务侧的地物隐藏、样式联动还会被误伤。字段以_开头是社区惯例,表示内部状态不直接改;isActive提供只读访问,避免外部直接篡改_active导致事件与状态不同步。
3.2 事件绑定:单双击与预览更新的节奏
量测交互最常用的事件是三件套:左键单击加点、鼠标移动预览、左键双击结束。下面是一次完整的绑定实现。
_bindHandler() { if (this._handler) return; this._handler = new Cesium.ScreenSpaceEventHandler(this._scene.canvas); this._handler.setInputAction((movement) => { const cartesian = pickPosition(this._viewer, movement.position); if (!cartesian) return; this._positions.push(cartesian); this._renderMeasure(); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); this._handler.setInputAction((movement) => { if (this._positions.length === 0) return; this._previewPoint = pickPosition(this._viewer, movement.endPosition); this._renderMeasure(); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); this._handler.setInputAction(() => { this._finishMeasure(); }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); // 右键清除本次量测,遇到业务右键菜单冲突时可直接移除 this._handler.setInputAction(() => { this.clear(); }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); }事件绑定里有一个容易踩的坑:在 Cesium 中LEFT_DOUBLE_CLICK触发前,浏览器事件流会先派发两次LEFT_CLICK,导致顶点数组末尾混入两个重复点。处理方式是双击回调里先判断尾两个点的屏幕像素距离,小于 2 像素就把重复点剔除,再进入结果计算。右键清除在原型阶段非常有用,接入成熟项目时若与业务右键菜单冲突,直接把RIGHT_CLICK分支换成自定义cancel事件即可。
3.3 生命周期与工具栏集成
量测插件的交互状态可以用一个小状态机管理:idle → measuring → finished → idle。start()进入measuring,_finishMeasure()进入finished,clear()回到idle。工具栏按钮的高亮与置灰切换,完全依赖isActive这个只读入口,UI 层不需要感知_positions的内部细节。
方法行为对比表:
| 方法 | 激活状态 | 顶点数据 | 事件绑定 |
|---|---|---|---|
start() | 进入 measuring | 保留已有顶点 | 绑定 |
stop() | 回到 idle | 保留已有顶点 | 解绑 |
clear() | 不变 | 清空顶点与实体 | 不变 |
destroy() | 释放实例 | 清空全部 | 销毁 handler |
如果量测插件和鹰眼视图放在同一个工具栏里,需要留意事件顺序:鹰眼通常监听viewer.clock.onTick做小地图同步,量测插件监听的是画布鼠标事件,两者互不干扰。但若是台风路径量测这类需要把结果同步到鹰眼小地图的场景,就应在_renderMeasure之外额外抛出一个onMeasureChange(positions)回调,由鹰眼组件自行消费。常见做法是构造函数的options里传入onMeasureChange函数,量测过程中每推入一个顶点就调用一次。
4. Cesium 量测插件中距离、面积、高度的实现细节与参数取舍
4.1 距离量测:动态折线与测地距离
距离量测是插件里最先实现的模式。用户在场景里逐点点击,插件实时更新一条折线并累加各段距离。核心的渲染与计算逻辑放在_renderMeasure里:
_renderMeasure() { const positions = this._positions.slice(); if (this._previewPoint) positions.push(this._previewPoint); if (positions.length < 2) return; // 每帧先清空量测数据源,避免实体堆积 this._dataSource.entities.removeAll(); // 折线实体:把已确认顶点和预览点连接成一条红线 this._dataSource.entities.add({ polyline: { positions, width: 3, material: Cesium.Color.RED, }, }); // 沿椭球面累加各段测地距离 const ellipsoid = this._scene.globe.ellipsoid; let total = 0; for (let i = 0; i < positions.length - 1; i++) { const start = ellipsoid.cartesianToCartographic(positions[i]); const end = ellipsoid.cartesianToCartographic(positions[i + 1]); total += new Cesium.EllipsoidGeodesic(start, end).surfaceDistance; } // 把累计距离标注到最后一个点附近 this._dataSource.entities.add({ position: positions[positions.length - 1], label: { text: total.toFixed(2) + ' m', font: '14px sans-serif', pixelOffset: new Cesium.Cartesian2(10, -20), }, }); }positions由已确认顶点加预览点组成,每帧先removeAll()再重建,渲染开销可控,远低于实体不断堆积导致的卡顿。EllipsoidGeodesic.surfaceDistance返回的是沿椭球面的最短路径长度,逐段累加比首尾直接算一次更符合三维场景里逐步点击的操作直觉。如果业务需要的是"两栋楼之间的直线跨距",把surfaceDistance替换成Cesium.Cartesian3.distance(positions[i], positions[i+1])即可,两种公式只差一个属性名,但语义完全不同。
4.2 面积量测:多边形顶点与投影面积换算
面积量测用到polygon几何体,Cesium 在渲染层完成多边形三角化,插件只需要回填顶点并计算面积。计算球面多边形面积通常采用球面近似算法:
// 球面多边形面积:对经纬度边界做球面投影累加 function computePolygonArea(positions, ellipsoid) { const cartographics = positions.map((p) => ellipsoid.cartesianToCartographic(p) ); let area = 0; for (let i = 0; i < cartographics.length; i++) { const p1 = cartographics[i]; const p2 = cartographics[(i + 1) % cartographics.length]; // 每段边界的面积贡献:经度差 × 纬度正弦和 area += (p2.longitude - p1.longitude) * (2 + Math.sin(p1.latitude) + Math.sin(p2.latitude)); } return Math.abs(area * 6378137.0 * 6378137.0 / 2); }该公式把每个边界段映射到球面投影后累加,假设参考球半径为 6378137 米,在几千平方公里的范围内误差可忽略。渲染侧把this._positions放入polygon的hierarchy,同时设置perPositionHeight: true保留每个顶点的 Z 值,这样多边形在三维地形上是"贴皮"的,而不是悬在半空。面积结果的展示可加单位换算:超过 100 万平方米时显示为平方千米,避免长数字刷屏。
4.3 高度量测:椭球高、海拔高与相对高差的区分
scene.pickPosition返回的Cartesian3转换出的Cartographic.height是椭球高,不是海拔高。对大多数业务场景,用户关心的是两点之间的相对高差,而不是绝对海拔。相对高差的计算非常直接:
_finishHeightMeasure() { if (this._positions.length < 2) return; const ellipsoid = this._scene.globe.ellipsoid; const c1 = ellipsoid.cartesianToCartographic(this._positions[0]); const c2 = ellipsoid.cartesianToCartographic(this._positions[1]); // 相对高差 = 两者椭球高之差 const diff = c2.height - c1.height; this._dataSource.entities.add({ position: this._positions[1], label: { text: `高差 ${Math.abs(diff).toFixed(2)} m`, font: '14px sans-serif', pixelOffset: new Cesium.Cartesian2(10, -20), }, }); }diff为正表示终点高于起点,为负则相反。若业务需要真实海拔,不能只读Cartesian3转出的椭球高,而要结合Cesium.sampleTerrainMostDetailed对地形高度做采样,或接入大地水准面模型做改正。很多新手在山区项目里量高差偏差几十米,原因就是把椭球高当成了海拔高,这个口径问题在需求评审阶段就应先对齐。
4.4 量测结果的多段管理与一键清除
三种模式整合进同一个插件对象时,保持clear()幂等是关键:无论用户当前处于measuring还是finished,调用clear()都只清量测数据而保留插件实例。前面的骨架里用_dataSource.entities.removeAll()做到了这一点。更精细的做法是维护一个_measurements[]数组,每次完成一个量测块就push一条记录,clear()遍历删除并清空数组,这样能在 UI 上实现"撤销上一次量测"的操作。
量测模式的数值口径差异需要在使用文档里写明:
| 量测模式 | 几何对象 | 数值口径 | 关键设置 |
|---|---|---|---|
| 距离 | polyline | 测地距离或欧氏距离 | distanceMode |
| 面积 | polygon | 球面投影近似面积 | perPositionHeight |
| 高度 | polyline+ label | 椭球高差 | 海拔需sampleTerrain |
5. Cesium 量测插件的精度校验与三个高频排错技巧
5.1 用已知地理数据反向量测误差
插件写完不要直接接业务,先做一轮精度校验。选两个已知经纬度坐标的点,比如同一经度上纬度相差 1 度的两点,理论距离约 111.2 公里。在场景里量一次,看结果与理论值偏差是否小于 0.1%。
校验步骤建议按下面的顺序走:
- 在两个已知经纬度点之间量测距离,确认测地线结果与理论值偏差小于 0.1%;
- 在带地形的场景里量同一条山脊线,分别用
pickPosition和pickEllipsoid各测一次,两者差值就是地形起伏引入的误差; - 用 Cesium 自带的绘制矩形工具先框出一个规则的已知范围,再用面积量测验算回推结果,确认面积公式和顶点顺序没有颠倒。
完成这三步后,再去接业务数据,后续排查会省很多时间。记得把校验用的经纬度点和理论值写进插件的单元测试,防止后续重构时把公式改坏。
5.2 三个高频排错场景与应对
排错一:量测点悬在半空或贴不到模型表面。先检查scene.pickPositionSupported是否为 true,再确认没有在viewer初始化之前调用pickPosition。Cesium 的相机与场景在初始化完成后才可拾取,常见于在viewer构造后立即启动量测插件的场景,稍等一帧或监听scene.postRender再启动即可。
排错二:双击结束时多出一个顶点。这是LEFT_CLICK先于LEFT_DOUBLE_CLICK触发的典型表现。有效方案是双击回调里把_positions末尾两个点中距离小于 5 像素的重复点剔除,再进入结果计算。这个阈值要定义在插件配置项里,因为不同屏幕像素比下眯点手感不同。
排错三:3D 地球在连续量测旋转时出现卡顿甚至崩溃。多数原因是量测实体持续堆积,_renderMeasure每帧removeAll再add,量测块数量增长后渲染队列快速膨胀。解决思路是限制单次量测的最大顶点数,把量测结果从Entity体系沉淀到静态Primitive,Entity 适合交互,Primitive 适合渲染,转换时机选在_finishMeasure完成那一刻。
提示:如果发现距离结果与真实测量差 10% 以上,优先检查拾取方式是否退回成了椭球拾取,而不是先怀疑公式写错。
最后补充一个在连续量测场景里很实用的性能细节:量测标注超过几十个时,把每帧的removeAll加add改为"相机静止后再重建"——监听scene.postRender,判断相机位置和朝向是否发生变化,未变化就不重绘;只有用户停止旋转后才一次性提交结果,帧耗时能稳定控制在个位数毫秒级别,量测点位再多也不至于拖垮渲染管线。
本文还有配套的精品资源,点击获取