在一个 2D 矢量编辑器中,曲线渲染和裁切工具分别依赖两套不同的精度体系。渲染走自适应 tessellation,裁切走数学参数化。两者在常规缩放下表现一致,但放大到 10 倍以上时,视觉精度与逻辑精度的裂缝就暴露了——曲线出现折线感,裁切高亮超出交点,镜像侧识别错误。

这篇文章记录这三个问题的根因和修复过程。

问题一:放大时曲线分段粗糙

现象

用户放大画布后,本应平滑的贝塞尔曲线出现明显的折线段。圆弧和椭圆更为严重——闭合点处偶尔出现断裂或跳变。

根因

渲染管线将贝塞尔曲线转换为折线段时,使用自适应 tessellation 公式:

steps = ceil((curveLength × displayScale / pixelsPerSegment) × (1 + curvatureWeight × curvature))

默认配置是 maxSteps = 128pixelsPerSegment = 3。放大 10 倍时,一条 20mm 的曲线在屏幕上约 600px,公式算出约 200 步,被 maxSteps 截断为 128。每段约 4.7px,肉眼已能感知折线。

更严重的是一个隐藏的越界 bug:tessellateCircleIntotessellateEllipseInto 在生成闭合点时写入 out[steps]。当 steps 恰好等于缓冲区长度(256)时,闭合点写入越界位置,后续渲染读到脏数据。

drawing-1780299937046

修复

两步走:先修越界,再提精度。

越界保护:所有 Into 方法将 steps 限制为 min(resolvedSteps, out.length - 1),确保含闭合点的总输出永远不超过缓冲区。不再依赖条件判断 if (steps < out.length) 来决定是否写入闭合点——这种防御式写法在边界条件下静默跳过闭合点,产生另一类渲染错误。

精度提升maxSteps 从 128 调到 256,pixelsPerSegment 从 3 调到 2。缓冲区同步扩容到 258(maxSteps + 闭合点 + 1 余量)。两个渲染后端的临时点数组同步扩容。

这一步的关键是:精度参数和缓冲区容量必须原子性地同步修改。之前一次只改了精度没改缓冲区,直接触发了越界 bug,导致所有预览图层渲染异常。

问题二:裁切高亮超出交点

现象

裁切工具悬停时显示红色高亮段,标识即将被删除的曲线区间。高亮段的端点应精确对齐交点坐标,但实际上有 1-3 像素的偏移——放大后尤其明显。

根因

裁切工具的工作流程分三步:

  1. 交点计算:用数学库(paper.js)求两条曲线的精确交点,得到参数 t 值

  2. 子曲线提取:用 bezier-js 的 .split(startT, endT) 提取精确的子曲线控制点

  3. 渲染高亮:将子曲线通过标准渲染管线的 bezierCurveTo 绘制

前两步是数学精确的。问题出在第三步:bezierCurveTo 内部走均匀 tessellation,将曲线转为等间距折线段。均匀采样意味着最后一段线段的终点是通过 evalCubic(1.0, ...) 计算的,可能与原始交点坐标有浮点偏差。同时,端点附近如果曲率较大,最后一段较长的线段会视觉上"超出"数学端点。

drawing-1780300073918

修复:三重精度策略

不走标准 bezierCurveTo 管线,改为自定义非均匀 tessellation:

共点:首尾点强制使用精确的交点坐标(seg.startseg.end),不通过 evalCubic 重新计算。这保证两条曲线在交点处共享同一个浮点坐标,视觉上完全对齐。

端点加密:在 t ∈ [0, 0.05] 和 t ∈ [0.95, 1.0] 各插入 4 个额外采样点。端点附近的线段长度降到 0.5px 以下,即使有微小偏差也不可感知。

曲率感知:对中间段,在相邻采样点之间估算局部曲率。曲率超过阈值时插入中间点。直线段曲率为零,不受影响;高曲率区域自动加密。

// 伪代码:非均匀采样
const tValues = [0]
// 端点加密
for (let i = 1; i <= 4; i++) tValues.push(i * 0.0125)
// 中间段正常步数
for (let i = 1; i < 32; i++) tValues.push(0.05 + (i / 32) * 0.9)
// 尾部加密
for (let i = 0; i < 4; i++) tValues.push(0.95 + i * 0.0125)
tValues.push(1)

