大家好,我是 红波。一名专注于智驾、机器人标注工具与可视化的全栈工程师。技术栈主要围绕
TS,Vue,WebGL,Three.js,Go,Rust。在开发 2D/3D 标注工具时,「编辑多边形」是最基础也最核心的交互之一。今天我们来聊聊一个看似简单,实则暗藏数学细节的功能:如何在多边形的边上,精准地插入一个新的顶点?
🎯 场景与痛点
在自动驾驶数据标注或机器人路径规划可视化中,我们经常需要绘制多边形(Polygon)。绘制完成后,标注员往往发现某些地方不够贴合,需要在现有的两条边之间插入一个新的控制点。
交互流程通常是这样的:
- 鼠标 Hover 到多边形边上。
- 边高亮,显示一个“可插入”的提示。
- 点击鼠标,新顶点被插入到距离鼠标最近的线段中间。
核心难点在于: 如何计算鼠标位置(2D 屏幕坐标或 3D 投影坐标)距离哪一条线段最近?而不是距离哪个顶点最近。
如果只计算顶点距离,当鼠标靠近线段中间时,可能会错误地吸附到端点上,导致交互体验极差。
🧮 核心算法:点到线段的最近投影
解决这个问题的数学本质是:计算点 到线段 的最近点,并找出所有线段中距离最小的一条。
这里涉及到一个经典的向量投影算法。为了让大家更直观地理解,我整理了一个基于 Three.js 生态的 TypeScript 实现。
代码实现
import * as THREE from 'three';
/**
* 获取插入点的线段索引
* @param pts 多边形顶点数组 (x, y, z)
* @param pos 鼠标/光标位置 (2D 向量)
* @param closed 是否闭合多边形
* @returns 最近线段的起始点索引,若未找到返回 -1
*/
function getInsertSegmentIndex(
pts: Array<{ x: number; y: number; z: number }>,
pos: THREE.Vector2,
closed: boolean
) {
// 1. 处理闭合路径的重复点问题
// 如果闭合且点数>1,最后一个点通常与第一个点重合,计算线段时应排除最后一段重复计算
const uniquePts = closed && pts.length > 1 ? pts.slice(0, pts.length - 1) : pts
let minDist = Infinity
let idx = -1
// 2. 计算线段总数
// 开放路径:n-1 条线段;闭合路径:n 条线段
const segCount = uniquePts.length - 1 + (closed && uniquePts.length > 1 ? 1 : 0)
// 3. 辅助函数:安全获取点(处理闭合时的首尾相接)
const getPoint = (i: number) => (i === uniquePts.length ? uniquePts[0] : uniquePts[i])
for (let i = 0; i < segCount; i++) {
const a = uniquePts[i]
const b = getPoint(i + 1)
// 提取坐标 (注意:这里虽然 pts 有 z,但插入逻辑通常基于屏幕空间或投影后的 2D 平面)
const ax = a.x, ay = a.y
const bx = b.x, by = b.y
// 向量 AB
const vx = bx - ax
const vy = by - ay
// 向量 AP (P 为鼠标位置 pos)
const wx = pos.x - ax
const wy = pos.y - ay
// 线段长度的平方 (避免开根号,性能优化)
// || 1 是为了防止两点重合导致除以 0
const len2 = vx * vx + vy * vy || 1
// 4. 核心:计算投影因子 t
// t = (AP · AB) / |AB|^2
let t = (wx * vx + wy * vy) / len2
// 5. 限制 t 在 [0, 1] 之间
// t < 0 表示最近点在 A 外侧,t > 1 表示在 B 外侧
// 我们需要的是线段上的最近点,所以必须 clamp
if (t < 0) t = 0
else if (t > 1) t = 1
// 6. 计算线段上的投影点坐标
const px = ax + t * vx
const py = ay + t * vy
// 7. 计算鼠标到投影点的实际距离
const dx = pos.x - px
const dy = pos.y - py
const dist = Math.sqrt(dx * dx + dy * dy)
// 8. 更新最小距离
if (dist < minDist) {
minDist = dist
idx = i
}
}
return idx
}
🔍 关键细节解析
作为工程师,我们不能只 Copy 代码,必须理解其中的每一个 Magic Number 和逻辑分支。
1. 闭合路径的去重处理 (uniquePts)
在标注工具的数据结构中,闭合多边形(Polygon)的顶点数组通常首尾相连,即 pts[0] === pts[pts.length-1]。
- 如果直接遍历
pts.length - 1,对于闭合图形,最后一条边(尾点到首点)会被漏掉。 - 如果遍历
pts.length,对于闭合图形,最后一条边会被计算两次(一次作为普通边,一次作为闭合边)。 - 解决方案:通过
closed标志位动态调整segCount,并在getPoint函数中处理索引越界(自动回绕到 0),这样逻辑最清晰且不易出错。
2. 向量投影与 t 值
这是计算几何中的经典操作。
- 向量
- 向量
- 投影长度比例
- 当 时,垂足落在线段上。
- 当 时,最近点是 。
- 当 时,最近点是 。
注意:在插入顶点的场景下,我们通常希望用户点击的是“边”而不是“顶点”。如果 被 clamp 到 0 或 1,说明鼠标离顶点更近。在实际业务中,你可以增加一个判断:
if (t <= 0.05 || t >= 0.95) return -1,强制用户必须点击线段中间区域才能插入,避免误操作。
3. 性能考量 (len2 || 1)
在图形编辑中,用户可能会误操作导致两个顶点重合。如果 和 重合,len2 为 0,会导致除以零错误(得到 Infinity 或 NaN)。加上 || 1 是一种防御性编程,保证算法鲁棒性。
4. 2D 与 3D 的维度陷阱
注意函数签名:pts 是 {x, y, z},但 pos 是 THREE.Vector2。
- 为什么? 标注工具通常是在 3D 场景中操作,但鼠标交互是 2D 的。
- 最佳实践:在调用此函数前,应该先将 3D 顶点通过相机投影到屏幕空间(Screen Space),或者将鼠标射线投射到地面平面(Ground Plane)转为 3D 坐标。上面的代码假设已经统一到了同一个 2D 平面坐标系(例如俯视图的 XZ 平面映射为 XY)。
🚀 进阶优化思路
如果你的标注工具需要处理成千上万个顶点(例如激光雷达点云生成的复杂轮廓), 的遍历可能会在高频 mousemove 事件中造成卡顿。
- 空间索引:引入
R-Tree或QuadTree对线段进行索引,将查询复杂度降低到 。 - 阈值过滤:在遍历前,先计算点到线段包围盒(AABB)的距离,快速排除不可能的线段。
- WebAssembly:对于极度复杂的几何计算,可以使用
Rust编写核心算法,编译为Wasm供 TS 调用。这也是我目前在新架构中尝试的方向,性能提升显著。
📝 总结
在可视化开发中,交互的“手感”往往取决于这些底层的数学计算是否精准。
- 精准:点到线段的投影算法。
- 鲁棒:处理重合点、闭合路径边界。
- 体验:合理的插入阈值判断。
希望这段代码和解析能帮你解决标注工具开发中的痛点。如果你在开发智驾或机器人相关工具,欢迎关注我,后续我会分享更多关于 Three.js 性能优化 和 Rust + WebAssembly 在前端的应用 的实战文章。
觉得有用,请点赞 👍 收藏 ⭐ 支持一下!
作者:红波 专注:智驾 / 机器人 / 标注工具 / 可视化 技术栈:TS, Vue, WebGL, Three.js, Go, Rust