上一次被这类看起来“简单”的几何问题折磨,是在写一个配送范围重叠判断。背景是外卖平台要算出两个商家配送圈的重合区域有多大,用来评估客流分流风险。当时第一反应是“两个圆重叠还不好算”,真动手推公式才发现,要从两圆交点坐标一步步算到交叉区域的精确面积,中间全是细节:相切怎么办、包含怎么办、浮点误差怎么办、角度象限对不对。
后来在游戏碰撞检测、地理围栏、信号覆盖分析里又连续遇到同一个问题,这才意识到:两个重叠的圆不是一道初中平面几何题,而是一整套工程计算套路。
这篇文章打算把这个套路完整拆开:从最基础的圆与圆位置关系开始,推导交点坐标和重叠面积的数学公式,再给出可直接复制的 Python、JavaScript 实现,最后补充边界条件、数值验证和常见坑。适合正在做 2D 游戏、地图服务、信号覆盖分析或者 Canvas 可视化的同学,收藏下来可以直接套用。
1. 两个重叠的圆,到底要算什么
“两个重叠的圆”在工程里很少只是一个静态图形,更多时候是三类计算需求:
第一类是交点坐标计算。比如在 Canvas 上画两个相交圆,要标出它们的切点;或者拖拽一个圆时实时更新交点的位置。这类需求需要的是解析解——给定两个圆心坐标和半径,直接算出来两个交点落在哪里。
第二类是重叠面积计算。这是更硬核的需求。地理围栏里要算两个圆形区域的交集多大,决定是否触发合并告警;无线覆盖里要评估两个信号塔覆盖范围的冗余程度;图像处理里可能需要计算两个圆形 ROI 的交集。这类需求需要的是精确面积,而不是“看着大概重叠了五分之一”。
第三类是碰撞检测和关系判定。两个圆是相交、相切、包含还是外离,这组判断是游戏碰撞检测和物理引擎的地基。它不涉及复杂的公式,但需要写对边界条件。
这三种计算,本质上都从同一个源问题出发:给定圆心 O1(x1, y1)、O2(x2, y2) 和半径 r1、r2,如何完整描述这两个圆的几何关系?
很多资料会直接给出结论公式,却没有解释公式推导和使用条件。结果就是新手抄完公式,遇到“小圆完全在大圆内部”这种边界情况,得出的面积直接变成负数。这篇文章会把推导逻辑讲清楚,并给出健壮的代码实现。
2. 圆与圆位置关系:所有计算的判断前提
2.1 圆心距是一切判断的起点
设两圆圆心分别为 O1 和 O2,圆心距记为:
d = sqrt((x2 - x1)^2 + (y2 - y1)^2)不管后续要计算什么,第一步都是先算出这个 d。它是全部判断的基础。
两个圆的平面位置关系有五种,由 d 与 r1、r2 的大小关系决定:
| 位置关系 | 判断条件 | 交点数量 | 重叠面积 |
|---|---|---|---|
| 外离 | d > r1 + r2 | 0 | 0 |
| 外切 | d = r1 + r2 | 1 | 0 |
| 相交 | r1 - r2 | < d < r1 + r2 | |
| 内切 | d = | r1 - r2 | 且 d ≠ 0 |
| 内含 | d < | r1 - r2 | |
| 同心 | d = 0 | 0 | 小圆面积 |
这里要注意两个边界:
- 同心但半径不同时,小圆在大圆内部,重叠面积是小圆面积。
- 同心且半径相等时,两圆完全重合,重叠面积理论上是大圆面积,但在工程计算中这种精确重合通常视为异常或按全等处理。
2.2 一个容易忽略的细节:浮点相等判断
代码里不能直接写d == r1 + r2,因为浮点数计算有精度误差。实际工程中建议使用误差阈值:
def is_tangent(d, r1, r2, eps=1e-9): return abs(d - (r1 + r2)) < eps or abs(d - abs(r1 - r2)) < eps这个细节在后续代码里会反复出现。
3. 两圆交点坐标的数学推导
这是整个计算的核心。很多资料直接给公式,但没有说明公式怎么来的,导致读者不理解什么情况下公式会失效。这里我们完整推导一遍。
3.1 问题描述
已知两个圆:
圆1:方程 (x - x1)^2 + (y - y1)^2 = r1^2 圆2:方程 (x - x2)^2 + (y - y2)^2 = r2^2要求这两个方程组成的方程组的解,即两个交点的坐标。
3.2 第一步:把“求交点”转换成“求位置”
观察两圆相交时的几何结构。设两个交点分别为 I1、I2,线段 I1I2 是两圆的公共弦。这个公共弦与圆心连线 O1O2 互相垂直,且交点 P 是公共弦的中点。
这个结构相当有用,它把二维的交点求解放到了一条直线上:
- 先求出 P 在 O1O2 连线上距离 O1 多远,记为 a。
- 再求出从 P 到 I1(或 I2)的垂直距离 h。
- 最后在 P 点处沿垂直方向平移 h,得到两个交点。
3.3 第二步:求 a
在三角形 O1-O2-I1 中,三条边分别是:
- O1I1 = r1
- O2I1 = r2
- O1O2 = d
P 是 I1I2 与 O1O2 的垂足,所以三角形 O1-P-I1 是直角三角形。设 O1P = a,则 O2P = d - a。
由勾股定理:
r1^2 - a^2 = r2^2 - (d - a)^2展开整理:
r1^2 - a^2 = r2^2 - (d^2 - 2da + a^2) r1^2 - a^2 = r2^2 - d^2 + 2da - a^2 r1^2 = r2^2 - d^2 + 2da a = (r1^2 - r2^2 + d^2) / (2d)这就是计算 a 的标准公式。
3.4 第三步:求 h
在直角三角形 O1-P-I1 中,斜边是 r1,一条直角边是 a,另一条直角边是 h。所以:
h = sqrt(r1^2 - a^2)当 r1^2 - a^2 < 0 时,说明两圆不相交,这个公式在实数范围内无解。
3.5 第四步:从向量方向求交点坐标
现在有了:
- 单位向量 u,方向从 O1 指向 O2:u = ((x2 - x1)/d, (y2 - y1)/d)
- P 点坐标:P = O1 + a * u
- 垂直单位向量 v = (-u.y, u.x)
两个交点分别为:
I1 = P + h * v I2 = P - h * v写成完整坐标形式:
I1.x = x1 + a * (x2 - x1) / d - h * (y2 - y1) / d I1.y = y1 + a * (y2 - y1) / d + h * (x2 - x1) / d I2.x = x1 + a * (x2 - x1) / d + h * (y2 - y1) / d I2.y = y1 + a * (y2 - y1) / d - h * (x2 - x1) / d用向量方式实现不容易出错,比直接套坐标公式更清晰,也更便于理解。
4. 两圆重叠面积的推导
重叠面积是最容易写错的部分。网上常见公式版本很多,如果不理解推导,边界条件写错了很难排查。
4.1 弓形分解法
在两圆相交的情况下,公共区域可以被分解为两个“弓形”之和:
- 圆1被公共弦切出的弓形(弦对应的弓形区域)
- 圆2被公共弦切出的弓形
每个弓形的面积 = 对应扇形面积 - 对应三角形面积。
设圆1中公共弦所对的圆心角为 2α,圆2中为 2β。从前面推导可知:
- 在圆1中,α 满足 cos(α) = a / r1,因此:
α = acos((d^2 + r1^2 - r2^2) / (2 * d * r1))- 在圆2中,同理:
β = acos((d^2 + r2^2 - r1^2) / (2 * d * r2))4.2 弓形面积公式
圆1的弓形面积:
弓形1 = (1/2) * r1^2 * 2α - (1/2) * r1^2 * sin(2α) = r1^2 * (α - sinα * cosα)同理,圆2的弓形面积:
弓形2 = r2^2 * (β - sinβ * cosβ)因此重叠面积:
S = r1^2 * (α - sinα * cosα) + r2^2 * (β - sinβ * cosβ)这就是不用开根号的重叠面积公式。它只依赖 α、β 两个角,实现起来更简单。
4.3 教科书版本的等价公式
更常见的表示方式是用海伦公式处理后面那个三角形面积项:
S = r1^2 * acos((d^2 + r1^2 - r2^2)/(2*d*r1)) + r2^2 * acos((d^2 + r2^2 - r1^2)/(2*d*r2)) - 0.5 * sqrt((-d+r1+r2) * (d+r1-r2) * (d-r1+r2) * (d+r1+r2))这个公式与弓形公式等价,但多了一个带负号的根号项,更容易因为浮点误差出现负面积。如果只是实现功能,推荐使用弓形公式。
4.4 边界条件不能丢
写代码时必须在主公式前处理这些情况:
d >= r1 + r2:不重叠,面积 0。d <= abs(r1 - r2):小圆被大圆完全包含,面积为 min(πr1^2, πr2^2)。d == 0且半径不同:面积同样是较小圆的面积。- 半径相同且
d == 0:完全重合,视为异常或返回一个大圆面积。
5. 完整代码实现:Python 与前端方案
5.1 Python 版本:交点计算
下面的代码实现了两圆交点计算,并处理了相切、无交点和包含等边界情况。
# 文件路径:circle_intersection.py import math def circle_intersection_points(x1, y1, r1, x2, y2, r2): """ 计算两个圆的交点坐标。 参数: x1, y1, r1: 圆1的圆心横坐标、纵坐标、半径 x2, y2, r2: 圆2的圆心横坐标、纵坐标、半径 返回值: 交点列表 [(x, y), ...],包含 0-2 个点。 """ dx = x2 - x1 dy = y2 - y1 d = math.hypot(dx, dy) # 同心圆,无交点 if d < 1e-12: return [] # 外离,无交点 if d > r1 + r2 + 1e-12: return [] # 内含,无交点 if d < abs(r1 - r2) - 1e-12: return [] # 相切情况,只有一个交点 # 外切 if abs(d - (r1 + r2)) < 1e-9: t = r1 / d return [(x1 + dx * t, y1 + dy * t)] # 内切 if abs(d - abs(r1 - r2)) < 1e-9: if r1 > r2: t = r1 / d return [(x1 + dx * t, y1 + dy * t)] else: t = 1 - r2 / d return [(x1 + dx * t, y1 + dy * t)] # 标准的两交点情况 a = (r1 * r1 - r2 * r2 + d * d) / (2 * d) h2 = r1 * r1 - a * a h = math.sqrt(max(h2, 0.0)) # 中点 P px = x1 + a * dx / d py = y1 + a * dy / d # 垂直单位向量 vx = -dy / d vy = dx / d i1 = (px + h * vx, py + h * vy) i2 = (px - h * vx, py - h * vy) return [i1, i2]这段代码的关键点是:
- 使用
math.hypot计算距离,避免dx * dx + dy * dy在极端数值下溢出。 - 把“外切”和“内切”单独处理,防止后续
h计算出现接近 0 的值导致精度不稳定。 - 用
max(h2, 0.0)防止浮点误差导致根号下出现微小的负数。
5.2 Python 版本:重叠面积计算
# 文件路径:circle_overlap.py import math def circle_overlap_area(x1, y1, r1, x2, y2, r2): """ 计算两个圆的重叠面积。 参数: x1, y1, r1: 圆1的圆心横坐标、纵坐标、半径 x2, y2, r2: 圆2的圆心横坐标、纵坐标、半径 返回值: 重叠面积,浮点数。 """ d = math.hypot(x2 - x1, y2 - y1) # 不重叠 if d >= r1 + r2: return 0.0 # 含或被含 if d <= abs(r1 - r2): return math.pi * min(r1, r2) ** 2 # 同心且半径不等(d 已经等于 0 时) if d < 1e-12: return math.pi * min(r1, r2) ** 2 # 半角公式 alpha = math.acos((d * d + r1 * r1 - r2 * r2) / (2 * d * r1)) beta = math.acos((d * d + r2 * r2 - r1 * r1) / (2 * d * r2)) area = ( r1 * r1 * (alpha - math.sin(alpha) * math.cos(alpha)) + r2 * r2 * (beta - math.sin(beta) * math.cos(beta)) ) return area5.3 JavaScript 版本:Canvas 实时可视化
前端场景同样常用。这里给出一个可直接运行的 HTML 页面,展示两个重叠圆的绘制和实时面积显示。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Two Overlapping Circles</title> <style> body { font-family: monospace; margin: 40px; background: #fafafa; } #cv { border: 1px solid #ddd; background: #fff; margin-bottom: 16px; } </style> </head> <body> <canvas id="cv" width="600" height="400"></canvas> <div id="info"></div> <script> const canvas = document.getElementById('cv'); const ctx = canvas.getContext('2d'); const info = document.getElementById('info'); // 参数:圆A和圆B const c1 = { x: 240, y: 200, r: 120 }; const c2 = { x: 360, y: 200, r: 100 }; function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 先画圆1 ctx.beginPath(); ctx.arc(c1.x, c1.y, c1.r, 0, Math.PI * 2); ctx.fillStyle = 'rgba(66, 133, 244, 0.25)'; ctx.fill(); ctx.strokeStyle = '#4285f4'; ctx.lineWidth = 2; ctx.stroke(); // 再画圆2 ctx.beginPath(); ctx.arc(c2.x, c2.y, c2.r, 0, Math.PI * 2); ctx.fillStyle = 'rgba(234, 67, 53, 0.25)'; ctx.fill(); ctx.strokeStyle = '#ea4335'; ctx.lineWidth = 2; ctx.stroke(); // 计算交点和面积 const area = overlapArea(c1, c2); const points = intersectionPoints(c1, c2); // 画交点 points.forEach(p => { ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fillStyle = '#222'; ctx.fill(); }); info.textContent = '重叠面积: ' + area.toFixed(4) + ' px², 交点数量: ' + points.length; } // JS 版本:交点计算 function intersectionPoints(a, b) { const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy); if (d < 1e-12 || d > a.r + b.r + 1e-12 || d < Math.abs(a.r - b.r) - 1e-12) { return []; } if (Math.abs(d - (a.r + b.r)) < 1e-9) { const t = a.r / d; return [{ x: a.x + dx * t, y: a.y + dy * t }]; } const t = (a.r * a.r - b.r * b.r + d * d) / (2 * d); const h = Math.sqrt(Math.max(a.r * a.r - t * t, 0)); const px = a.x + t * dx / d; const py = a.y + t * dy / d; const vx = -dy / d; const vy = dx / d; return [ { x: px + h * vx, y: py + h * vy }, { x: px - h * vx, y: py - h * vy } ]; } // JS 版本:重叠面积 function overlapArea(a, b) { const d = Math.hypot(b.x - a.x, b.y - a.y); if (d >= a.r + b.r) return 0; if (d <= Math.abs(a.r - b.r)) return Math.PI * Math.min(a.r, b.r) ** 2; const alpha = Math.acos((d * d + a.r * a.r - b.r * b.r) / (2 * d * a.r)); const beta = Math.acos((d * d + b.r * b.r - a.r * a.r) / (2 * d * b.r)); return a.r * a.r * (alpha - Math.sin(alpha) * Math.cos(alpha)) + b.r * b.r * (beta - Math.sin(beta) * Math.cos(beta)); } // 初始绘制 draw(); </script> </body> </html>这个页面有两个值得注意的点:
- 面积计算在浏览器里直接实时更新,适合用来理解位置关系的变化。
- 如果以后要拖拽圆,只需要在
mousemove事件里更新c1或c2的位置再调用draw()。
6. 运行结果与数值验证
6.1 用已知几何关系验证代码
写数学相关代码,最重要的一件事是用已知结果验证。下面是三个标准测试用例。
测试用例1:等半径外离。
# r1 = 5, r2 = 5, d = 12,外离 area = circle_overlap_area(0, 0, 5, 12, 0, 5) print(area) # 期望 0.0测试用例2:小圆完全在大圆内部。
# r1 = 5, r2 = 2, d = 1,内含 area = circle_overlap_area(0, 0, 5, 1, 0, 2) print(area) # 期望 12.566370614359172(π * 4)测试用例3:两圆正交相交,r1 = r2 = 5,d = 6。
# 手工推导结果:22.365... area = circle_overlap_area(0, 0, 5, 6, 0, 5) print(area) # 期望约 22.365813第三个用例我们可以手工验证:
α = acos(0.6) ≈ 0.927295 sinα = 0.8,cosα = 0.6 弓形面积 = 25 * (0.927295 - 0.48) ≈ 11.1824 重叠面积 = 22.3648如果代码输出与这个数字匹配,说明实现正确。
6.2 用蒙特卡洛方法做交叉验证
解析公式算完,最好用蒙特卡洛模拟验证一次。思路是向大包围盒里随机撒点,统计落在两个圆内的点的比例,乘以包围盒面积。
# 文件路径:monte_carlo_check.py import math import random def monte_carlo_area(x1, y1, r1, x2, y2, r2, samples=300000): # 计算包围盒 min_x = min(x1 - r1, x2 - r2) max_x = max(x1 + r1, x2 + r2) min_y = min(y1 - r1, y2 - r2) max_y = max(y1 + r1, y2 + r2) box_area = (max_x - min_x) * (max_y - min_y) count = 0 for _ in range(samples): px = random.uniform(min_x, max_x) py = random.uniform(min_y, max_y) in_1 = (px - x1) ** 2 + (py - y1) ** 2 <= r1 ** 2 in_2 = (px - x2) ** 2 + (py - y2) ** 2 <= r2 ** 2 if in_1 and in_2: count += 1 return box_area * count / samples # 对比 analytic = circle_overlap_area(0, 0, 5, 6, 0, 5) mc = monte_carlo_area(0, 0, 5, 6, 0, 5) print(f"解析公式结果: {analytic:.4f}") print(f"蒙特卡洛结果: {mc:.4f}")蒙特卡洛的结果会根据随机种子有波动,但通常样本量 30 万时,误差可以控制在 0.5% 以内。如果两条曲线数值差距很大,说明解析公式或代码有问题,优先检查边界条件和 acos 的输入是否越界。
6.3 运行失败的排查顺序
如果代码输出与预期不一致,按这个顺序排查:
- 先检查 d 的数值,确认圆心距计算没有错误。
- 检查 acos 的输入参数是否落在 [-1, 1] 区间。浮点误差会导致分母乘出来的结果偏出 1,比如 1.0000000002,此时要 clamp 到 1。
- 检查边界条件是否覆盖了“内含”的情况,否则小圆在大圆内时面积会算错。
- 比较两个等价公式的结果,如果弓形公式和教科书公式不一致,通常是三解项符号问题。
7. 常见问题与故障排查
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 相交时交点坐标明显不对 | 垂直向量方向取反 | 打印 P 点和本地方向向量,手工验算一个直角特例 | 确认 v = (-dy/d, dx/d) 与 u = (dx/d, dy/d) 垂直 |
| 面积计算结果偏大或为负 | 未处理“内含”边界 | 构造 r2 远小于 r1 的用例 | 在公式前增加d <= abs(r1 - r2)判断 |
| acos 报 ValueError | 浮点误差导致输入超过 1 | 打印公式分子和分母 | 对 acos 参数做 clamp(-1, 1) |
| 相切时交点数量抖动 | 浮点比较d == r1 + r2不稳定 | 打印 d 和 r1+r2 的差值 | 使用误差阈值比较 |
| 同心圆时除零 | d 为 0,代码未做保护 | 检查圆心距离判断 | 在计算 a 之前先返回内侧圆面积 |
| 蒙特卡洛验证不一致 | 采样量不足或边界条件写错 | 增大采样量到 100 万,打印正反例 | 先修复解析公式,再用蒙特卡洛复核 |
| 前端 Canvas 显示重叠区域不明显 | 透明度叠加颜色不明显 | 检查 fillStyle 透明度 | 使用半透明高对比颜色 |
8. 工程最佳实践与扩展思考
8.1 写成独立的工具模块
两圆相交计算是高度通用的基础能力,建议封装成独立模块,而不是散落在业务代码里。推荐 API 设计:
Circle(center_x, center_y, radius) - distance_to(other) - intersection_points(other) - overlap_area(other) - relation(other)这样业务层不需要关心几何公式,只需要调用overlap_area或intersection_points。
8.2 使用误差阈值而不是精确相等
圆心距和半径相加的相等判断,在浮点体系下几乎不可能精确命中。务必引入误差阈值。在 Python 中可以使用math.isclose,在 JavaScript 中自行定义EPSILON。
推荐默认误差阈值1e-9或相对误差方式。对于像素级的 Canvas 应用,1e-3通常足够;对于地理坐标或高精度计算,需要更严格的判断。
8.3 acos 输入越界的防御性处理
acos的合法输入范围是[-1, 1]。浮点舍入可能导致传入参数为1.0000000002,在 Python 中会直接抛异常,在 JavaScript 中返回NaN。最稳妥的做法是:
def safe_acos(value): return math.acos(max(-1.0, min(1.0, value)))8.4 在工程场景中的扩展
两圆相交只是“圆形对象”运算的起点。很多真实问题最终会扩展成:
- 多个圆叠加覆盖区域计算:合并所有圆形的覆盖范围,判断总覆盖面积,不能用两两相交简单相加,需要平面扫描或网格法。
- 移动圆的连续碰撞检测:考虑时间维度后,问题从静态相交变成“质点在单位时间内的扫掠路径与另一个圆是否相交”。
- 圆周与线段的相交:游戏里角色搜索范围是一个圆,墙面是线段,此时两圆相交公式不够用,需要几何求交的升级版。
- 旋转坐标系下的椭圆相交:两圆正交相交公式可以直接推广到“轴对齐椭圆”,但任意角度椭圆相交就需要代数求解了。
8.5 性能与精度平衡
单次两圆相交计算是常数时间复杂度,性能不需要优化。但如果做批量计算,比如上万次碰撞检测:
- 先用粗粒度包围盒(AABB)过滤明显不相交的圆对。
- 再对候选对执行精确计算。
- 使用平方距离比较,避免开根号带来的开销。
对于面积计算,acos和sin开销比sqrt稍高,但在绝大多数场景中可忽略。如果需要在移动端每帧计算多次,可以考虑提前表格化或近似计算。
9. 一些额外的思考
“两个重叠的圆”这个几何问题,看起来是一个再基础不过的数学知识点,但越深入越会发现它连接着许多实际工程方向。
从学校到工程,中间隔着的其实不是公式记忆,而是对边界情况的敏感度。第一次实现时,我花了很长时间在“正常相交”的主逻辑上,结果一跑起来就崩在“内含”和“外切”上。两个圆的几何关系只有五种情况,主公式却只有“相交”这一种能用,其他四种都要靠边界条件拦截。这大概是这类几何算法最核心的经验:先判断关系,再套用公式。
如果你最近也在做类似的功能,建议按这个顺序实践:
- 先跑通单独的交点计算函数,输入几个已知几何关系验证。
- 再实现重叠面积,注意边界分支和 acos 越界。
- 最后在你的业务场景里接入,并保留测试用例。
几何算法的测试用例应该保留下来,它们比任何文档都更能说明问题:外离、外切、相交、内切、内含、同心,六个用例各写一个断言,以后重构或换语言实现,跑一遍测试就知道有没有改坏。这一步很基础,但在实际项目里带来很多便利。
希望这篇整理对你也有用,特别是正准备在项目里写圆相关计算的时候,能少走一点弯路。如果你在实践中有更好的边界处理技巧,也欢迎在评论区交流。