// 曲率感知:高曲率区域插入中间点
// ...

// 强制首尾为精确交点坐标
points[0] = seg.start        // 不用 curve.get(0)
points[last] = seg.end       // 不用 curve.get(1)

这三重策略对直线段同样适用。直线不需要 tessellation(两点确定一条线),但端点必须是精确的交点坐标——否则高亮线段的端点与交叉线的端点不重合,放大后能看到缝隙。

问题三:镜像侧裁切识别错误

现象

编辑器支持 Y 轴对称镜像:主形状在右侧(X > 0),镜像副本在左侧(X < 0)。用户在镜像侧点击裁切时,高亮段却显示在主侧。操作的区域和预览的区域不一致。

根因

裁切工具的 hover 流程有三步:

  1. hit-test:遍历所有可见形状(含镜像副本),找距离最近的

  2. resolve:如果命中的是镜像副本,映射回主形状

  3. 渲染高亮:用 resolve 后的 shapeId 获取几何并绘制

问题出在第 2 步和第 3 步的耦合。hover 阶段就执行了 resolve,导致 shapeId 变成主形状 ID。渲染器用主形状的几何绘制高亮——高亮自然出现在主侧。同时渲染器检测到 shape.mirrorId 存在,还会翻转 X 渲染一次"对称侧"——结果两侧都有高亮,用户不知道实际裁的是哪一段。

另一个问题是 hit-test 的优先级。当主形状和镜像副本到光标的距离相近时(比如形状跨越 Y 轴),单纯按距离排序可能选中主形状而非镜像副本。用户在左侧点击,却命中了右侧的主形状。

修复

分离 hover 和 execute 的 resolve 时机:hover 阶段不做镜像解析,保留镜像副本的 shapeId。预览高亮渲染时用的就是镜像副本的几何坐标(已经在负 X 区域),高亮自然显示在用户点击的那一侧。只在执行裁剪命令时才调用 resolveToEntity,将操作映射到主形状。

移除自动渲染对称侧:渲染器不再检查 mirrorId 后自动翻转 X 渲染另一侧。高亮只渲染 trimSegment.shapeId 对应的那一侧。如果需要对称侧也高亮,由对称侧自己的 trim segment 独立触发。

hit-test 镜像侧优先:当光标在 X < 0 区域时,同等距离下优先选择镜像副本而非主形状。实现方式是引入"侧匹配"优先级——如果新候选在正确侧而当前最佳不在,直接替换,不论距离。

// 伪代码:镜像侧优先 hit-test
const isOnMirrorSide = point.x < 0

for (const shape of allVisibleShapes) {
  const distance = getDistanceToShape(point, shape)
  if (distance > hitTolerance) continue

  const sideMatch = isOnMirrorSide ? isMirrorCopy(shape) : !isMirrorCopy(shape)
  const prevSideMatch = foundShape ? /* 同样的判断 */ : false

  // 同侧优先,否则按距离
  const shouldReplace = !foundShape ||
    (sideMatch && !prevSideMatch) ||
    (sideMatch === prevSideMatch && distance < minDistance)

  if (shouldReplace) { /* 更新候选 */ }
}

小结

三个问题的共同点是:渲染精度和逻辑精度在不同层级独立演化,直到用户放大或跨侧操作时才暴露不一致。

修复的核心原则:

  • 缓冲区容量和精度参数必须原子性同步,否则一方先行会触发越界

  • 裁切高亮的端点不能依赖 tessellation 的浮点计算,必须使用原始交点坐标

  • 预览和执行的 resolve 时机不能混淆——预览服务于视觉反馈,执行服务于数据变更,两者的 shapeId 语义不同

矢量图形编辑器的精度问题往往不是算法错误,而是多套精度体系之间的边界没对齐。渲染层追求视觉平滑(自适应段数),逻辑层追求数学正确(精确参数化),交互层追求响应一致(哪侧点就高亮哪侧)。三者各自正确,但交汇处需要显式的收敛约束。