Cesium 通用绘制与测量工具:动态预览、距离面积与顶点编辑
- 江河
- 2026年7月12日
- 5 mins
- Cesium Webgis
- 测量 callbackproperty cesium ellipsoidgeodesic
文章目录
Cesium 绘制工具不是“点击一次就添加一个 Entity”。完整流程还包含动态预览、结束判定、结果冻结、测量计算、顶点编辑和资源清理。
如果把这些逻辑全部写进一个鼠标回调,线、多边形、圆和矩形很快会互相影响。更稳定的结构是把它分成三层:
交互层:处理鼠标点击、移动、右键和拖拽
状态层:保存确认点、预览点、绘制状态和编辑状态
图形层:不同模式负责创建、更新和冻结自己的 Entity
绘制状态不只有一个 positions
动态绘制至少需要区分两种点:
- 确认点:用户已经点击确定的位置。
- 预览点:跟随鼠标移动、尚未确认的位置。
可以用一个数组保存它们,再记录确认点数量:
private positions: Cesium.Cartesian3[] = []
private confirmedCount = 0
private getDisplayPositions() {
return this.positions.slice()
}
第一次点击时创建主 Entity。后续点击把预览点变成确认点,再追加一个新的预览点:
function confirmPosition(position: Cesium.Cartesian3) {
if (positions.length === 0) {
positions.push(position)
confirmedCount = 1
positions.push(position.clone())
mainEntity = createDynamicEntity(() => positions)
return
}
positions[positions.length - 1] = position
confirmedCount += 1
positions.push(position.clone())
}
鼠标移动只替换最后一个预览点:
handler.setInputAction(
(movement: { endPosition: Cesium.Cartesian2 }) => {
const picked = pickWorldPosition(viewer, movement.endPosition)
if (!picked || confirmedCount === 0) return
positions[positions.length - 1] = picked
onUpdate?.(positions)
},
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
)
这个区分可以避免两个常见问题:把预览点算进最终测量结果,或者结束绘制时多出一个重复顶点。
CallbackProperty 只负责读取当前状态
折线的动态预览可以使用 CallbackProperty:
const entity = dataSource.entities.add({
polyline: {
positions: new Cesium.CallbackProperty(
() => positions,
false,
),
width: 3,
material: Cesium.Color.CYAN,
clampToGround: true,
},
})
多边形则返回 PolygonHierarchy:
const entity = dataSource.entities.add({
polygon: {
hierarchy: new Cesium.CallbackProperty(
() => new Cesium.PolygonHierarchy(positions),
false,
),
material: Cesium.Color.CYAN.withAlpha(0.25),
},
})
CallbackProperty 没有主动修改 Entity。Cesium 在更新场景时调用回调,读取当时的 positions。因此数组是当前状态,回调只是它的动态视图。
结束时把动态属性冻结
绘制结束后继续保留 CallbackProperty 会让每一帧都执行不再需要的回调。结束时先丢掉预览点,再把属性改成固定值:
function stop() {
handler?.destroy()
handler = null
const confirmed = positions.slice(0, confirmedCount)
if (mainEntity?.polyline) {
mainEntity.polyline.positions = new Cesium.ConstantProperty(confirmed)
}
onFinish?.(confirmed, mainEntity)
positions = []
confirmedCount = 0
mainEntity = null
}
多边形冻结时需要保存 PolygonHierarchy,圆和矩形则保存最终半径或 Rectangle。
“安排动态更新”和“保存最终结果”是两个阶段。CallbackProperty 用于前者,固定 Property 用于后者。
用策略隔离不同绘制模式
线、多边形、圆和矩形对点数量和 Entity 属性的要求不同,但鼠标流程相同。可以定义模式策略:
interface DrawModeStrategy {
minPoints: number
autoStopPoints?: number
createEntity(
dataSource: Cesium.CustomDataSource,
getPositions: () => Cesium.Cartesian3[],
style: DrawStyleOptions,
): Cesium.Entity
freezeEntity(
entity: Cesium.Entity,
positions: Cesium.Cartesian3[],
): void
extractPositions(
entity: Cesium.Entity,
time: Cesium.JulianDate,
): Cesium.Cartesian3[]
}
例如折线至少需要两个点,多边形至少需要三个点;圆和矩形通常在第二次点击后自动结束:
const strategies: Record<DrawMode, DrawModeStrategy> = {
polyline: polylineStrategy,
polygon: polygonStrategy,
circle: { ...circleStrategy, minPoints: 2, autoStopPoints: 2 },
rectangle: { ...rectangleStrategy, minPoints: 2, autoStopPoints: 2 },
}
新增模式时只补充图形行为,不需要复制整套鼠标事件。
距离:地表距离加高差
直接计算两个 Cartesian3 的直线距离,得到的是空间弦长。地图测距通常更关心椭球表面的大地线距离。
function getGeodesicDistance(
start: Cesium.Cartesian3,
end: Cesium.Cartesian3,
) {
const startCartographic = Cesium.Cartographic.fromCartesian(start)
const endCartographic = Cesium.Cartographic.fromCartesian(end)
const geodesic = new Cesium.EllipsoidGeodesic(
startCartographic,
endCartographic,
)
const surfaceDistance = geodesic.surfaceDistance
const heightDifference = endCartographic.height - startCartographic.height
return Math.hypot(surfaceDistance, heightDifference)
}
这里先计算椭球表面距离,再用勾股关系加入两点高差。它不是沿真实起伏地形逐米积分,因此不能当成山路长度。如果业务需要贴地路径长度,必须沿路径加密采样地形,再累计相邻采样点距离。
折线总长就是每段距离的累加:
function calculateLineDistance(positions: Cesium.Cartesian3[]) {
const segments: number[] = []
let total = 0
for (let i = 0; i < positions.length - 1; i += 1) {
const distance = getGeodesicDistance(positions[i], positions[i + 1])
segments.push(distance)
total += distance
}
return { total, segments }
}
面积:球面近似及适用边界
局部区域可以采用经纬度球面近似:
function calculatePolygonArea(positions: Cesium.Cartesian3[]) {
if (positions.length < 3) return 0
const ellipsoid = Cesium.Ellipsoid.WGS84
let sum = 0
for (let i = 0; i < positions.length; i += 1) {
const current = ellipsoid.cartesianToCartographic(positions[i])
const next = ellipsoid.cartesianToCartographic(
positions[(i + 1) % positions.length],
)
sum +=
(next.longitude - current.longitude) *
(2 + Math.sin(current.latitude) + Math.sin(next.latitude))
}
return Math.abs(
sum * ellipsoid.maximumRadius * ellipsoid.minimumRadius / 2,
)
}
这个算法适合范围不大、不跨越日期变更线的普通测量。大范围多边形、跨经度 ±180° 的区域、包含洞的多边形和测绘级计算,需要先处理经度展开、环方向和投影误差,不能直接套用这段代码。
顶点编辑本质上是重新进入动态状态
编辑已有图形时,先读取固定位置,再把 Entity 属性改回 CallbackProperty:
function startEdit(entity: Cesium.Entity) {
editPositions = strategy.extractPositions(
entity,
viewer.clock.currentTime,
)
strategy.makeDynamic(entity, () => editPositions)
createEditHandles()
setupEditHandlers()
}
每个编辑手柄通过索引读取当前顶点:
const handle = dataSource.entities.add({
position: new Cesium.CallbackPositionProperty(
() => editPositions[index] ?? Cesium.Cartesian3.ZERO,
false,
),
point: {
pixelSize: 14,
color: Cesium.Color.YELLOW,
outlineColor: Cesium.Color.WHITE,
outlineWidth: 3,
},
})
拖拽期间暂时关闭相机旋转,否则一次鼠标移动会同时修改顶点和相机:
viewer.scene.screenSpaceCameraController.enableRotate = false
const picked = pickWorldPosition(viewer, movement.endPosition)
if (picked) editPositions[dragIndex] = picked
// LEFT_UP 时恢复
viewer.scene.screenSpaceCameraController.enableRotate = true
结束编辑后再次冻结 Entity,并移除编辑手柄。
清理顺序
绘制工具通常同时持有两个事件处理器:绘制处理器和编辑处理器。销毁时依次停止编辑、取消未完成绘制、清空 Entity、移除 DataSource:
function destroy() {
stopEdit()
cancel()
dataSource.entities.removeAll()
viewer.dataSources.remove(dataSource)
}
通用绘制工具的核心不是支持多少图形,而是把确认点、预览点、动态属性和最终结果分开管理。测量只是读取最终位置数组的一个业务层。