【可视化交互】在标注工具中,如何优雅地实现「多边形插入顶点」?

8 阅读6分钟

大家好,我是 红波。一名专注于智驾、机器人标注工具与可视化的全栈工程师。技术栈主要围绕 TS, Vue, WebGL, Three.js, Go, Rust

在开发 2D/3D 标注工具时,「编辑多边形」是最基础也最核心的交互之一。今天我们来聊聊一个看似简单,实则暗藏数学细节的功能:如何在多边形的边上,精准地插入一个新的顶点?


🎯 场景与痛点

在自动驾驶数据标注或机器人路径规划可视化中,我们经常需要绘制多边形(Polygon)。绘制完成后,标注员往往发现某些地方不够贴合,需要在现有的两条边之间插入一个新的控制点。

交互流程通常是这样的:

  1. 鼠标 Hover 到多边形边上。
  2. 边高亮,显示一个“可插入”的提示。
  3. 点击鼠标,新顶点被插入到距离鼠标最近的线段中间。

核心难点在于: 如何计算鼠标位置(2D 屏幕坐标或 3D 投影坐标)距离哪一条线段最近?而不是距离哪个顶点最近。

如果只计算顶点距离,当鼠标靠近线段中间时,可能会错误地吸附到端点上,导致交互体验极差。


🧮 核心算法:点到线段的最近投影

解决这个问题的数学本质是:计算点 PP 到线段 ABAB 的最近点,并找出所有线段中距离最小的一条。

这里涉及到一个经典的向量投影算法。为了让大家更直观地理解,我整理了一个基于 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

这是计算几何中的经典操作。

  • 向量 V=BAV = B - A
  • 向量 W=PAW = P - A
  • 投影长度比例 t=WVV2t = \frac{W \cdot V}{|V|^2}
  • t[0,1]t \in [0, 1] 时,垂足落在线段上。
  • t<0t < 0 时,最近点是 AA
  • t>1t > 1 时,最近点是 BB注意:在插入顶点的场景下,我们通常希望用户点击的是“边”而不是“顶点”。如果 tt 被 clamp 到 0 或 1,说明鼠标离顶点更近。在实际业务中,你可以增加一个判断:if (t <= 0.05 || t >= 0.95) return -1,强制用户必须点击线段中间区域才能插入,避免误操作。

3. 性能考量 (len2 || 1)

在图形编辑中,用户可能会误操作导致两个顶点重合。如果 AABB 重合,len2 为 0,会导致除以零错误(得到 InfinityNaN)。加上 || 1 是一种防御性编程,保证算法鲁棒性。

4. 2D 与 3D 的维度陷阱

注意函数签名:pts{x, y, z},但 posTHREE.Vector2

  • 为什么? 标注工具通常是在 3D 场景中操作,但鼠标交互是 2D 的。
  • 最佳实践:在调用此函数前,应该先将 3D 顶点通过相机投影到屏幕空间(Screen Space),或者将鼠标射线投射到地面平面(Ground Plane)转为 3D 坐标。上面的代码假设已经统一到了同一个 2D 平面坐标系(例如俯视图的 XZ 平面映射为 XY)。

🚀 进阶优化思路

如果你的标注工具需要处理成千上万个顶点(例如激光雷达点云生成的复杂轮廓),O(N)O(N) 的遍历可能会在高频 mousemove 事件中造成卡顿。

  1. 空间索引:引入 R-TreeQuadTree 对线段进行索引,将查询复杂度降低到 O(logN)O(\log N)
  2. 阈值过滤:在遍历前,先计算点到线段包围盒(AABB)的距离,快速排除不可能的线段。
  3. WebAssembly:对于极度复杂的几何计算,可以使用 Rust 编写核心算法,编译为 Wasm 供 TS 调用。这也是我目前在新架构中尝试的方向,性能提升显著。

📝 总结

在可视化开发中,交互的“手感”往往取决于这些底层的数学计算是否精准。

  • 精准:点到线段的投影算法。
  • 鲁棒:处理重合点、闭合路径边界。
  • 体验:合理的插入阈值判断。

希望这段代码和解析能帮你解决标注工具开发中的痛点。如果你在开发智驾或机器人相关工具,欢迎关注我,后续我会分享更多关于 Three.js 性能优化Rust + WebAssembly 在前端的应用 的实战文章。

觉得有用,请点赞 👍 收藏 ⭐ 支持一下!


作者:红波 专注:智驾 / 机器人 / 标注工具 / 可视化 技术栈:TS, Vue, WebGL, Three.js, Go, Rust