资讯动态

贝塞尔曲线拼接:C1连续性原理与Canvas鼠标交互实现

发布时间:2026/10/14 8:15:21 来源:尧图企业网站定制
很早就想写这篇了。之前那篇聊的是怎么把一条三次贝塞尔曲线用鼠标拖出来画完之后我以为事情就结束了直到有人拿来连续画了好几段然后拼接处有明显的折角问我是不是曲线算法写错了。其实算法没错错的是拼接时完全没考虑连续性。这一篇就专门解决这个问题在鼠标交互的流程里把两次独立的绘制缝成一条视觉上顺滑的曲线。如果你也在做画板、矢量编辑器、笔迹工具、路径设计器这类功能应该能理解我的痛点。单段绘制再复杂也就是四个控制点的事一旦进入多段拼接数学、交互、数据回退全搅在一起。本文会从连续性原理讲起再给出一套基于 Canvas 的 JavaScript 实现最后复盘我在实际调试中遇到的几个坑。内容偏实操面向已经能画出单条贝塞尔曲线、想进一步做连续绘制的读者。1. 拼接不平滑的根因连续性与控制点几何约束1.1 三次贝塞尔曲线的参数表达与连续性定义三次贝塞尔曲线由四个控制点 P0、P1、P2、P3 决定曲线本身不经过中间两个控制点但曲线的切线方向会受到它们的牵引。参数方程写出来是这样B(t) (1-t)^3 * P0 3*(1-t)^2*t * P1 3*(1-t)*t^2 * P2 t^3 * P3其中 t 从 0 变化到 1P0 是起点P3 是终点。这个公式本身很简单但拼接问题恰恰藏在端点两侧的控制点里。两段曲线拼接第一段记为 C第二段记为 Q。如果只是简单地把 Q 的起点放在 C 的终点上那只能保证位置连续数学上叫 C0 连续。视觉表现就是曲线没有断开但接缝处有一个明显的尖角或折线感。为什么会折因为在 C 的终点处曲线离开的方向由 P2 指向 P3 决定而在 Q 的起点处曲线进入的方向由 Q0 指向 Q1 决定。这两个方向互不相干折角就出来了。真正让拼接处看起来顺滑需要的是 C1 连续也就是在一阶导数层面连续。贝塞尔曲线的一阶导数是B(t) 3*(1-t)^2*(P1-P0) 6*(1-t)*t*(P2-P1) 3*t^2*(P3-P2)在终点 t1 处B(1) 3*(P3-P2)在下一段起点 t0 处Q(0) 3*(Q1-Q0)要让 C1 成立就得让 B(1) Q(0)也就是P3 - P2 Q1 - Q0而拼接时 Q0 就是 P3所以进一步得到Q1 2*P3 - P2这个式子就是整篇文章的核心。它看起来只是简单地把 P2 关于 P3 做了一次对称但它把两段曲线是否平滑这个视觉问题精准转化成了三个控制点之间的几何关系P2、P3、Q1 必须共线且 P3 到 P2 的距离等于 P3 到 Q1 的距离。换句话说在拼接点两侧控制臂要等长、共线、方向相反。1.2 C1连续的本质共享切线与等距控制点上一节推导出了公式但实际动手时你会发现公式漂亮不等于交互好做。因为用户不是拿计算器在画图而是拿鼠标在屏幕上拖。鼠标的轨迹天然是自由的它不会主动停在让两边臂长相等的位置上。所以交互层的任务就是把用户的自由拖拽约束到满足连续性的几何关系里。我的做法是用户拖拽新曲线时只要起点吸附到了已有曲线的终点程序就接管 Q1 的计算。不再允许用户随意设置 Q1而是直接按公式 Q1 2*P3 - P2 生成。这时候用户拖动鼠标影响的是 Q2 和 Q3也就是新曲线后半段的形状前半段的走向已经被锁定成顺着前一条曲线末端继续走。这里有个容易忽略的细节P2 与 P3 的共线且等距约束保证了两条曲线在拼接点处的切线方向与切线长度都一致。但如果你只关心方向不关心长度那就是另一种连续叫 G1 几何连续。很多图形软件实际用的是 G1因为人眼对切线方向异常敏感对面切线长度是否一致其实没那么敏感。考虑到交互自由度我在第一版里直接做 C1也就是方向长度都约束公式实现起来也最简单。后面讲交互流程时会看到C1 约束反而让用户拖起来更省心不用自己猜手臂拉多长才合适。1.3 为什么交互实现里优先用 C1 而不是 C2写到这里肯定有人问都做拼接了能不能直接上 C2 连续让曲率也连续这样曲线光顺度不是更高吗理论上可以但交互式鼠标绘制的场景里C2 会带来一个很尴尬的后果控制点自由度被锁死太多。C2 连续要求两段曲线在拼接点处的二阶导数也相等。结合前面 C1 的约束条件可以推出新曲线第二个控制点 Q2 也必须满足一个固定关系Q2 P1 4*(P3 - P2)也就是说一旦用户把新曲线的起点拼接在旧曲线终点上那么 Q1 和 Q2 都不能自由拖动了用户能控制的只剩 Q3 这一个点。一条三次贝塞尔曲线有四个控制点结果三个都是死的只剩一个末端可以动。那些 Q1、Q2 的位置完全由前一段曲线决定用户拖出来的形状自由度极低。这在实际交互里是很劝退的。画一条曲线用户期望的是我拖得越高曲线翘得越高结果 C2 约束下一多半的控制点都不听鼠标使唤视觉反馈会很怪异。我试过强行做 C2 版本最后发现只有两种情况适合一是画完后再自动光顺二是曲线数量少且用户不做精细调整。对于鼠标交互式逐段绘制这个场景C1 是性价比最高的选择视觉顺滑度足够交互自由度也保住了。2. 鼠标交互的状态机设计从绘制一段到拼接一段2.1 交互状态拆解空闲、吸附、拖拽、确认连续拼接最忌讳的是把所有逻辑堆在事件回调里鼠标一动就判断是不是端点然后到处改数据最后代码一团乱。我习惯先把交互状态机拆清楚。整个流程可以拆成四个状态空闲态鼠标在画布上移动没有按下只做端点吸附检测和悬停高亮。吸附态检测到鼠标靠近某个已有曲线终点画面上给出提示比如该点变大、变亮但还没真正按下。拖拽态用户按下鼠标曲线开始绘制。此时记录起点后续 mousemove 根据当前鼠标位置不断更新临时曲线。确认态鼠标松开临时曲线转正写入正式数据状态回到空闲。这段状态流转看起来简单但有一个关键点吸附态与拖拽态的判定不能混在一起。鼠标没按下时是吸附检测一旦按下起点的吸附结果就要冻结下来拖拽过程中不再重新搜索附近端点。否则可能出现这种情况用户明明对准了一个终点按下鼠标稍微移动一点程序又判断出另一个更近的端点结果起点中途跳变画出来的曲线完全失控。我在具体实现中用的是一套非常轻量的状态机代码如下let state idle; // idle | dragging let draft null; // 当前正在绘制的临时曲线 canvas.addEventListener(mousedown, (e) { const pos getCanvasPos(e); state dragging; const joint findNearestEndpoint(pos, SNAP_RADIUS); if (joint) { // 吸附到已有曲线的终点按 C1 约束生成控制点1 draft createDraftFromJoint(joint, pos); } else { draft createDraftFromFree(pos); } }); canvas.addEventListener(mousemove, (e) { const pos getCanvasPos(e); hoverTarget findNearestEndpoint(pos, SNAP_RADIUS); if (state dragging draft) { draft.updateEnd(pos); } }); canvas.addEventListener(mouseup, () { if (state dragging draft) { commitCurve(draft); draft null; } state idle; });这段代码是骨架真正的核心都藏在createDraftFromJoint和updateEnd里。后面第三章会展开讲这里先记住一个原则状态切换的判定条件要简单且不可变。按下那一瞬间做一次吸附判断之后整个拖拽过程都信任这个判断结果。2.2 端点吸附的判定逻辑与阈值设定吸附是拼接的第一步。用户要用鼠标把新曲线的起点对齐到已有曲线的终点上如果完全靠肉眼对齐几百像素的屏幕上几乎不可能做到没有误差。所以要做端点吸附鼠标靠近某个终点一定范围内就自动把起点吸到那个终点上。判定逻辑非常直白遍历所有已有曲线计算鼠标位置与每条曲线的起点、终点的距离取小于阈值的最近端点。严格来说我只吸附终点不吸附起点。原因是拼接新段到已有终点的语义非常自然往前延伸吸附到已有起点则意味着新曲线要往回接用户心理上容易混乱。后续如果做闭合曲线再考虑双向吸附。阈值的选择我在调的时候踩了坑。太小了用户瞄半天都对不上太大了又容易误吸附。我最终用的是屏幕像素 10px也就是鼠标距离某个曲线终点 10 像素以内才触发吸附。视觉上 10px 大约是一个普通圆点按钮的半径用户移动鼠标时能明显感觉到被吸住的反馈。这里必须强调距离计算要在屏幕坐标系下做不能直接拿 canvas 世界坐标算。原因后面第四章会细讲现在先埋个伏笔CSS 尺寸与绘制缓冲区尺寸不一致时直接在 ctx 坐标里算距离会得到错误结果。吸附反馈很重要。我在空闲态 mousemove 里检测到吸附件后会额外绘制一个高亮圆环并且把光标从 crosshair 改成 pointer。用户不需要看状态栏文字看一眼就知道当前按下会接在哪个点上。这种可视化确认比任何日志都管用。2.3 拖拽过程中控制点联动与实时预览进入拖拽态后鼠标的每一次移动都要立刻反映到画布上否则用户会觉得曲线不跟手。但曲线的四个控制点不能随随便便设得有一套稳定的生成策略。非拼接情况下我的策略是鼠标 mousedown 位置作为 P0拖拽过程中的当前鼠标位置作为 P3。P1 和 P2 由 P0 与 P3 的相对位移生成。方向向量取 (P3-P0) 的单位向量臂长取与拖拽距离相关的一个比例值。这样画出来的曲线天然是从起点拉向终点的弧线符合直觉。拼接情况下P0 被吸附到旧曲线终点同时 P1 被 C1 公式锁死剩下的 P2 和 P3 依然跟随鼠标。为了让曲线不出现奇怪的缠绕我给 P2 的生成加了一个约束P2 的方向跟随鼠标位置但长度与 P1 的长度保持某个比例。实测下来P2 臂长设为 P1 臂长的 1.2 倍左右曲线形态比较自然。实时预览我不用额外的 canvas 层而是在同一块画布上渲染。每次 mousemove 之后调用一次renderDraft(draft)绘制一条半透明的贝塞尔曲线同时把控制点画出来。如果用户一直接着画这条半透明曲线会随时间轴不断变化直到鼠标松开才变成正式的不透明曲线。预览拖拽与正式数据之间隔离得很干净后面讲回退时会看到这个设计的好处。2.4 回退与打断机制交互必须允许用户反悔拼接交互里最容易忽略的是反悔权。用户拖了一半觉得不好看或者吸附错了端点如果没有打断机制就只能把整条曲线画完再删体验很差。我的做法是支持两个级别的打断第一级是拖拽过程中按 Esc 键取消当前临时曲线回到空闲态。这需要在 document 上挂一个 keydown 监听并在状态处于 dragging 时拦截。第二级是拼接完成后的撤消也就是 CtrlZ。撤消不能只删掉新加的曲线还要恢复被修改过的旧数据。这里有个设计要点我的拼接方案在按下时就把 P1 锁定了但没有在拖拽过程中修改旧曲线的任何数据。所以撤消只需要把新曲线从数组里弹出去旧曲线天然保持原样非常干净。如果你在实现时图方便直接改了旧曲线的控制点来预览拼接效果那么撤消逻辑就必须记录旧数据的快照复杂度会明显上升。我第四章专门讲这个坑。document.addEventListener(keydown, (e) { if (e.key Escape state dragging) { draft null; state idle; render(); } });回退的历史栈我也做得比较简单每次 commit 时往 history 里 push 一个对象结构是{ removed: [], added: [newCurve] }。撤消时把 added 里的曲线从全局数组移除把 removed 里的曲线加回去。因为我的实现里拼接不会修改旧段removed 永远是空数组。以后如果扩展更复杂的光顺操作history 对象里可以加一个 modified 字段。3. 核心实现Canvas场景下的完整代码走读3.1 曲线数据结构与绘制函数写代码前先定数据结构。这是所有交互的基础结构定好了后面的事件、吸附、回退都好写。我用最简单的方式每条曲线保存四个控制点坐标用 { x, y }。class BezierCurve { constructor(p0, p1, p2, p3) { this.p0 p0; this.p1 p1; this.p2 p2; this.p3 p3; } } const curves []; // 全局已确认曲线 let draftCurve null; // 预览中的临时曲线绘制函数本身不复杂。核心是用 Canvas 的bezierCurveTo方法把三次贝塞尔曲线画出来。同时我还画了控制点和控制臂这样用户能看到曲线的骨架对后续调整会更直观。function drawCurve(ctx, curve, { dashed false } {}) { ctx.save(); if (dashed) ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(curve.p0.x, curve.p0.y); ctx.bezierCurveTo( curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y ); ctx.stroke(); ctx.restore(); drawControlPoint(ctx, curve.p0, #999); drawControlPoint(ctx, curve.p1, #666); drawControlPoint(ctx, curve.p2, #666); drawControlPoint(ctx, curve.p3, #999); } function drawControlPoint(ctx, p, color) { ctx.fillStyle color; ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fill(); }有个细节控制点包括 P0 和 P3 两个端点。因为它们在拼接时要承担吸附目标绘制时要用更显眼的颜色与中间的 P1、P2 区分开。3.2 坐标转换与鼠标事件绑定坐标转换是所有 Canvas 交互里最容易出错的一步。很多人拿到e.offsetX就直接当画布坐标用大多数时候没问题但一旦 canvas 的 CSS 尺寸和属性尺寸不一致坐标就偏了。我的画布设置了固定宽高属性页面适配会拉伸 CSS这里就必须做换算。function getCanvasPos(e) { const rect canvas.getBoundingClientRect(); return { x: (e.clientX - rect.left) * (canvas.width / rect.width), y: (e.clientY - rect.top) * (canvas.height / rect.height) }; }这个函数在 mousedown、mousemove、mouseup 三处统一使用。吸附检测距离、控制点计算、曲线绘制全部基于转换后的坐标。这样不论画布怎么缩放逻辑都不会乱。事件绑定的方式前面已经展示过简化版。完整版要额外处理的是 mouseup 可能发生在 canvas 外的情况。用户按下后拖到画布外再松开如果只监听 canvas 的 mouseup事件根本不会触发曲线就永远停在拖拽态了。稳妥做法是 mouseup 监听在 document 上或者至少在 mousedown 后临时把 mouseup 挂到 document 上。我实际用的是 document 上的 mouseup反正业务逻辑里也会判断是否处于拖拽态。3.3 吸附与 C1 控制点的实际代码吸附查找函数的设计要点是只在空闲态调用拖拽中途不再调用保证起点稳定。查找范围是所有已确认曲线的终点。function findNearestEndpoint(pos, threshold) { let best null; let bestDist threshold * threshold; for (const curve of curves) { const dx pos.x - curve.p3.x; const dy pos.y - curve.p3.y; const dist2 dx * dx dy * dy; if (dist2 bestDist) { bestDist dist2; best { curve, point: curve.p3 }; } } return best; }拼接时生成新曲线草稿的代码如下function createDraftFromJoint(joint, pos) { const p0 { ...joint.point }; // C1 连续的核心P1 由旧曲线终点和旧曲线 P2 对称得到 const p1 { x: 2 * p0.x - joint.curve.p2.x, y: 2 * p0.y - joint.curve.p2.y }; const p3 { ...pos }; const dx p3.x - p0.x; const dy p3.y - p0.y; const len Math.max(40, Math.hypot(dx, dy) * 0.3); // P2 方向延续拖拽方向臂长取 1.2 倍 P1 臂长 const p2 { x: p3.x - (dx / Math.hypot(dx, dy) || 0) * len, y: p3.y - (dy / Math.hypot(dx, dy) || 0) * len }; return { curve: new BezierCurve(p0, p1, p2, p3), isJoint: true }; }这段代码里最值得注意的就是2*p0 - joint.curve.p2。它做的事是把旧曲线在终点处的切线方向继承过来确保新曲线起点处的切线方向与旧曲线终点的切线方向完全一致。用户拖动鼠标时P3 在变P2 也在变但 P1 始终被锁在这里。这是整个拼接方案中最关键的几何约束。非拼接情况下的草稿生成更自由function createDraftFromFree(pos) { const p0 { ...pos }; const p1 { x: p0.x 60, y: p0.y - 30 }; const p2 { x: p0.x 120, y: p0.y 20 }; const p3 { x: p0.x 150, y: p0.y }; return { curve: new BezierCurve(p0, p1, p2, p3), isJoint: false }; }这个默认状态给了一组看得见但不夸张的初始控制点用户拖拽时再随鼠标位置动态更新 P1 和 P2。这里的具体策略可以按需调优核心是曲线跟随鼠标的反馈感。3.4 确认拼接与状态重置鼠标松开后临时曲线要转正。只需要把 draft 里的 curve push 进 curves 数组然后清空 draft重绘整个场景。同时还要把这次提交记录到历史栈里供撤消使用。function commitCurve(draft) { curves.push(draft.curve); history.push({ added: [draft.curve], removed: [] }); draft null; render(); }这里有一个容易忽略的问题曲线转正以后curves数组长度的变化会影响下一次吸附查找。如果用户紧接着在上一段的终点继续画下一段吸附查找应该能找到刚刚被提交的那条曲线的终点。因为findNearestEndpoint遍历的是整个curves数组而commitCurve恰好把曲线加进了这个数组所以天然支持连续拼接。我一开始把历史操作写到 localStorage 里后来发现完全没有必要内存数组已经够用还少了很多异步问题。整个渲染流程用requestAnimationFrame统一管理。所有状态curves、draft、hoverTarget都维护在一个全局对象里render 函数每次清空画布、遍历绘制。不需要局部增量更新因为曲线数量在演示场景下不会有性能压力。这样的实现优点是好调试、好回退逻辑链路清晰。4. 从单段绘制到拼接绘制实测中的坑与策略调整4.1 坐标系不一致导致的吸附假成功这个坑我印象很深。最初版本里我没做坐标转换直接用e.offsetX当画布坐标。在普通分辨率、画布没有被拉伸时一切正常直到我把画布 CSS 宽度设成了容器宽度的 120%吸附就出问题了鼠标明明离某个终点还有十几像素却触发了吸附反过来对准终点却吸附不到。原因是offsetX返回的是相对于目标 DOM 节点的坐标单位是 CSS 像素。而我在绘制、存储曲线时用的是 canvas 内部坐标单位是实际绘图缓冲区的像素。当 CSS 尺寸和缓冲尺寸不一致时这两套坐标系就岔开了。吸附检测用屏幕坐标曲线数据用画面坐标距离算出来自然是错的。解决方式就是前面写的那个getCanvasPos拿到鼠标相对 canvas 的 CSS 坐标后按画布宽高比换算成内部坐标。这是所有后续计算的第一道关卡。我建议任何做 Canvas 交互的人第一时间把这个函数写好后面省一堆事。4.2 阈值设定属于屏幕空间而不是世界空间吸附阈值我一开始写成以内部坐标为单位。因为当时觉得反正检测范围和曲线数据在同一个坐标系存起来也不乱。实际测试后发现问题当画布被拉伸或缩放时10px 的内部坐标距离在屏幕上可能只显示 3px非常难吸附缩小画布时又会觉得吸附范围太大动不动就误触发。原因在人类的感知层面用户判断是否靠近一个点用的是屏幕上的视觉距离而不是画布内部的抽象距离。所以吸附阈值必须落在屏幕空间。我的统一做法是在 mousemove 里拿到鼠标位置后先计算屏幕坐标下的距离判断是否进入吸附范围如果进入再把端点坐标转换为屏幕坐标做可视化高亮。整个检测链路全部使用屏幕像素避免内部坐标和屏幕坐标混用。阈值本身我推荐 8-12px。小于 6px 时用户的鼠标移动速度稍快就容易错过大于 16px 时会有明显的还没走到就被吸住的感觉。如果你的界面里画布会缩放建议把阈值写成一个与当前缩放系数无关的常量因为我们要维持的是屏幕上的视觉距离。4.3 预览拼接不要直接修改旧段控制点前文提过有一种实现方式是吸附成功后为了让用户提前看到拼接后的整体效果直接把旧曲线的控制点临时改掉让旧曲线的形态向新段方向调整。我第一次做原型时也这么干了结果被撤消功能折磨得够呛。最简单的坍缩场景是这样的用户按下鼠标吸附到旧段终点程序把旧段的 P2 改了旧曲线的后半段形态在预览期间发生了肉眼可见的偏移。用户觉得不对按 Esc 取消。取消代码想着把旧段还原但如果中途又发生了一次 commit旧段数据已经被污染了。等用户 CtrlZ 时历史栈里记录的旧段快照与当前实际数据对不上因浅拷贝还可能出现还原后旧段仍然被改掉的诡异现象。后来我把方案改成预览期间完全不动旧曲线数据新曲线的 P1 直接由 C1 公式计算并写入新段。旧曲线在预览期间保持原样用户看到的是新曲线顺着旧曲线末端延伸出去的效果。这本身已经足够直观了。真正的拼接只发生在确认提交的那一瞬间之后旧曲线没有任何变化回退只需要弹掉新曲线。这个改动让整个系统的复杂度降了一档。4.4 光标样式与辅助线的体验细节技术链路理清后还剩下最后一层交互反馈的细腻度。这里没有高深算法但直接影响用户愿不愿意用你的工具。第一个是光标。空闲态用 crosshair表示可以开始画。吸附命中时改成 pointer表示这里可以接上。拖拽中保持 crosshair 或换成 grabbing。这个切换开销很小但感知强。第二个是吸附高亮。命中端点后我在端点外画一圈半径 8px 的空心圆环并在端点上叠加一个更亮的实心点。用户会立刻知道自己将对准哪条曲线的哪个点。第三个是控制臂辅助线。拼接预览时我会用虚线把 P1 到 P0 之间的连线以及 P0 到旧曲线 P2 之间的连线画出来。这样用户直接看到这条新曲线是沿着旧曲线的切线方向走的而不是只看到一个默默生成的曲线形状。辅助虚线在鼠标松开后消失。最开始我没有画辅助线用户反馈说总觉得拼接处怪怪的但又说不出哪里怪。其实就是缺少了切线方向的视觉提示。补上虚线后用户能理解自己的控制权边界在哪里新段前半段由前一条曲线的切线接管能自由调整的只有后半段。这个边界感建立起来工具的使用门槛就降下来了。5. 拼接能力还能够怎么走从 C1 连续到更高阶连续5.1 C2 连续需要哪些额外条件如果你做完 C1 拼接之后还希望整条复合曲线更光滑下一步可以考虑 C2 连续。C2 要求曲线在拼接点处的二阶导数也相等也就是曲率的连续性。前面推导过C2 会给 Q2 增加一个固定约束Q2 P1 4*(P3 - P2)注意这里 Q2 是下一段曲线的第二个控制点。也就是说用户能自由拖动的只剩下 Q3。从交互角度讲这非常受限。但如果你想要的是一个曲线自动光顺的功能而不要求用户实时调整C2 很有价值。我建议的路线是绘制阶段保留 C1 交互让用户自由控制画完之后提供一个整体光顺按钮把整条复合曲线作为输入用全局优化方法重新逼近各段控制点使所有拼接点达到 C2。这个做法把交互的灵活性和最终的光顺性分开用户既可以自由创作又可以一键获得光滑结果。不过要提醒的是C2 连续不等于视觉上没有急弯。曲率连续只保证二阶导数相等但拼接点两侧的曲率半径可能差距很大人眼在某些角度下仍能分辨出忽然变弯的感觉。真正追求极致光顺需要更高阶的样条策略。5.2 从贝塞尔走向统一样条的思路如果你在做一个笔迹工具或者手绘路径工具拼接点连续性问题可以用另一种方式从根源上化解放弃逐段三次贝塞尔改用一个整体样条表示比如三次均匀 B 样条或 Catmull-Rom 样条。以 Catmull-Rom 为例它的每个曲线段由四个控制点插值定义而且经过每个控制点本身。对用户来说画了一条线在底层是一条多段样条天然具备高阶连续性拼接点的光顺问题在表达层就消失了。但这不等于没有代价。第一交互模型变了。贝塞尔的四个控制点可以直接暴露给用户调整而样条的控制点与曲线的关系更加间接用户理解起来有门槛。第二局部修改更复杂。你要移动某一小段曲线可能牵动周围多个控制点。第三底层数据从一条贝塞尔曲线变成一个样条容器存储和序列化都要跟着改。我个人的判断是如果你做的是标注工具、路径编辑器贝塞尔加 C1 拼接已经足够如果你做的是自由手绘、笔锋模拟直接上 Catmull-Rom 或均匀样条才是正确的方向。贝塞尔的好处是直观坏处是拼接连续性问题永远要用额外手段去补。5.3 可以继续实验的方向在现在的代码基础上有几个不复杂但很有意思的方向可以继续做。第一个是闭合曲线。现在只支持把新曲线拼接在已有曲线的终点上如果要画一个闭环最后一段的终点要拼回第一段的起点。这时需要处理的是起点吸附而不是终点吸附同时保证最后一段与第一段在闭环点满足 C1也就是第一段的 P1 与最后一段的 P2 关于拼接点对称。实现起来不算难但连续拼接的状态机要多加一条目标是起点的分支。第二个是拖拽中的数据矫正。当前实现只做了起点吸附控制点臂长比例是在拼接时硬编码的。更进一步可以给 P1、P2 加手柄用户拖拽手柄微调形态。手柄移动时保持 C1 约束的策略是拖动 P1 时自动调整旧段的 P2拖动 P2 时自动调整新段的 P1始终让两者关于拼接点对称。第三个是导出 SVG 路径。将贝塞尔曲线转成 SVG 的 cubic bezier 指令非常直接只需要输出 M、C 命令。多段拼接时把相邻段的拼接点合并并注意不要重复生成不必要的 M 命令否则图形放大后会出现细缝。这个扩展很适合做导出功能强烈推荐加。写在最后做拼接功能前后改了三版最后这套状态机 端点吸附 C1 控制点锁定 独立预览的方案算是在复杂度和效果之间找到了平衡点。如果你的目标只是让几条曲线首尾相连时没有折角C1 已经足够应对绝大多数场景与其提前追求 C2 甚至 C3不如先把交互反馈和回退机制做扎实。毕竟工具的价值不在于数学上多精确而在于用户能不能凭直觉画出自己想要的东西。我自己的体会是给用户一个能看懂、能反悔、能实时预览的拼接过程比任何高阶连续的算法都更接近好用两个字。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