在线CAD圆弧绘制实战:从数学原理到Canvas渲染 📅 发布时间:2026/9/16 4:31:15 👁 浏览次数: 前阵子接了个需求给团队的在线CAD编辑器补上“圆弧”命令。直线、矩形、多段线这些图元几天就搞完了我原本以为圆弧撑死了也就一下午——毕竟Canvas原生就带arc方法API文档翻一翻就会了。结果这个“撑死一下午”的活实际占了我快两周。问题从来不在arc方法本身而在一堆看不见的地方方向怎么定、角度基准在哪、坐标系怎么翻转、交互状态怎么管理任何一个环节没想清楚画出来的弧就不是用户想要的那条。这篇就围绕“在线CAD如何在web端实现AutoCAD的绘圆弧功能”展开把从数学原理、交互状态机到渲染选型和代码落地的东西一次性讲透。适合正在做Web端CAD、图形编辑器或者想给白板/设计工具加圆弧能力的开发者参考。里面有些坑是我反复踩过才绕出来的能帮你省掉不少时间。1. 为什么说圆弧是web端CAD第一块硬骨头1.1 同一个圆弧AutoCAD给了你十种画法AutoCAD的ARC命令和直线命令有个本质差别直线只需要确定两个端点圆弧的参数组合就复杂多了。在命令行里敲ARC光是官方支持的输入方式就有十种左右三点画弧、起点-圆心-终点、起点-圆心-角度、起点-圆心-长度、起点-终点-角度、起点-终点-半径、圆心-起点-终点、圆心-起点-角度、连续圆弧等等。这里最关键的点不是“要不要全支持”而是“你兜底的数据结构必须能容纳所有输入方式”。我在设计时没有按输入方式去拆类而是统一抽象成了一套标准格式圆心坐标(cx, cy)、半径r、起始角startAngle、终止角endAngle外加一个方向标志ccw。不管用户用哪种方式输入最终都换算成这组数据。这样做的好处是后续渲染、DXF导出、二次编辑全都复用同一套逻辑不会出现“画的时候好好的存到文件里再打开就变形”的窘境。1.2 圆弧是有方向的直线没有这是整个功能里最容易被新手忽略的地方。一条直线从A点到B点和从B点到A点画出来是完全重合的几何形状。圆弧不是这样同样是圆心(0,0)、半径10、从0度到90度顺时针画和逆时针画一条落在第一象限一条扫过了270度完全是两条弧线。所以每次生成圆弧必须显式地记录它是沿着哪个方向走的。网上很多半吊子教程写WebCAD圆弧功能只给出startAngle和endAngle完全不提方向你跟着做会发现有些角度组合下画出来的弧方向永远是错的。这一点在后面的数学部分会重点展开。1.3 数据格式兼容性DXF组码的角度约定如果你的在线CAD不是自娱自乐大概率需要导出DXF文件再拿到AutoCAD或者其他CAD软件里打开。DXF中ARC实体的核心组码包括组码含义10, 20圆心X、Y坐标40半径50起始角度51终止角度DXF里角度以度为单位并且统一按逆时针为正方向。也就是说如果你内部用弧度、习惯用顺时针为正导出前必须做转换。我在实现时让内部数据结构直接采用“度逆时针为正”的约定跟DXF对齐Rendering层再转成Canvas和SVG各自需要的格式这样层面职责清晰也不会出现“导出文件到AutoCAD里弧线乱飞”的惨案。2. 圆弧绘制的数学基础先把角、方向和圆心算明白2.1 三点定圆代数解和边界条件实现圆弧功能优先要支持的是“三点画弧”因为涵盖的几何意义最全也是AutoCAD默认模式。三个点确定一个圆这是初中几何就学过的命题但代码实现还是有一些细节要注意。给定三个点P1(x1, y1)、P2(x2, y2)、P3(x3, y3)圆心可以通过解线性方程组得到。我直接给出用行列式表示的结果d 2 * (x1*(y2 - y3) x2*(y3 - y1) x3*(y1 - y2)) cx ((x1² y1²)*(y2 - y3) (x2² y2²)*(y3 - y1) (x3² y3²)*(y1 - y2)) / d cy ((x1² y1²)*(x3 - x2) (x2² y2²)*(x1 - x3) (x3² y3²)*(x2 - x1)) / d分母d的几何意义是三角形P1P2P3面积的2倍。如果d的绝对值接近0说明三点共线或非常接近共线此时圆不存在或半径趋向无穷大。代码里必须有这个分支不能让它继续算下去否则会得出一个极大的半径画出来的“假圆弧”能把整个画布占满。拿到圆心之后半径用任意一个点到圆心的欧氏距离就行。起始角就是P1相对圆心的极角终止角是P3相对圆心的极角用Math.atan2计算。2.2 方向判断叉积和坐标系的恩怨确定初始角和终止角之后还要判断弧线从起点到终点走的方向。标准做法是计算向量P1P2和P2P3的叉积cross (P2.x - P1.x) * (P3.y - P2.y) - (P2.y - P1.y) * (P3.x - P2.x)如果在标准的数学坐标系y轴向上里cross 0表示三个点呈逆时针方向排列。但浏览器里的坐标系是y轴向下直接把cross用在这个坐标系里方向判断就会反向。这个问题我最初没意识到导致一个点序列画出来的弧在不同象限的表现时对时错折腾了很久才定位到是坐标系的锅。我的处理方式是底层几何计算统一在国内标准的数学坐标系中完成屏幕坐标转换时把y取反。具体来说鼠标事件拿到的坐标先做一次变换把y变成-y算完圆弧再把结果转换回屏幕坐标渲染。这样三点方向、角度正负这些几何判断都符合数学直觉不容易出错。2.3 角度规范化处理0度跨度和周期循环拿到startAngle和endAngle之后不能直接画。因为atan2返回的角度范围是[-π, π]而画图API又经常要求角度递增或递减连续变化这里存在一个角度规范化的问题。举个例子起点在350度终点在10度如果我想画一条从350度开始逆时针扫到10度的弧走的是350度 - 360度 - 370度这条路径。但atan2算出的endAngle是10度而不是370度直接传给底层APIAPI会理解为从350度往回走到10度画出一大段错误的弧。通用的做法是先确定方向然后按方向把endAngle调整到startAngle的“正方向一侧”。如果是逆时针画弧就把endAngle不停加2π直到它大于startAngle如果是顺时针画弧就把endAngle不停减2π直到它小于startAngle。这样角度差始终与方向一致中间就算跨过0度边界也可以正确表示。2.4 浮点与容差放大视图下才暴露的精度问题浏览器的坐标系在缩放平移之后鼠标坐标动辄就是几万几十万的量级。三点定圆公式里包含大量平方运算如果输入坐标是(10234.56, 87342.12)这种规模计算结果中的小误差会被放大导致画出来的圆弧首尾不闭合视觉上出现一个细微的缺口。处理方法是两处第一计算容差不要用绝对量而要用相对量。判断三点是否共线时不要看d是否等于0而是看Math.abs(d)跟三点构成的边长量级相比是否小于某个阈值。第二生成结果前做一个归一化修正用圆心坐标重新计算半径取三个点到圆心距离的中间值作为统一半径。这样至少保证起点、终点都在同一个圆上视觉缺口会小很多。3. 交互状态机把AutoCAD的ARC命令翻译成Web事件3.1 状态定义从空闲到圆弧生成Web端的绘图操作本质上是一个状态机。以三点画弧为例我把它拆成四个状态IDLE空闲、PICK_FIRST已确定起点、PICK_SECOND已确定第二点、PICK_THIRD已确定第三点并生成圆弧。状态机的核心价值是让代码逻辑和用户行为一一对应不会出现“连续点鼠标导致状态混乱”的问题。我用一个简单的枚举加切换函数管理每次鼠标事件进来根据当前状态决定是推进到下一个状态还是报错/忽略。这样做还有个额外的好处撤销和重做可以沿着状态边界记录快照而不是每一帧都存数据。3.2 鼠标事件到绘制的映射具体的事件绑定逻辑如下鼠标左键点击mousedown触发取点。处于IDLE时点击记录起点状态切到PICK_FIRST。处于PICK_FIRST时点击记录第二点状态切到PICK_SECOND。处于PICK_SECOND时点击记录第三点立即调用三点定圆算法生成圆弧数据存入图元列表状态回到IDLE。mousemove过程中如果状态不是IDLE就实时取当前鼠标位置结合已确定的点动态计算圆弧用于预览。按Esc取消当前绘制回到IDLE按Backspace或右键可以回退到上一步取点方便画错的时候纠正不用重来。这里有意识地做了“第三点即生成”的交互AutoCAD的ARC命令在选完第三点后命令并不会自动结束你必须再按一次回车来退出。但在Web端这会让用户困惑因为在线CAD的工具按钮是显式的命令生命周期很短。我最终选择第三点落定就结束命令这是为了让“随手画”的体验更顺畅。如果你要复刻AutoCAD的全部行为可以加一个“连续画弧”模式。3.3 实时预览两个层级的选择预览效果直接影响用户的判断。如果你画的弧要等第三点全部确定才显示用户根本不知道第一点和第二点之间的关系很容易画歪。我做过两种方案第一种是简单预览就是每次mousemove都重新跑一次三点定圆算法。这个方案实现成本低但是性能不是最优的因为几何计算量不算小如果画布上还有其他图元每帧全量重绘会掉帧。适合图元数量少的场景。第二种是“先算圆再拖弧”的增量预览确定起点和第二点后可以先根据这两点算出潜在的圆心轨迹中垂线然后预览圆等鼠标移动时只计算第三点在圆上的投影。这个方案更贴近AutoCAD的“动态输入”体验但实现复杂且在某些位置上会产生歧义。我最终在首版里用的是第一种后面图元多了再做优化。这里提醒一句预览弧的样式一定要和正式弧区分开通常用虚线、对比色否则用户会误以为已经画好了。3.4 键盘参数输入让Web端手感更接近AutoCAD鼠标点击虽然直观但精确画弧经常需要指定半径长度或角度。AutoCAD用户习惯在命令行里输入数值Web端没有命令行但可以用输入框模拟。我的做法是在工具栏旁边放一个浮动参数输入条当状态处于PICK_FIRST时可以手动输入起点坐标处于PICK_SECOND时输入相对距离和角度处于PICK_THIRD时输入半径值。按Tab键在输入框和画布之间切换焦点输入完按回车相当于完成当前步骤。这个方案的工程量不大但对手感提升非常明显尤其是有CAD使用习惯的用户如果没有键盘输入他们基本不会认真用Web端画弧。4. 渲染层对比与选型Canvas还是SVG4.1 两种方案的能力边界对比渲染圆弧网上最常见的选择是Canvas 2D和SVG。我整理了一份对比方便你根据自己的场景选型对比维度Canvas 2DSVG圆弧绘制APIctx.arc 及其变体path元素中的A命令图形独立性无整个画布是一个整体每个图形是DOM节点大规模图形性能优重绘成本固定差节点过多会拖慢DOM事件拾取需要自己做命中测试天然支持每节点事件复杂CAD场景适配需要自己管理显示列表和拾取适合轻量编辑场景导出/序列化手动处理但可控性高可以直接读DOM结构我最终选了Canvas 2D原因很简单在线CAD的核心是大量图元和高频交互Canvas的“一次性绘制所有内容”的模式更适合而且后续要叠加WebGL做3D或超大图纸渲染Canvas的演进路径更平滑。如果你只是做一个轻量的设计工具图元数量不超过几百个SVG的开发效率会高很多。4.2 Canvas arc方法的细节与anticlockwise的坑Canvas的arc方法签名是ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise)这里的counterclockwise就是坑。在Canvas默认坐标系y向下中角度增大方向在视觉上是顺时针的。所以当你拿到数学坐标系里算好的角度直接传进去会有两种不合预期的情况如果你想画数学坐标系下的逆时针弧Canvas里的counterclockwise参数需要传true。但如果你已经做了y轴翻转把视觉弧转换成了数学弧那传给Canvas时反而要传false。这个参数极其容易让人当场宕机。我的经验是不要在渲染层做任何“脑内换算”在代码里定义清晰的变量名比如mathCCW然后写一个注释解释坐标系的转换关系渲染时直接按注释处理比临时调参数靠谱得多。另外画完整的圆起始角0、终止角2π在Canvas里有个小坑如果直接传0和2π某些浏览器会因为浮点精度问题画出空路径。稳妥的做法是当角度差大于等于2π时单独用ctx.ellipse或者ctx.arc配合两个半圆的方式画或者将终止角微调为Math.PI * 2 0.0001。4.3 SVG的A命令参数映射逻辑如果你选SVG圆弧对应path A命令A rx ry x-axis-rotation large-arc-flag sweep-flag x y其中large-arc-flag决定是否画优弧角度大于180度的弧sweep-flag决定画弧方向。把内部的圆弧数据映射到SVG时需要根据起点终点连线方向和圆弧的中点位置综合计算这两个标志位比Canvas多一点几何推算但也不算复杂。这里只提醒一点SVG中的y轴也是向下sweep-flag为1时视觉上是顺时针方向和Canvas的counterclockwise参数一样容易弄反最好也做一层封装不让业务代码直接碰这些标志位。4.4 一万个圆弧的渲染性能优化当图元数量上来之后Canvas的渲染瓶颈主要在路径状态切换和重绘策略而不是arc调用本身。我的三个优化手段对同颜色、同线宽的圆弧分组批量设置ctx.strokeStyle和ctx.lineWidth减少状态切换。视口裁剪只绘制与当前视口矩形相交的圆弧。粗判用圆弧的包围盒精细判断在看命中率大部分情况下粗判就够。离屏Canvas缓存静态图层只有当前激活图元才实时重绘其余内容缓存到离屏Canvas上每次交互只需重绘一层而不是全部。这些优化做完之后我本地测试了大约五千个圆弧的图纸缩放漫游基本维持在60帧手感可用。5. 完整示例用TypeScript实现三点画弧5.1 项目结构与依赖为了让你能直接抄作业我给一个最小可运行的示例。项目结构如下src/ math/arc.ts // 几何算法三点定圆、方向判断 interaction/arcState.ts // 交互状态机 render/canvasRenderer.ts // Canvas渲染 main.ts // 入口绑定事件、组装模块依赖只有一个TypeScript编译器和浏览器环境不引入任何第三方图形库方便你理解原理后迁移到自己的渲染引擎上。5.2 几何算法模块arc.tsexport interface Point { x: number; y: number; } export interface ArcData { cx: number; cy: number; radius: number; startAngle: number; // 弧度 endAngle: number; // 弧度 ccw: boolean; // 数学坐标系下是否逆时针 } const EPSILON 1e-8; /** * 三点定圆并返回圆弧参数 * 输入的坐标已在调用前转换为数学坐标系y向上 */ export function arcFromThreePoints( p1: Point, p2: Point, p3: Point ): ArcData { const d 2 * (p1.x * (p2.y - p3.y) p2.x * (p3.y - p1.y) p3.x * (p1.y - p2.y)); if (Math.abs(d) EPSILON) { throw new Error(三点共线无法构成圆弧); } const p1Len p1.x * p1.x p1.y * p1.y; const p2Len p2.x * p2.x p2.y * p2.y; const p3Len p3.x * p3.x p3.y * p3.y; const cx (p1Len * (p2.y - p3.y) p2Len * (p3.y - p1.y) p3Len * (p1.y - p2.y)) / d; const cy (p1Len * (p3.x - p2.x) p2Len * (p1.x - p3.x) p3Len * (p2.x - p1.x)) / d; const radius Math.hypot(p1.x - cx, p1.y - cy); // 以P1为起点、P3为终点 let startAngle Math.atan2(p1.y - cy, p1.x - cx); let endAngle Math.atan2(p3.y - cy, p3.x - cx); // 叉积判断方向 const cross (p2.x - p1.x) * (p3.y - p2.y) - (p2.y - p1.y) * (p3.x - p2.x); const ccw cross 0; // 角度规范化按方向将endAngle调整到startAngle同一侧 if (ccw) { while (endAngle startAngle) { endAngle Math.PI * 2; } } else { while (endAngle startAngle) { endAngle - Math.PI * 2; } } return { cx, cy, radius, startAngle, endAngle, ccw }; }这里的核心逻辑就两个算圆心、规范化方向。后面所有功能都围绕这个函数展开。5.3 交互状态机arcState.tsimport { ArcData, Point, arcFromThreePoints } from ../math/arc; export type ArcStateName idle | pickFirst | pickSecond | pickThird; export class ArcInteraction { state: ArcStateName idle; points: Point[] []; previewArc: ArcData | null null; // 点击画布推进状态机 handleClick(pt: Point): ArcData | null { switch (this.state) { case idle: this.points [pt]; this.state pickFirst; return null; case pickFirst: this.points.push(pt); this.state pickSecond; return null; case pickSecond: this.points.push(pt); this.state pickThird; return this.tryGenerate(); case pickThird: this.points [pt]; this.state pickSecond; return null; } } // 鼠标移动更新预览 handleMove(pt: Point) { if (this.state idle) { this.previewArc null; return; } const tempPoints this.state pickFirst ? [this.points[0], pt] : this.state pickSecond ? [this.points[0], this.points[1], pt] : [this.points[0], this.points[1], pt]; if (tempPoints.length 3) { try { this.previewArc arcFromThreePoints( tempPoints[0], tempPoints[1], tempPoints[2] ); } catch { this.previewArc null; } } } // 生成最终圆弧 private tryGenerate(): ArcData | null { try { const arc arcFromThreePoints(this.points[0], this.points[1], this.points[2]); this.state idle; return arc; } catch { // 三点共线时自动回退到只有起点的状态让用户重新选第二点 this.points [this.points[0]]; this.state pickFirst; return null; } } // 取消当前操作 cancel() { this.state idle; this.points []; this.previewArc null; } }这里有一个设计细节当第三点与另外两点共线导致弧度生成失败时我选择回退到只剩起点的状态而不是清空所有状态。用户只需要重新选第二点和第三点不用从起点再来一遍体验好很多。5.4 渲染模块canvasRenderer.tsimport { ArcData } from ../math/arc; export function drawArc( ctx: CanvasRenderingContext2D, arc: ArcData, scale: number, offsetX: number, offsetY: number ) { const x arc.cx * scale offsetX; const y -arc.cy * scale offsetY; // 数学坐标转为屏幕坐标 const r arc.radius * scale; ctx.beginPath(); // 注意屏幕上y向下原先数学坐标的逆时针在Canvas中要传true // 这里根据实际坐标系映射关系直接用 !ccw 作为anticlockwise参数 // 因为数学坐标的逆时针弧在y翻转的屏幕坐标中视觉上也是逆时针 // 但Canvas的arc默认按角度递增方向绘制等效于视觉顺时针所以取反。 const anticlockwise !arc.ccw; ctx.arc(x, y, r, arc.startAngle, arc.endAngle, anticlockwise); ctx.stroke(); }关于anticlockwise !arc.ccw这行我多说一句由于屏幕坐标y向下数学坐标的逆时针方向绘制到Canvas上时角度变化方向恰好反了所以取反而得。这个结论依赖之前在数学层做过的坐标翻转和角度规范化缺一不可。5.5 main.ts串起整个流程import { ArcInteraction } from ./interaction/arcState; import { drawArc } from ./render/canvasRenderer; const canvas document.getElementById(canvas) as HTMLCanvasElement; const ctx canvas.getContext(2d)!; const interaction new ArcInteraction(); canvas.addEventListener(mousedown, (e) { const pt screenToWorld(e.clientX, e.clientY); const arc interaction.handleClick(pt); if (arc) { arcList.push(arc); } renderAll(); }); canvas.addEventListener(mousemove, (e) { const pt screenToWorld(e.clientX, e.clientY); interaction.handleMove(pt); renderAll(); }); document.addEventListener(keydown, (e) { if (e.key Escape) { interaction.cancel(); renderAll(); } }); function screenToWorld(clientX: number, clientY: number) { const rect canvas.getBoundingClientRect(); // 像素坐标转画布坐标再统一转成数学坐标y向上 return { x: (clientX - rect.left) / dpr, y: -(clientY - rect.top) / dpr, }; } function renderAll() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制已经存在的图元 for (const arc of arcList) { drawArc(ctx, arc, scale, tx, ty); } // 绘制预览弧 if (interaction.previewArc) { ctx.setLineDash([6, 4]); drawArc(ctx, interaction.previewArc, scale, tx, ty); ctx.setLineDash([]); } }screenToWorld里的dpr指的是设备像素比如果你的画布是CSS像素和物理像素1:1就不用管但正式项目里建议处理否则高分屏上坐标会偏移。6. 我踩过的那些“方向不对”的坑6.1 叉积符号被坐标系翻转首版代码里我拿到的鼠标坐标直接进入三点定圆逻辑算出来的cross符号一直是反的。表现就是用户以顺时针方向点了三个点画出来的弧却是反向的。这种问题特别隐蔽因为不是所有角度都会出错。当三点构成的弧跨越角度较小比如30度到60度时方向反了看起来也是一条看的过去的弧只有当用户画大弧或者跨象限时才会发现弧线绕了远路。排查方法是在调试面板里把每次点击的原始点、转换后的坐标、cross值、ccw标志全部打印出来手工画坐标轴验证。别指望在脑子里翻坐标轴画出来一看就明白了。最终我引入了统一的数学坐标系转换这个问题彻底消失。6.2 Canvas的anticlockwise参数和我以为的方向反了跟坐标系问题相关联的是Canvas的“真·逆时针”定义。我在初版用ctx.arc(x, y, r, start, end, arc.ccw)心想ccw为true就是逆时针结果在屏幕上看到弧的方向还是错的。原因是之前已经做了y轴翻转所以ccwtrue的弧在数学坐标系里是逆时针但到屏幕坐标后视觉方向已经变成顺时针而Canvas的anticlockwise参数是相对它自己内部的数学方向y向上解释的。因此我必须在渲染层再做一次反向。这类命名上的误导是最大的隐性成本建议大家在接口命名上带着坐标系前缀比如mathCCW、screenClockwise看名字就知道该怎么传。6.3 角度跨过0度导致的大弧/小弧判断失败另一个让我头疼的问题是角度跨越0度或±π边界时规范化逻辑一旦写错原本只想画30度的小弧结果系统给画了330度的大弧或者反过来。核心是对“终点角必须按方向调整到起点角同侧”这个原则理解不到位。我在5.2的代码里已经给出规范的写法逆时针时把endAngle循环加2π直到它大于startAngle顺时针时循环减2π直到它小于startAngle。这个循环次数在正常情况下不会超过1次但用while循环比写死一次加2π更安全可以避免某些极端角落比如起点是接近π、终点是接近-π的计算歧义。6.4 半径取平均还是取单点三点定圆后半径的标准算法是用任意点到圆心的距离。但实际用鼠标取点时三个点不可能精确落在同一个圆上因为鼠标有物理精度偏差。如果半径直接用P1到圆心的距离可能导致P2或P3离圆的实际距离有偏差视觉上弧线虽然在画但总感觉某些点不在弧线上。后来我改成用三个点到圆心距离的平均值作为统一半径再手动把三个点投影到这个半径的圆上视觉偏差明显减小尤其是在放大比例比较大的时候。6.5 用数据规律反向验证方向最后分享一个实用的自检方法生成圆弧后不要只盯着屏幕看方向对不对直接把起点角度、终点角度、ccw和角度差打出来。合法的数据应该满足ccw为true时角度差为正终点角大于起点角且角度差的绝对值是实际弧线的扫过角度ccw为false时角度差为负。如果发现角度差为0说明起点终点重合或者正负方向与ccw不一致基本可以确定数学层有问题。这个方法在我接入不同输入方式圆心-起点-角度、起点-终点-半径等时能快速筛掉九成以上的方向错误。上面这些坑有些是坐标系造成的有些是API语义误解造成的但归根结底都是因为圆弧是一个“有向图形”比直线多了一个方向维度。我在给团队做技术分享时说过一句话Web端绘圆弧90%的工作量都在处理和“方向”有关的事情上真正的绘制调用往往只有一行。理解了这一点再回看AutoCAD里那些看似理所当然的交互你会发现在Web端复刻它们是一件比想象中更有挑战、也更有意思的事情。