做CAD插件的时候遇到一个特别基础的问题:用户圈了一段圆弧,程序要自动捕捉这段弧的中点坐标。我一开始以为这题白送,结果第一版代码就被测试打回来了——用户给过来的弧是优弧,我的“中点”落在了劣弧上。后来真正把圆上任意一段弧的中点算明白了,才发现这里面的门道比想象中多。
如果你也是写图形程序、做机器人圆弧轨迹规划,或者还在上学正在被解析几何折磨,这篇内容应该能帮你省下不少调试时间。这里不写教科书式的理论推导,只讲我实际开发中验证过的几种算法、它们各自的坑,以及不同场景下到底该选哪一套。
1. 先把问题定义清楚:弧中点到底是什么
1.1 几何定义:弧的中点就是“等分圆心角”
先说一个最容易踩的坑:很多人拿到题目,第一反应是把弧的两个端点坐标做算术平均,觉得这样就算出中点了。这个思路对线段成立,但对圆弧完全不成立。
为什么?因为圆弧是弯曲的,它在平面上的坐标分布并不均匀。圆上的点由圆心角唯一决定,一段弧的弧长s和它对应的圆心角Δθ满足s = r·Δθ这个关系。也就是说,弧上的点随角度线性变化,而不是随坐标线性变化。所谓“弧的中点”,本质上是把这段弧对应的圆心角等分成两份,中点在角平分线上,而不是在两端点连线的中点。
举个例子就很直观:四分之一圆上取A(1,0)、B(0,1),这段弧在第一象限。如果直接坐标平均,得到(0.5,0.5),这个点显然不在圆x²+y²=1上。真正的弧中点对应的角度是45°,坐标是(√2/2, √2/2),约等于(0.7071, 0.7071)。两个点的误差接近30%,在图形程序里这种误差会直接导致标注错位、刀轨偏出公差。
所以,弧中点的严格定义是:在该弧上,满足两端点到该点的弧长相等、且该点对应的圆心角为该弧圆心角一半的那个点。写程序时,所有算法都必须围绕圆心角来做文章,绕开这一步必然会出问题。
1.2 这题背后真正要解决的是什么
从纯数学角度,这个题目很单纯:给一个圆,给一段弧,求中点。但放到实际工程里,“给出弧”的方式千奇百怪,这才是问题真正复杂的地方。
我在实际开发中遇到的输入形式主要有三种:第一种最常见,用户直接给圆心坐标、半径、弧的起始角度和终止角度,这是CAD软件内部最自然的存储方式;第二种是给圆心坐标、半径、弧上两个端点的坐标,并不直接提供角度;第三种最麻烦,可能还带着一个方向标志——顺时针还是逆时针,甚至弧本身是优弧还是劣弧都不说明。
不同输入形式决定了该用哪套算法。比如输入是端点坐标时,如果你强行先转换成角度再用角度平均,就会引入atan2和坐标转换这一步,边界情况随之而来;反过来如果你用向量法,根本不需要把角度算出来就能直接拿到方向,代码会简洁很多。这也是为什么我强烈建议你先把输入形式想清楚,再选方案。
另外还要区分一个概念:纯几何的“劣弧中点”和工程里“指定弧段的中点”。前者只有一种答案,后者取决于你选的弧段是大于半圆的那半还是小于半圆的那半。同一个圆上,从A到B的劣弧中点和优弧中点是两个点,它们关于圆心对称。这个问题我在第4节专门展开。
2. 三种主流算法,从原理到代码
2.1 角度取平均:参数方程法
这是最直接、最符合几何直觉的做法。既然弧长与圆心角线性相关,弧中点对应的角度就是起始角和终止角的算术平均。
已知圆心O(x0, y0)、半径r、起始角α、终止角β,圆上任意角度θ的坐标由圆的参数方程给出: x = x0 + r·cosθ y = y0 + r·sinθ
那么弧中点坐标就是: θ_mid = (α + β) / 2 midX = x0 + r·cos(θ_mid) midY = y0 + r·sin(θ_mid)
看上去简单到不需要解释,但我实际写代码时发现一个隐藏问题:如果α和β跨过0°边界(比如从350°到10°),直接取平均得到180°,而实际的中点应该是在0°附近。这个坑我第4节会专门讲,典型解法是把角度先归一化再取平均,或者用向量相加的方式来规避。
这类算法的定位非常明确:适用于你已经明确知道起止角度、且能保证角度顺序的场景。比如数控机床的圆弧插补指令,通常直接给出起止点对应的角度,用这个方法最直接。优点是计算量极小,一次cos、一次sin就完事;缺点是把角度边界问题甩给了你自己,你需要额外处理角度跨越和优弧劣弧判断,否则会静默出错——不是崩溃,而是给你一个看似合理但完全错误的坐标。
2.2 向量法:不需要算角度,绕开所有边界问题
我在实际项目里最终长期用的是这一套,说实话它比角度平均更“结实”,也更优雅。
已知圆心O和两个端点A、B,思路是:从圆心指向端点的两个向量,方向就是弧的两个端点方向。我们想找的是角平分线方向,而两个单位向量相加的结果恰好指向角平分线方向——这是向量加法最基本的平行四边形法则。
具体步骤如下:
- 求向量OA = A - O,向量OB = B - O
- 分别归一化,得到单位向量u_a = OA / |OA|,u_b = OB / |OB|
- 求和:u_sum = u_a + u_b
- 把u_sum归一化,得到单位方向向量u_mid
- 弧中点 = O + r · u_mid
为什么这个解法能成立?因为u_a和u_b是单位向量,长度都是1,它们的和向量的方向正好位于两个原向量的角平分线方向。这就像两个人从同一起点出发,各走1米后连线的方向正好位于两人方向的中间。无论夹角是30°还是170°,这个结论都成立,而且它根本不需要你调用atan2,自然也就没有角度跨零、负角转换这类麻烦。
但这个方法有一个致命边界:当A、B恰好在直径两端时,u_a和u_b方向相反,相加得到零向量,无法归一化。这种情况对应半圆,弧中点有两个(上半圆弧和下半圆弧的中点),需要额外信息才能确定选哪个。所以我在代码里通常会判断u_sum的模长,如果接近0就回退到角度法并提示输入不完整。
2.3 垂直平分线法:解析几何思路,适合手算和教学
前两种都是偏“程序思维”的做法,这一套更接近高中解析几何的思路,适合手算时用。
考虑弧中点C,它有个天然性质:C到弧的两个端点A、B的距离相等。为什么?因为C是弧的中点,弧AC和弧CB相等(都等于整段弧的一半),等弧对等弦,所以弦AC和CB长度相等。这说明C在AB的垂直平分线上。
于是解法变成:
- 写出AB垂直平分线方程。设A(x1, y1),B(x2, y2),中点为M((x1+x2)/2, (y1+y2)/2),垂直平分线方向向量是(A-B)的法向量,方程为(x - Mx)(x2-x1) + (y - My)(y2-y1) = 0
- 联立这个直线方程与圆的方程(x-x0)² + (y-y0)² = r²
- 解出两个交点,这两个点分别是整圆被AB分成的两条弧(劣弧和优弧)的中点
- 根据你需要的是哪一段弧,选出正确的一个点
第4步的判断需要额外的逻辑:如果你要的是劣弧中点,就选圆心和这个点连线的方向与OA、OB的夹角都在90°以内的那个交点;如果要优弧中点就选另一个。实际判断时用点积即可:设p为候选交点,若向量OP与OA的点积以及OP与OB的点积都大于0,说明OP方向位于OA和OB的夹角范围(劣弧一侧)内,它就是劣弧中点。
这个方法在工程代码里不常用,因为要解二次方程,涉及求根和判断分支,效率低,而且同样有优弧劣弧的判断问题。但如果你是拿纸笔在算题,这个方法最见功夫,也能加深对“等弧对等弦”这一几何性质的理解。
3. 从公式到程序:完整实现与验证
3.1 Python实现:两个核心函数
我实际开发时会把两个方案都封装成函数,方便不同输入形式直接调用。先给Python版本。
第一个函数处理“已知起止角度”的情形:
import math def arc_midpoint_by_angles(cx, cy, r, start_angle, end_angle): """ 参数方程法:已知圆心、半径、弧起止角(弧度制) 计算弧中点坐标 """ mid_angle = (start_angle + end_angle) / 2.0 mid_x = cx + r * math.cos(mid_angle) mid_y = cy + r * math.sin(mid_angle) return (mid_x, mid_y)这段代码逻辑是最短的,但如果直接放进实际项目里会在角度跨零时出错,所以更稳的写法得先把两个角度调整到合适范围。比如:
def arc_midpoint_by_angles_safe(cx, cy, r, start_angle, end_angle): # 确保 end_angle 在 [start_angle, start_angle + 2pi) 范围内 delta = end_angle - start_angle delta = math.fmod(delta, 2 * math.pi) if delta < 0: delta += 2 * math.pi mid_angle = start_angle + delta / 2.0 mid_x = cx + r * math.cos(mid_angle) mid_y = cy + r * math.sin(mid_angle) return (mid_x, mid_y)这里的思路是:不直接对两个角度取算术平均,而是先算出从start到end的有向角度差delta(归一到[0, 2π)区间),然后从起点角度往前走一半delta。这样即使start=6.11弧度(约350°)、end=0.17弧度(约10°),delta归一化后是0.35弧度(约20°),中点就是start+0.17弧度,得到的坐标正好指向0°方向附近,而不是错到π附近。这个写法处理跨零问题非常干净。
第二个函数处理“已知端点坐标”的情形,用向量法:
def arc_midpoint_by_points(cx, cy, r, ax, ay, bx, by): """ 向量法:已知圆心、半径、弧的两个端点坐标 返回劣弧中点(若为半圆则返回 None) """ # 从圆心指向两端点的向量 ox_a = ax - cx oy_a = ay - cy ox_b = bx - cx oy_b = by - cy # 归一化 len_a = math.hypot(ox_a, oy_a) len_b = math.hypot(ox_b, oy_b) if len_a == 0 or len_b == 0: raise ValueError("端点不能与圆心重合") ua_x = ox_a / len_a ua_y = oy_a / len_a ub_x = ox_b / len_b ub_y = oy_b / len_b # 单位向量相加 -> 角平分线方向 sum_x = ua_x + ub_x sum_y = ua_y + ub_y len_sum = math.hypot(sum_x, sum_y) # 半圆情况:向量相反,和为零向量 if len_sum < 1e-12: return None mid_x = cx + r * (sum_x / len_sum) mid_y = cy + r * (sum_y / len_sum) return (mid_x, mid_y)这个函数返回的是劣弧中点。你可能注意到,我并没有显式地传“这段弧是哪个弧”,默认处理的是两点之间小于半圆的那段。如果需要求优弧中点,只需要把结果坐标关于圆心对称一下即可:优弧中点 = (2cx - mid_x, 2cy - mid_y)。这就是同一个方程两个根的几何意义。
3.2 JavaScript实现:前端和游戏开发版本
Web端和游戏开发里经常遇到同样的需求,我也提供JavaScript版本。和Python版本思路完全一致,只是语法不同。
function arcMidpointByAngles(cx, cy, r, startAngle, endAngle) { // 角度差归一到 [0, 2PI) let delta = endAngle - startAngle; delta = delta % (2 * Math.PI); if (delta < 0) delta += 2 * Math.PI; const midAngle = startAngle + delta / 2; return { x: cx + r * Math.cos(midAngle), y: cy + r * Math.sin(midAngle) }; } function arcMidpointByPoints(cx, cy, r, ax, ay, bx, by) { let uax = (ax - cx) / r, uay = (ay - cy) / r; let ubx = (bx - cx) / r, uby = (by - cy) / r; let sx = uax + ubx, sy = uay + uby; let len = Math.hypot(sx, sy); if (len < 1e-12) return null; return { x: cx + r * (sx / len), y: cy + r * (sy / len) }; }在使用向量法时,如果你能确保A、B两点确实都在圆上(到圆心距离等于r),那么第一版的归一化可以直接除以r,省去hypot计算。但实际工程中因为浮点数误差,端点未必严格在圆上,所以稳妥起见还是算出实际长度再归一化,动一次hypot的成本几乎可以忽略。
有个容易忽略的细节:屏幕坐标系里y轴是向下的,所以angle从0到π/2描出的弧不是“左上”而是“左下”。这在数学坐标系里没问题,但如果你把数学坐标系的角度直接塞给Canvas API,会得到镜像的弧。解决方法是明确你的坐标系约定:Canvas中角度方向反转,绘制时需要对y取反或对角度取负。代码本身不关心坐标系,但验证取点时一定要拿着坐标在画布上打点检查,否则方向反了你都发现不了。
3.3 怎么验证算出来的点真的是弧中点
我在开发中吃过亏,所以每次实现完算法都会做一个验证步骤。用回第一象限四分之一圆的例子:
# 圆心(0,0),半径1,A(1,0),B(0,1) mid = arc_midpoint_by_points(0, 0, 1, 1, 0, 0, 1) print(mid) # (0.7071067811865476, 0.7071067811865475)预期结果是(√2/2, √2/2),计算值和预期一致。如果你算出来的点和预期不符,先检查角度的弧度/度数有没有混用。弧度制下180°是π≈3.14159,cos(90°)应该传math.pi/2而不是90——这个错误我见过太多人犯,包括当年的我自己。
还有一个自检公式:弧中点C到两端点A、B的距离应当相等,而且这个距离等于2r·sin(Δθ/4)。你可以把它写进断言里,作为自动化测试的一部分。如果验证失败,说明算法或输入数据有问题,早点暴露比上线后炸要好得多。
4. 常见问题与排查技巧实录
4.1 优弧和劣弧:同一个角度公式的两副面孔
这个问题值得重点说。题目里写“圆上某一段弧”,往往没有告诉你这段弧是优弧还是劣弧。而你用角度平均法算出来的那个中点,数学上默认是劣弧的中点。
看个具体例子。圆心(0,0),半径1,A(1,0),B(0,1)。这两点之间的圆弧有两段:第一象限的90°劣弧中点是(0.707, 0.707);剩下那段270°优弧中点是(-0.707, -0.707),正好在圆心的另一侧。
优弧中点的求法很简单:把劣弧中点坐标关于圆心对称。数学上就是加π: θ_优 = θ_劣 + π
因为优弧对应圆心角大于π,它的中点必然位于劣弧中点的对径位置。这个关系在向量法里同样成立:得到劣弧中点向量v后,优弧中点向量就是-v。
实际工程里最稳妥的方式:不要把“优弧/劣弧”作为隐含假设,一定要从输入数据结构里找方向信息。大多数图形库(比如SVG的arc指令、CAD的Arc对象)都会明确告诉你是顺时针还是逆时针、是大弧还是小弧。把方向标志传给你的函数,再配合一个参数选优弧还是劣弧,才能保证不会出错。
4.2 半圆情况:向量法直接失效
圆上取两点,把这两点连起来通过圆心,意味着这段弧正好是半圆。这时候A和B是直径的两个端点,单位向量u_a和u_b互为相反数,相加等于零向量,向量法没法算出角平分线方向。这个在数学上也说得通——直径端点的角平分线有两条,恰好对应上半圆和下半圆,不附带额外条件根本没法选。
我的实际处理策略是:在向量法里检测到模长小于某个阈值(比如1e-12)时,返回一个特殊标志,让上层逻辑去决定取哪个点。如果需要自动选择,可以约定取“逆时针方向从A到B经过的那半弧的中点”,做法是计算有向角度并把弧中点定为旋转90°方向上的点——从OA方向逆时针转90°或顺时针转90°,根据弧的方向选一个。这个约定在CAD里通常是默认的,但在通用程序里最好显式暴露给调用者。
4.3 角度跨零:为什么平均180°是错的
前面提到过,角度平均法最大的坑是跨零边界。再具体一点:假设圆心(0,0),半径1,起始角为350°(6.11弧度),终止角为10°(0.17弧度),这段弧只有20°,跨度非常小。如果直接平均: (6.11 + 0.17) / 2 = 3.14(约180°)
这个结果指向了圆上完全相反的位置。正确的中点角度应该是0°方向。这就是为什么我在3.1节提供的安全版函数不直接做平均,而是先计算有向角度差并归一化。数学上,你永远应该问自己:“描述这段弧的有向角度差是多少?”而不是“两个角度数的平均值是多少?”——Angular Difference和Arithmetic Mean是两回事。
顺手提醒一个和atan2相关的坑:atan2(y, x)返回值的范围是(-π, π],当你从atan2得到-2.94(约-168°),又用atan2得到1.74(约100°),你可能会以为这两角差268°,但实际上差值是92°。要正确计算角度差,应该让delta = atan2(sin(β-α), cos(β-α)),这能天然保证结果落在(-π, π],彻底避开跨±π边界的烦恼。
4.4 浮点数精度:能不能用,怎么验证
图形程序里有个常见的初级错误:端点坐标是经过多少次旋转、平移之后得到的,实际不在理想的圆上。比如A点到圆心的距离是0.9999999,B点是1.0000001,用半径r直接算归一化向量时,方向会带一点误差,但通常不影响最终结果。真正要注意的是比较坐标时不能用精确相等,要用容差。
我在测试代码里用的比较方式是这样的:
def nearly_equal(a, b, eps=1e-9): return abs(a - b) < eps另外,向量加法在这种场景下的数值稳定性优于三角函数法。原因很简单:三角函数在参数接近0或π时可能出现较大舍入误差,而向量加法和归一化主要用到乘法和开方,没有周期性边界,误差更可控。尤其是在嵌入式设备或者游戏引擎中,内存和CPU有限,能少调一次sin/cos就少调一次。如果数据是单精度浮点数,误差会更大,判断半圆和端点重合时阈值需要适当放宽,比如1e-7。
5. 三种算法横向对比与选型建议
5.1 一张表看穿三种方法的适用边界
实现过一轮之后,我把三种算法的适用条件、优缺点整理成了一张表,在团队内部讨论方案时会直接拿出来对照:
| 算法 | 适用输入 | 核心操作 | 边界风险 | 程序健壮性 | 手算友好度 |
|---|---|---|---|---|---|
| 参数方程法 | 圆心+半径+起止角 | 角度平均+cos/sin | 角度跨零、优弧劣弧 | 需要额外防护 | 中等 |
| 向量法 | 圆心+半径+端点坐标 | 归一化+向量相加 | 半圆失效 | 高,天然避开角度边界 | 较难 |
| 垂直平分线法 | 圆心+半径+端点坐标 | 解直线与圆的方程 | 要先判断弧度 | 一般,需解二次方程 | 最直观 |
参数方程法的优势是代码最短、执行最快,但防御逻辑都写在外围;向量法代码稍微多两行,却把最难缠的角度边界问题直接消灭掉了;解析法手算最友好,编程最不友好——因为要解二次方程并筛选根,麻烦且意义不大。
5.2 实际项目里我到底怎么选
做CAD插件时,我拿到的是端点坐标和半径,最自然的选择是向量法。做数控机床的圆弧插补解析器时,指令格式里明确有起止角度,那就用安全版参数方程法,配合方向标志决定取优弧还是劣弧。做Web前端图表的时候,数据源通常给的是三个点坐标(圆心、起始点、终止点),我依然用向量法,因为它在代码库里结构最统一,出错概率最低。
学生解题或者面试手撕这类算法的时候,推荐优先掌握垂直平分线法,因为最能体现你对几何本质的理解:弧中点到两端点距离相等、在垂直平分线上。答题的时候把参数方程法作为验证手段,两者结合基本不会失分。
还有个工程细节:如果API需要返回多个候选值(比如同时返回劣弧中点和优弧中点),我建议接口设计成返回两个坐标或返回一个坐标加一个标志位,而不是隐含默认选劣弧。很多问题都是“差一个flag”的事,但就是差了这个flag,后续接手的同事才会在一堆看似莫名的bug里挣扎。
最后分享一个我自己的体会:最早实现这个功能时,我用的是角度平均法,结果总被各种角度跨零、方向反转的问题折腾。后来重构成向量法,代码短了三分之一,边界问题几乎全部消失。现在如果有人来问我,我会说:只要输入给的是点位坐标,优先用向量法;只有在输入本身就明确给了角度、且你能保证角度范围约定的前提下,才直接用参数方程法。如果你未来要处理复杂的弧段解析,比如CAD中的带方向圆弧、路径规划中的多弧段拼接,也建议从向量法的思路出发去扩展——它才是那个真正抗折腾的骨架。