在一个 2D 矢量编辑器中,曲线渲染和裁切工具分别依赖两套不同的精度体系。渲染走自适应 tessellation,裁切走数学参数化。两者在常规缩放下表现一致,但放大到 10 倍以上时,视觉精度与逻辑精度的裂缝就暴露了——曲线出现折线感,裁切高亮超出交点,镜像侧识别错误。
这篇文章记录这三个问题的根因和修复过程。
问题一:放大时曲线分段粗糙
现象
用户放大画布后,本应平滑的贝塞尔曲线出现明显的折线段。圆弧和椭圆更为严重——闭合点处偶尔出现断裂或跳变。
根因
渲染管线将贝塞尔曲线转换为折线段时,使用自适应 tessellation 公式:
steps = ceil((curveLength × displayScale / pixelsPerSegment) × (1 + curvatureWeight × curvature))
默认配置是 maxSteps = 128,pixelsPerSegment = 3。放大 10 倍时,一条 20mm 的曲线在屏幕上约 600px,公式算出约 200 步,被 maxSteps 截断为 128。每段约 4.7px,肉眼已能感知折线。
更严重的是一个隐藏的越界 bug:tessellateCircleInto 和 tessellateEllipseInto 在生成闭合点时写入 out[steps]。当 steps 恰好等于缓冲区长度(256)时,闭合点写入越界位置,后续渲染读到脏数据。
修复
两步走:先修越界,再提精度。
越界保护:所有 Into 方法将 steps 限制为 min(resolvedSteps, out.length - 1),确保含闭合点的总输出永远不超过缓冲区。不再依赖条件判断 if (steps < out.length) 来决定是否写入闭合点——这种防御式写法在边界条件下静默跳过闭合点,产生另一类渲染错误。
精度提升:maxSteps 从 128 调到 256,pixelsPerSegment 从 3 调到 2。缓冲区同步扩容到 258(maxSteps + 闭合点 + 1 余量)。两个渲染后端的临时点数组同步扩容。
这一步的关键是:精度参数和缓冲区容量必须原子性地同步修改。之前一次只改了精度没改缓冲区,直接触发了越界 bug,导致所有预览图层渲染异常。
问题二:裁切高亮超出交点
现象
裁切工具悬停时显示红色高亮段,标识即将被删除的曲线区间。高亮段的端点应精确对齐交点坐标,但实际上有 1-3 像素的偏移——放大后尤其明显。
根因
裁切工具的工作流程分三步:
交点计算:用数学库(paper.js)求两条曲线的精确交点,得到参数 t 值
子曲线提取:用 bezier-js 的
.split(startT, endT)提取精确的子曲线控制点渲染高亮:将子曲线通过标准渲染管线的
bezierCurveTo绘制
前两步是数学精确的。问题出在第三步:bezierCurveTo 内部走均匀 tessellation,将曲线转为等间距折线段。均匀采样意味着最后一段线段的终点是通过 evalCubic(1.0, ...) 计算的,可能与原始交点坐标有浮点偏差。同时,端点附近如果曲率较大,最后一段较长的线段会视觉上"超出"数学端点。
修复:三重精度策略
不走标准 bezierCurveTo 管线,改为自定义非均匀 tessellation:
共点:首尾点强制使用精确的交点坐标(seg.start 和 seg.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 流程有三步:
hit-test:遍历所有可见形状(含镜像副本),找距离最近的
resolve:如果命中的是镜像副本,映射回主形状
渲染高亮:用 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 语义不同
矢量图形编辑器的精度问题往往不是算法错误,而是多套精度体系之间的边界没对齐。渲染层追求视觉平滑(自适应段数),逻辑层追求数学正确(精确参数化),交互层追求响应一致(哪侧点就高亮哪侧)。三者各自正确,但交汇处需要显式的收敛约束。

参与讨论
(Participate in the discussion)
参与讨论