一聊到工程几何建模,很多人脑子里浮现的是NX、SolidWorks里那个Fillet命令:选中两条边,输入半径,回车,一个圆角就出来了。但如果你自己写过CAM前处理、解析过DXF轮廓、或者想用Python做参数化建模,就会发现这个看似简单的功能背后,藏着一整套值得拆开揉碎的几何逻辑。这篇文章我想从一个非常朴素的问题出发:为什么工程轮廓天生适合用分段函数来描述?所谓“完美圆角”在数学上到底意味着什么?以及怎样用Python把一个带尖角的折线轮廓,改造成带圆弧过渡的平滑轮廓。
我自己写这类算法踩过不少坑,比如圆弧方向搞反、半径一大就报错、切点算到线段延长线上。所以这篇会尽量把原理和实现中的细节都讲透,包括等距线求圆心的思路、切点距离的钳制条件、闭合轮廓的正反方向陷阱,以及最终离散成加工路径时要怎么控制弦高误差。适合正在写几何算法、搞数控加工路径生成、或者纯粹想用Python做工程绘图辅助的朋友参考。
1. 为什么要从“分段函数”视角理解工程轮廓
1.1 工程轮廓本质上就是一个分段函数
你可能觉得“分段函数”是高中数学里的概念,跟工程图纸八竿子打不着。但实际上任何一条工程轮廓线,本质上都是一段一段拼起来的参数方程。
直线段可以写成 P(t) = P0 + t·(P1 - P0),t 从0到1;圆弧段可以写成中心角参变的圆方程;复杂一点还有B样条、NURBS。把它们按顺序首尾拼接,就是一个完整的分段函数。CAD系统之所以能缩放到任意精度不模糊,就是因为保存的不是位图,而是这样的几何基元列表。
我自己的习惯是:拿到一个封闭轮廓,先把它看成“由N段几何元素拼成的分段函数”,每段有各自的参数区间、起止点、局部坐标。这样后面做圆角、倒角、偏置、离散化,都是在做“分段函数的变换”,思路会特别清晰。比如圆角过渡,本质上就是取原轮廓的第i段和第i+1段,在两段交界处删掉一个尖角,插入一段新的圆弧段,于是分段函数从两段变三段,整体仍然是“直线-圆弧-直线”的连续结构。
1.2 尖角是问题,圆角是答案
工程上为什么处处要圆角?不是因为美学,而是因为尖角在物理世界中真的很麻烦。
先说应力。学过材料力学的都知道,直角和尖角处会产生严重的应力集中,应力集中系数理论值可以趋近无穷大。机械零件上哪怕只是一个小台阶,如果没做圆角过渡,疲劳裂纹往往就从那里开始萌生。车间老师傅常说的“尖角杀”,指的不只是划伤人,更是杀零件的寿命。
再说加工。数控铣削时刀具沿轮廓运动,遇到尖锐拐角如果直接转向,刀具冲击大、容易振动,还可能因为减速停顿留下刀痕。有了圆角过渡,刀具可以保持相对恒定的进给速度,路径更平滑,表面质量明显更好。这也是为什么CAM软件里很多策略都要求轮廓“必须圆角化”。
从设计制造到装配检测,圆角几乎是刚需。一个典型的例子是钣金折弯件,折弯内角不做成圆弧,模具容易崩、板材也容易开裂;注塑件就更不用说了,塑料流动需要平滑过渡,尖角处容易出现应力发白和缩痕。可以说“从分段函数到完美圆角”这条路,是所有工程造型绕不开的基本功,也是我用Python写几何算法的第一课。
2. 完美圆角的几何原理:相切、等距线与三个公式
2.1 “完美圆角”的数学合同:G1连续
先给“完美圆角”一个能被程序验证的定义:一段半径为 r 的圆弧,和它两侧的线段都相切。所谓相切,就是圆弧在连接点处的切线和线段的切线方向完全一致。用几何连续性的术语说,位置连续叫G0,切线方向连续叫G1,曲率连续叫G2。圆弧和直线连接处只要求G1,因为直线段曲率为0,圆弧段曲率为1/r,在切点处曲率必然发生突变。
但圆角最重要的不是曲率连续,而是“切点落在两条线段的有效范围内,而不是它们的延长线上”。一个漂亮圆角,要让人看不出拼接痕迹,圆弧起点和终点与相邻线段平滑衔接,不能出现“切进去再弹出来”的折线感。说白了一句话:圆弧该圆的半径刚刚好,该贴的位置贴得刚刚好。
这也解释了为什么网上很多简单的“圆角”实现是错的:有人直接拿直线插补,用很多小线段去逼近圆角形状,那只是外观相似,数学上根本不相切;有人算出圆心但没检查切点是否越界,结果圆弧超出线段边界,加工时刀具直接扎进不该切的地方。所以“完美圆角”不是审美词,是数学合同。
2.2 求圆心:三种思路和我的选择
怎么求圆角的圆心?我见过三种主流思路。
第一种是角平分线公式法。已知两条边的夹角 α 和圆角半径 r,圆心在角平分线上,到顶点的距离为 d = r / sin(α/2),切点到顶点的距离为 L = r / tan(α/2)。这个公式简洁漂亮,适合在直角三角形里手算。但它的问题是:α是“内角”还是“外角”很容易搞混;遇到凹角时还要先求补角,否则符号直接翻车。
第二种是向量旋转法。把进入边的单位向量旋转90度得到法向,用两条边的法向构造两个约束方程,联立解出圆心。这种方法的几何意义清楚,也不容易出错,但实现时依然要小心法向方向的选择——凸角和凹角需要的法向方向是相反的。
第三种是等距线求交法,也是我实际工程中最推荐的方法。把两条原始线段各自向内偏移 r,得到两条平行线,这两条平行线的交点就是圆心。为什么?因为圆弧与一条线段相切,等价于圆心到这条线段的距离恰好等于 r;同时满足两条线段距离为 r 的点,只能是两条偏移线的交点。
第三种思路最“笨”,但最稳。我形容它就像在马路两边各划一道警戒线,两道警戒线交的点,就是安放红绿灯的位置。它天然支持凸角和凹角,只要把“内偏移方向”选对,计算复杂度也只是两条直线求交,不用处理三角函数,更不会遇到夹角接近0度时的数值爆炸。所以后面Python实现,我全部采用等距线求交的路线。
2.3 切点距离与半径钳制
有了圆心,还要找到圆弧和两条线段各自相切的切点。切点其实就是圆心在原始线段上的投影点。直线从A到B方向为e,圆心为O,那么切点参数 t = dot(OA, e),切点坐标就是 A + t·e。
这一步看似简单,但工程上它负责一件极其重要的事:半径合法性判定。如果算出来的 t 大于线段的长度,说明切点跑到了线段延长线上。这说明圆角半径太大,已经超出了这条边的承载能力。CAD软件这时会直接把半径钳制到允许的最大值,或者直接报错提示你“圆角半径超出轮廓范围”。
所以记住这三件事:圆心在等距线交点上;切点是圆心到线段的垂足;切点必须在线段有效范围内。这三条同时满足,圆角才是“完美”的。后面代码里我还会再演示怎么把这个检查落到实处。
3. Python实现:用几何类把圆角算法跑起来
3.1 先装环境,再定义几个几何基元
实现圆角算法不需要重型库,Python标准库的 math 就够完成核心计算。可视化用 matplotlib,数组运算用 numpy。如果你还没装,建议新建一个虚拟环境,避免污染全局Python环境:
python -m venv geofillet source geofillet/bin/activate # Windows下是 geofillet\Scripts\activate pip install numpy matplotlib我经常看到有人问“Python画图横坐标太密集”之类的问题,这多半是matplotlib的刻度参数没调好,跟后面的可视化验证也有关联。先不用管,后面我会提。
几何基元不用造得很复杂,一个二维点用二元组表示就够。核心工具函数是向量运算:
import math from typing import Tuple Point = Tuple[float, float] def vec(a: Point, b: Point) -> Point: return (b[0] - a[0], b[1] - a[1]) def dot(u: Point, v: Point) -> float: return u[0] * v[0] + u[1] * v[1] def cross(u: Point, v: Point) -> float: return u[0] * v[1] - u[1] * v[0] def norm(u: Point) -> float: return math.hypot(u[0], u[1]) def normalize(u: Point) -> Point: n = norm(u) return (u[0] / n, u[1] / n)这些函数没什么高深之处,但请务必统一口径:到底是“从A到B的向量”还是“从B到A的向量”,一个符号错了,整个圆角方向就会反掉。
3.2 核心函数:两条线的圆角过渡
现在写最核心的 fillet 函数。输入是折线上连续三个点 A、B、C,其中B是尖角顶点,外加半径 r。输出是圆心 O、两个切点 T1、T2。整体思路就是等距线求交:
def fillet(A: Point, B: Point, C: Point, r: float, direction: int = 1): e1 = normalize(vec(A, B)) e2 = normalize(vec(B, C)) cr = cross(e1, e2) if abs(cr) < 1e-12: # 共线,不需要圆角 return None # 根据转向确定偏移方向 # direction=1表示逆时针轮廓,内部在左侧 side = direction if cr > 0 else -direction n1 = (-e1[1], e1[0]) # 左法线 n2 = (-e2[1], e2[0]) A1 = (A[0] + side * r * n1[0], A[1] + side * r * n1[1]) B1 = (B[0] + side * r * n2[0], B[1] + side * r * n2[1]) # 两条偏移线求交,得到圆心 denom = cross(e1, e2) v = (B1[0] - A1[0], B1[1] - A1[1]) t = cross(v, e2) / denom O = (A1[0] + t * e1[0], A1[1] + t * e1[1]) # 切点:圆心到原始线段的投影 t1 = dot(vec(A, O), e1) t2 = dot(vec(B, O), e2) T1 = (A[0] + t1 * e1[0], A[1] + t1 * e1[1]) T2 = (B[0] + t2 * e2[0], B[1] + t2 * e2[1]) len1 = norm(vec(A, B)) len2 = norm(vec(B, C)) if not (0 <= t1 <= len1 and 0 <= t2 <= len2): raise ValueError(f"圆角半径 {r} 过大,切点超出边范围") return O, T1, T2, r这个函数能同时处理凸角和凹角,关键在side的选择。我默认轮廓按逆时针绕行,此时凸角对应的叉积为正,内测在左法线一侧;凹角对应的叉积为负,内侧反而在右法线一侧。所以 direction 乘以叉积符号,就能自动翻转偏移方向。
3.3 闭合轮廓遍历与分段函数输出
单条边会算还不够,真正的轮廓是一圈闭合的折线。遍历闭合轮廓时记得“首位相接”:
def fillet_contour(pts: list, r: float, direction: int = 1): n = len(pts) fillets = [] for i in range(n): A = pts[(i - 1) % n] B = pts[i] C = pts[(i + 1) % n] result = fillet(A, B, C, r, direction) if result is not None: fillets.append(result) return fillets这里得到的 fillets 是每个尖角处的圆角信息。如果你要的是一个可直接绘图的连续轮廓,还需要按顺序把“直线段”和“圆弧段”交替拼接。我习惯把轮廓表达成一个由几何元素组成的列表,也就是我开头说的“分段函数”:第一段从某个切点到下一个切点画直线,第二段画圆弧,第三段再画直线,如此往复。
圆弧段的采样可以单独写一个函数。这里最容易出问题的地方是角度差的计算,尤其是起点角度和终点角度跨越±π边界的时候。稳妥做法是计算两角之差,然后修正到正确的旋转方向:
def sample_arc(center: Point, start: Point, end: Point, r: float, n: int = 64): a0 = math.atan2(start[1] - center[1], start[0] - center[0]) a1 = math.atan2(end[1] - center[1], end[0] - center[0]) inc = (a1 - a0) % (2 * math.pi) if inc > math.pi: inc -= 2 * math.pi return [ (center[0] + r * math.cos(a0 + inc * i / n), center[1] + r * math.sin(a0 + inc * i / n)) for i in range(n + 1) ]这里采用“从start到end的旋转角不超过180度”的原则,实际生成时还要结合轮廓的绕行方向再确认一下。如果你发现圆弧画反了,多半就是角度修正的方向和轮廓行走方向不一致。
3.4 可视化验证:一次把几何关系画明白
几何算法的调试,最忌讳只看最终结果数值对不对。我的建议是:一上来就把原始折线、圆心、切点、圆弧、辅助偏移线全部画出来,人眼扫一遍比什么调试都好使。
import matplotlib.pyplot as plt def plot_fillet(pts, fillets, sampled_points): fig, ax = plt.subplots(figsize=(8, 6)) xs = [p[0] for p in pts] + [pts[0][0]] ys = [p[1] for p in pts] + [pts[0][1]] ax.plot(xs, ys, '--o', lw=1, label='原始折线') fx = [p[0] for p in sampled_points] fy = [p[1] for p in sampled_points] ax.plot(fx, fy, '-', lw=2, label='圆角轮廓') for O, T1, T2, r in fillets: ax.plot([O[0]], [O[1]], 'rx', label='圆心') ax.plot([T1[0], T2[0]], [T1[1], T2[1]], 's', ms=4, label='切点') ax.set_aspect('equal', adjustable='box') # 这行很关键 ax.grid(True) ax.legend() plt.show()画图时最容易踩的坑就是坐标轴不等比。CAD里一个正方形如果X轴和Y轴的比例不一样,看起来就像长方形,圆角看起来像椭圆。ax.set_aspect('equal')就是用来修复这个问题的。你要是发现画出来的圆角“不圆”,先检查这一行,不是算法错了。
4. 从屏幕曲线到真实工件:离散化与工程落地
4.1 加工路径里的圆弧不是“画”出来的
在CAD里,圆弧是一个精确的数学实体,你想放大多少倍都清晰。但数控机床不认识“圆弧方程”,它只认识G代码里的坐标和圆弧指令。如果你用的是支持圆弧插补的控制器,可以把圆角直接写成G2/G3指令,这样机床自己走圆弧,轨迹最平滑;如果你的控制器或者后处理器只支持直线插补,那就必须先把圆弧离散成密集的小线段。
我在实际项目里两种方式都遇到过。支持G2/G3时,圆角信息要原样保留,圆心坐标、圆弧起终点、方向全部输出;不支持时,就用采样点逼近,这时弦高误差就成了最需要关心的指标。
把圆角信息保留下来还有个好处:圆弧段是严格等曲率的,后续做刀具半径补偿、功耗仿真都比折线段方便得多。所以我的建议永远是:先把精确几何模型建好,离散化放到最后一步,不要一上来就变成点云。
4.2 弦高误差控制与采样点密度的计算
把圆弧近似成直线段,误差怎么算?假设弦长为 s,圆弧半径为 r,则弦高误差 h 大约等于:
h ≈ s² / (8r)
这个近似在 s 远小于 r 时很准。反过来,给定允许误差 h,可以算出最大弦长:
s ≈ sqrt(8·r·h)
举个例子。半径 r = 10mm,要求弦高误差不超过0.01mm,那么弦长不能超过 sqrt(8×10×0.01) = sqrt(0.8) ≈ 0.894mm。90度圆弧的弧长约15.7mm,需要至少约18段,实际取20到24段比较稳妥。如果你用固定点数64采样,90度圆弧的弦长约 15.7/64 ≈ 0.245mm,弦高误差只有 sqrt?反过来算 h = s²/(8r) ≈ 0.00075mm,远远够用。所以通用代码里我默认给64段,除非你要求特别极端的精度。
这个计算过程值得在代码注释里写清楚,否则过几个月你再打开这个函数,看到n=64会完全想不起为什么。
4.3 连续圆角、凹角圆角与退化情况
只看两个边夹一个角是理想情况,真实轮廓经常有连续多个尖角挨得很近的情况。比如锯齿形的轮廓、槽口、键槽,可能几个角互相之间距离不足。这时候前一个大圆角的切点,可能已经越过下一条边的起点,直接进入下一个顶点区域,切点距离检查会把它拦下来,但也意味着你需要一套“半径自动收缩”的策略。
我常用的策略很朴素:对每个顶点先按工艺要求的半径试算,如果切点越界,就把半径折半再试,直到满足所有顶点条件。这比直接报错用户体验好很多,因为对大多数零件,稍微小一点点的圆角完全不影响功能。
凹角圆角则是另一个重点。很多人以为圆角只是把凸出的角磨平,实际上凹进去的角同样需要圆角过渡,否则刀具转进去时容易过切。用我前面等距线求交的方法,只要 side 符号判断正确,凹角圆角自动就会“长”在凹槽外侧,圆弧向外部鼓出,把原来的直角内口填成一个小圆角。这个特性是等距线法的最大优势,用角平分线公式反而容易错。
5. 我会踩过的那些坑:问题排查与速查表
5.1 半径太大导致切点跑到边外
这是我最早遇到也最典型的错误。比如一条边长5mm,你给它配一个8mm的圆角半径,程序就会算出切点在延长线上。如果不检查切点参数是否落在 [0, len] 范围内,生成的轮廓就会畸形:圆角虽然“相切”,却切到了根本没材料的那一侧,加工出来完全不是你要的东西。
我的处理方式分两级:开发阶段直接 raise ValueError,把问题暴露出来;交付阶段写一个自动降级逻辑,在半径超出时调用min(r, max_safe_radius)钳制。同时输出警告信息,方便现场工艺人员知道发生了什么。
5.2 轮廓方向搞反,圆角“长”到了外侧
这个问题出现的频率比想象中高。读DXF、AI、SVG等文件时,外轮廓通常是逆时针,内孔通常是顺时针。如果你不加判断直接按逆时针处理,内孔的圆角就会全部偏到内部去,刀具路径直接出错。
解决方法是先计算轮廓有向面积,判断旋转方向:
def signed_area(pts): area = 0 n = len(pts) for i in range(n): x1, y1 = pts[i] x2, y2 = pts[(i + 1) % n] area += x1 * y2 - x2 * y1 return area / 2面积为正说明逆时针,为负说明顺时针。拿到符号后统一反转成逆时针再处理,最后输出时再按原始方向输出。这个“输入归正、中间统一、输出还原”的套路,我强烈推荐你写进所有几何处理工具里。
5.3 浮点误差和共线判断
计算机判断两条线段是否共线,不能直接写if cross == 0,因为浮点数很少精确等于0。建议设一个 epsilon,比如 1e-10 或者 1e-12。我在代码里用abs(cr) < 1e-12跳过共线顶点,这样微小噪声不会触发无效圆角。
还有一个容易被忽视的坑:用户给的折线可能存在重复点或者极短边。如果相邻两个顶点距离只有1e-5mm,单位向量归一化时数值会非常不稳定。我一般会预处理,把距离小于某个阈值的点合并掉,再进入圆角计算。
5.4 调试技巧:先画图再调参
我调试这种几何代码的经验只有一句话:永远别只盯着数值想问题,先画图。把原始折线、偏移线、圆心、切点、圆弧全部画在同一张图上,哪里不对一眼就看出来。文字输出只能告诉你“出错了”,图能告诉你“错在哪”。
比如圆弧方向反了,你会看到圆弧绕了一个大圈子;切点越界,你会看到圆弧起点在边外;圆心位置错,你会看到圆弧和直线存在明显缝隙或交叉。绘图验证的成本极低,收益极高。
5.5 常见问题速查表
下面这张表是我做类似项目时总结的,供你直接套用:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 圆弧画反了 | 角度差没按绕行方向修正 | 检查 sample_arc 中 inc 的正负 |
| 圆角偏到轮廓之外 | 轮廓顺时针但代码默认逆时针 | 用 signed_area 判断并统一方向 |
| 圆角半径一大就报错 | 切点超出线段有效范围 | 先钳制半径,再支持自动降级 |
| 圆角看起来像椭圆 | matplotlib坐标轴不等比 | 设置 ax.set_aspect(‘equal’) |
| 共线点也生成了圆角 | 叉积判断用了硬等号 | 用 epsilon 阈值过滤共线情况 |
| 凹角圆角方向错误 | 偏移方向没有按叉积符号翻转 | 用 side = direction if cr>0 else -direction |
6. 几句掏心窝的话
做几何建模算法,最难的部分往往不是数学公式,而是那些“你以为写对了、实际差一点”的细节。等距线求心、垂足投影、方向符号、越界钳制,每一步单拎出来都很简单,但组合在一起,就像搭积木,任何一块偏了,最终轮廓都会变形。我写这篇也是想告诉你:别怕这种看着“基础”的算法,越基础的几何工具,越值得反复打磨。
如果后续你想继续深入,可以考虑几个方向:一是把圆角半径做成变量,每个顶点可以单独指定,这就不是一个 fillet 函数能搞定的事,需要建立约束求解框架;二是把直线段换成圆弧或样条段,做“双圆弧过渡”或者“样条圆角”,这时候曲面接续的G2连续性会牵扯到更多曲率计算;三是把圆弧段直接输出成CNC的G2/G3代码,真正跑一台机器看看轨迹效果。每个方向都够再写一整篇文章。我这边目前做得最顺手的就是这套等距线+切点投影的框架,希望对你也有用。