我会沿用「零基础人话 + 完整步骤 + 代码逐行拆解」的方式,以绕 Z 轴(红色旋转圈)旋转为例(X/Y 轴逻辑一致,仅旋转轴不同),从 "渲染旋转圈" 到 "拖拽完成多边形旋转",彻底拆解旋转圈编辑的全流程,讲清每一步 "做什么、为什么做、代码怎么实现"。
旋转圈编辑(绕 Z 轴旋转)完整流程拆解
前置认知:先搞懂 3 个核心概念(用 "转盘上的盘子" 举例)
| 代码概念 | 生活对应 | 人话解释 |
|---|---|---|
| 多边形中心点 | 转盘的中心轴 | 旋转的基准点,所有顶点都绕这个点转 |
| Z 轴向量(ENU) | 转盘中心的竖直轴 | 旋转轴,多边形绕这个轴做圆周运动(比如转盘绕中心轴转) |
| 旋转约束平面 | 转盘的桌面(水平面) | 旋转只能在这个平面内进行,保证绕轴旋转不跑偏 |
| 旋转圈 | 转盘边缘的环形把手 | 用户拖拽这个圈,带动盘子(多边形)绕中心轴旋转 |
完整流程:共 8 步(从可视化到拖拽完成)
第一步:初始化渲染 ------ 在多边形中心显示 Z 轴旋转圈(用户能看到的操作把手)
做什么?
在多边形中心点画出红色的 Z 轴旋转圈(XY 水平面的环形折线),让用户知道 "拖这个圈能绕 Z 轴旋转"。
代码实现(对应代码里的addRotateRingsByCenter):
javascript
// 1. 计算多边形中心点(转盘中心)
const center = this.getCenter(this.positions.positions);
// 2. 定义旋转圈参数(半径、分段数,分段越多圈越圆)
const ringRadius = 20; // 旋转圈半径20米
const segment = 36; // 分成36段(每10度一段)
// 3. 生成旋转圈的顶点(XY水平面的环形)
const positions = [];
for (let i = 0; i <= segment; i++) {
const angle = (Cesium.Math.TWO_PI * i) / segment; // 计算每个点的角度
// 生成局部坐标(XY平面,Z=0):绕Z轴的环形
const localPoint = new Cesium.Cartesian3(
ringRadius * Math.cos(angle), // X坐标(东方向)
ringRadius * Math.sin(angle), // Y坐标(北方向)
0 // Z坐标=0(水平面)
);
// 转换为世界坐标(贴合地形)
const enuTransform = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const worldPoint = Cesium.Matrix4.multiplyByPoint(enuTransform, localPoint, new Cesium.Cartesian3());
positions.push(worldPoint);
}
// 4. 绘制Z轴旋转圈(红色环形折线),命名为edit-rotate-ring-z(用于识别)
ringCollection.entities.add({
id: 'edit-rotate-ring-z',
polyline: {
positions: positions,
width: 2,
material: Cesium.Color.RED.withAlpha(0.8)
}
});
关键:旋转圈的位置逻辑
- Z 轴旋转圈在XY 水平面 (垂直于 Z 轴),Y 轴旋转圈在XZ 平面 ,X 轴旋转圈在YZ 平面;
- 所有旋转圈都以多边形中心点为圆心,保证旋转围绕中心进行。
第二步:用户操作 ------ 左键点击 Z 轴旋转圈(触发旋转初始化)
做什么?
代码识别用户点击的是 Z 轴旋转圈,标记 "旋转圈拖拽中",并初始化旋转所需的所有参数。
代码实现(对应你代码里的 LEFT_DOWN 事件):
javascript
this.handler.setInputAction(async (movement) => {
// 1. 拾取用户点击的实体(比如点到了edit-rotate-ring-z)
const picks = this.viewer.scene.drillPick(movement.position);
const entityId = picks[0].id.id || picks[0].id.name;
// 2. 识别是Z轴旋转圈(edit-rotate-ring-z)
if (entityId === 'edit-rotate-ring-z') {
this.isRingDragging = true; // 标记:正在拖旋转圈
this.activeRing = 'z'; // 标记:绕Z轴旋转
this.disableMapMove(); // 禁用地图平移(视角不晃)
this.prepareRingDrag(movement.position); // 核心:初始化旋转参数
}
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
第三步:初始化旋转参数 ------prepareRingDrag(给旋转 "定规矩")
做什么?
计算旋转需要的 "旋转中心、旋转轴、约束平面、起始方向向量",相当于 "告诉代码:只能绕 Z 轴,在水平面内旋转"。
代码实现(逐行拆解):
javascript
private prepareRingDrag(screenPosition: Cesium.Cartesian2) {
// 1. 拿到多边形顶点,计算中心点(转盘中心)
const points = this.getNormalizedPoints(this.positions.positions);
if (points.length < 2) return;
const center = this.getCenter(points);
// 2. 拿到Z轴向量(转盘中心的竖直轴)
const zAxis = this.getAxisVector(center, 'z');
// 3. 生成旋转约束平面(转盘的桌面=XY水平面,垂直于Z轴)
// 平面方程:Z轴向量为法向量,过中心点
const plane = new Cesium.Plane(zAxis, -Cesium.Cartesian3.dot(zAxis, center));
// 4. 计算鼠标点击位置在约束平面上的投影点(转盘边缘的起始点)
const ray = this.viewer.camera.getPickRay(screenPosition); // 鼠标点到3D空间的射线
const startPoint = Cesium.IntersectionTests.rayPlane(ray, plane); // 射线和桌面的交点
if (!startPoint) return;
// 5. 计算起始点相对于中心点的向量(从中心到起始点的方向)
const v = Cesium.Cartesian3.subtract(startPoint, center, new Cesium.Cartesian3());
// 6. 归一化:向量长度转1(方便计算角度,长度不影响旋转)
const startVector = Cesium.Cartesian3.normalize(v, new Cesium.Cartesian3());
// 7. 保存这些参数(供鼠标移动时用)
this.dragCenter = center; // 旋转中心:转盘中心
this.dragAxis = zAxis; // 旋转轴:Z轴
this.dragPlane = plane; // 旋转平面:桌面(XY水平面)
this.dragStartPoint = startPoint; // 起始点:转盘边缘点击处
this.dragStartVector = startVector; // 起始方向向量:中心→起始点
}
关键对比(和轴拖拽的区别):
- 旋转约束平面:垂直于旋转轴(Z 轴旋转的平面是 XY 水平面);
- 轴拖拽约束平面:包含轴向量(Z 轴拖拽的平面是竖直面);
- 保存的核心参数:旋转圈保存 "起始方向向量",轴拖拽保存 "起始偏移标量"。
第四步:用户操作 ------ 鼠标移动(拖旋转圈)
做什么?
用户按住旋转圈拖动鼠标,代码实时计算旋转角度,让多边形跟着绕 Z 轴旋转。
代码实现(对应你代码里的 MOUSE_MOVE 事件):
javascript
this.handler.setInputAction((movement) => {
if (this.isRingDragging) { // 只处理旋转圈拖拽
this.handleRingDrag(movement.endPosition); // 计算角度+旋转
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
第五步:计算角度并旋转 ------handleRingDrag(核心执行逻辑)
做什么?
计算鼠标移动的旋转角度,让多边形(含孔洞)绕 Z 轴旋转,保证 "只改朝向,不改位置"。
代码实现(逐行拆解):
javascript
private handleRingDrag(screenPosition: Cesium.Cartesian2) {
// 1. 校验参数(少一个都不执行,避免崩溃)
if (!this.dragCenter || !this.dragAxis || !this.dragPlane || !this.dragStartVector) return;
// 2. 拿到鼠标当前位置的射线
const ray = this.viewer.camera.getPickRay(screenPosition);
if (!ray) return;
// 3. 计算鼠标当前点在旋转约束平面(桌面)上的投影点
const currentPoint = Cesium.IntersectionTests.rayPlane(ray, this.dragPlane);
if (!currentPoint) return;
// 4. 计算当前点相对于中心点的向量,归一化(长度转1)
const v = Cesium.Cartesian3.subtract(currentPoint, this.dragCenter, new Cesium.Cartesian3());
const currentVector = Cesium.Cartesian3.normalize(v, new Cesium.Cartesian3());
// 5. 计算旋转角度(核心!用叉乘+点乘求夹角)
// 5.1 叉乘:起始向量 × 当前向量 → 得到垂直于两者的向量(判断旋转方向:顺时针/逆时针)
const cross = Cesium.Cartesian3.cross(this.dragStartVector, currentVector, new Cesium.Cartesian3());
// 5.2 点乘旋转轴:得到正弦值(sin)→ 旋转方向+角度大小
const sin = Cesium.Cartesian3.dot(this.dragAxis, cross);
// 5.3 点乘起始/当前向量:得到余弦值(cos)→ 角度大小
const cos = Cesium.Cartesian3.dot(this.dragStartVector, currentVector);
// 5.4 反正切求角度(弧度):范围 -π ~ π(-180° ~ 180°)
const angle = Math.atan2(sin, cos);
// 6. 过滤微小角度(鼠标抖0.0001弧度不算旋转,避免闪屏)
if (Math.abs(angle) < 0.0001) return;
// 7. 执行旋转:多边形(含孔洞)绕Z轴旋转angle弧度
this.applyRotation(this.dragAxis, angle, this.dragCenter);
// 8. 更新起始方向向量(下次基于当前方向计算,实现连续旋转)
this.dragStartVector = currentVector;
// 9. 更新渲染:重新画旋转圈、更新标签
this.updateAfterTransform();
}
关键:旋转角度计算的人话解释
- 比如起始向量是 "向右(0°)",当前向量是 "向上(90°)":
cos = 0(0° 和 90° 的余弦值),sin = 1(正弦值);angle = Math.atan2(1, 0) = π/2(90°)→ 顺时针旋转 90°;
- 若当前向量是 "向下(-90°)":
sin = -1,angle = -π/2(-90°)→ 逆时针旋转 90°;
- 核心:通过 sin/cos 同时确定 "旋转方向" 和 "旋转角度",比单纯用距离计算更精准。
第六步:执行旋转 ------applyRotation(旋转多边形)
做什么?
用四元数 + 旋转矩阵,将多边形的外轮廓和内环孔洞顶点绕指定轴、指定中心旋转指定角度,保证 "整体旋转且位置不变"。
代码实现(逐行拆解):
javascript
private applyRotation(axis: Cesium.Cartesian3, angle: number, center: Cesium.Cartesian3) {
if (!this.positions?.positions) return;
// 1. 用轴+角度创建四元数(Cesium推荐的旋转方式,避免万向锁)
const q = Cesium.Quaternion.fromAxisAngle(axis, angle);
// 2. 四元数转旋转矩阵(方便计算顶点旋转)
const m = Cesium.Matrix3.fromQuaternion(q);
// 3. 定义单个顶点的旋转逻辑(核心!)
const rotatePoint = (p: Cesium.Cartesian3) => {
// 3.1 顶点 - 中心点 → 平移到原点(转盘中心移到坐标原点)
const local = Cesium.Cartesian3.subtract(p, center, new Cesium.Cartesian3());
// 3.2 应用旋转矩阵 → 绕原点旋转angle弧度
const rotated = Cesium.Matrix3.multiplyByVector(m, local, new Cesium.Cartesian3());
// 3.3 平移回原中心 → 完成旋转
return Cesium.Cartesian3.add(rotated, center, new Cesium.Cartesian3());
};
// 4. 旋转外轮廓顶点(盘子边缘)
this.positions.positions = this.positions.positions.map((p) => rotatePoint(p));
// 5. 旋转内环孔洞顶点(盘子中间的洞)
if (Array.isArray(this.positions.holes)) {
this.positions.holes.forEach((hole) => {
if (Array.isArray(hole.positions)) {
hole.positions = hole.positions.map((p) => rotatePoint(p));
}
});
}
}
关键:顶点旋转的三步法(人话)
- 平移到原点:把顶点从 "转盘边缘" 移到 "坐标原点"(中心对齐);
- 绕原点旋转:用旋转矩阵让顶点绕 Z 轴转指定角度;
- 平移回中心:把旋转后的顶点移回原转盘中心;→ 最终效果:顶点绕转盘中心旋转,位置不变,朝向改变。
第七步:更新渲染 ------updateAfterTransform(收尾)
做什么?
旋转后重新渲染旋转圈(跟着多边形转)、更新距离标签、刷新视图,让用户看到最新的多边形朝向。
第八步:结束旋转 ------ 鼠标松开(隐含逻辑)
做什么?
用户松开左键,代码标记isRingDragging = false,启用地图平移,旋转圈编辑流程结束。
旋转圈 vs 轴拖拽核心差异(对比记忆)
| 维度 | 旋转圈编辑(绕轴旋转) | 轴拖拽编辑(沿轴平移) |
|---|---|---|
| 核心目标 | 改朝向(绕中心转) | 改位置(沿轴移) |
| 约束平面 | 垂直于旋转轴(XY 水平面) | 包含轴向量(竖直面) |
| 核心参数 | 起始方向向量(中心→起始点) | 起始偏移标量(轴方向距离) |
| 核心运算 | 叉乘 + 点乘求角度(sin/cos/atan2) | 点乘提取轴偏移(delta) |
| 顶点处理 | 平移→旋转→平移(三步法) | 直接加平移向量(一步法) |
| 结果值 | angle(弧度,-π~π) | delta(米,距离增量) |
核心关键点总结(必记)
- 旋转约束平面:垂直于旋转轴,保证旋转在平面内进行(绕 Z 轴只在水平面转);
- 角度计算核心:叉乘判方向、点乘判大小,atan2 转弧度,兼顾 "方向 + 角度";
- 顶点旋转三步法:平移到原点→旋转→平移回中心,是 3D 旋转的通用标准写法;
- 四元数的作用:避免 "万向锁"(绕多轴旋转时的角度丢失问题),Cesium 推荐用法;
- 连续旋转的关键 :每次移动后更新
dragStartVector,基于当前方向计算,而非初始方向。
简单说:旋转圈编辑的本质是 "给多边形加一个'旋转轨道'(垂直于旋转轴的平面),让鼠标拖轨道边缘,带动多边形绕中心轴转,拖多少角度就转多少"。
圈代码
javascript
/**
* 在中心点绘制旋转圈(Z=红色,Y=绿色,X=蓝色)
*/
private addRotateRingsByCenter(activeShapePoints: Cesium.Cartesian3[]) {
if (!Array.isArray(activeShapePoints) || activeShapePoints.length < 2) return;
this.cleanEntityCollection('editTbRotateRingEntityCollection');
const ringCollection = new Cesium.CustomDataSource('editTbRotateRingEntityCollection');
this.viewer.dataSources.add(ringCollection);
// 处理闭合点(首尾相同)
const points =
activeShapePoints.length > 2 && Cesium.Cartesian3.equals(activeShapePoints[0], activeShapePoints[activeShapePoints.length - 1])
? activeShapePoints.slice(0, -1)
: activeShapePoints;
// 计算中心点(笛卡尔坐标平均)
const center = points.reduce((acc, cur) => Cesium.Cartesian3.add(acc, cur, acc), new Cesium.Cartesian3(0, 0, 0));
Cesium.Cartesian3.multiplyByScalar(center, 1 / points.length, center);
const ringRadius = 25; // 单位:米
const segment = 64;
const addRing = (plane: 'xy' | 'xz' | 'yz', color: Cesium.Color, id: string) => {
const positions: Cesium.Cartesian3[] = [];
for (let i = 0; i <= segment; i++) {
const angle = (Cesium.Math.TWO_PI * i) / segment;
let local = new Cesium.Cartesian3();
if (plane === 'xy') {
local = new Cesium.Cartesian3(ringRadius * Math.cos(angle), ringRadius * Math.sin(angle), 0);
} else if (plane === 'xz') {
local = new Cesium.Cartesian3(ringRadius * Math.cos(angle), 0, ringRadius * Math.sin(angle));
} else {
local = new Cesium.Cartesian3(0, ringRadius * Math.cos(angle), ringRadius * Math.sin(angle));
}
positions.push(local);
}
const matrix = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const worldPositions = positions.map((p) => Cesium.Matrix4.multiplyByPoint(matrix, p, new Cesium.Cartesian3()));
ringCollection.entities.add(
new Cesium.Entity({
id,
name: id,
polyline: {
positions: worldPositions,
width: 2,
material: color,
clampToGround: false,
},
})
);
};
// 🔴 绕 Z 轴(高度轴)旋转:水平面环
addRing('xy', Cesium.Color.RED, 'edit-rotate-ring-z');
// 🟢 绕 Y 轴(南北轴)旋转:XZ 环
addRing('xz', Cesium.Color.GREEN, 'edit-rotate-ring-y');
// 🔵 绕 X 轴(东西轴)旋转:YZ 环
addRing('yz', Cesium.Color.BLUE, 'edit-rotate-ring-x');
}