3. 逐点比较法(二):直线插补的推导与实现、圆弧插补的推导与实现、不同象限的处理

上一章我们聊了逐点比较法的基本思路——说白了就是“走一步看一步,偏了就纠”。这一章咱们把它落到实处,把直线和圆弧的插补逻辑完整推一遍,再把不同象限的坑填上。

我个人习惯把逐点比较法叫做“最朴素的插补算法”,因为它不需要三角函数,不需要矩阵运算,全靠加减法和符号判断就能干活。早期数控系统里,这玩意儿是绝对的主力。

3.1 直线插补的推导

先看第一象限的直线。假设起点在原点 O(0,0),终点是 E(Xe,Ye),当前刀具在 P(Xi,Yi)。

判断点在直线哪一侧,我用的是“偏差函数”这个概念:

F = Xi * Ye - Yi * Xe

为什么是这个式子?你想想看,如果 P 点在直线上,那么三角形 OPE 的面积就是零。这个 F 值其实就是叉积的 2 倍,正负号直接告诉你点在直线的哪一侧。

核心判断逻辑:

  • F = 0:点在直线上,走 +X 方向
  • F > 0:点在直线上方,走 +X 方向(逼近直线)
  • F < 0:点在直线下方,走 +Y 方向(逼近直线)

每次走一步后,偏差函数怎么更新?这里有个小技巧——不用重新算乘法,用递推公式就行。

如果走了 +X 步(Xi+1 = Xi + 1):

F_new = (Xi+1) * Ye - Yi * Xe = F_old + Ye

如果走了 +Y 步(Yi+1 = Yi + 1):

F_new = Xi * Ye - (Yi+1) * Xe = F_old - Xe

嗯,这里要注意:递推公式里只有加减法,没有乘法。这就是逐点比较法能在老式步进电机系统上跑得飞快的原因。

实战经验:我在调试一台老式铣床时遇到过一个问题——终点判断。光靠 F 值归零是不够的,因为可能刚好跨过终点。我的做法是加一个计数器,记录 X 和 Y 方向的总步数,走完就停。

3.2 直线插补的实现

下面给一个 C 风格的伪代码实现。注意,这里只处理第一象限,后面再扩展。

void line_interp(int Xe, int Ye) {
    int F = 0;
    int step = Xe + Ye;  // 总步数
    int x = 0, y = 0;
    
    while (step > 0) {
        if (F >= 0) {
            // 走 +X
            x++;
            F -= Ye;  // 注意:这里减 Ye,不是加
            printf("Step +X, pos(%d,%d)\n", x, y);
        } else {
            // 走 +Y
            y++;
            F += Xe;
            printf("Step +Y, pos(%d,%d)\n", x, y);
        }
        step--;
    }
}

避坑指南:我曾经在递推公式的符号上栽过跟头。F >= 0 时走 +X,F 要 减 Ye;F < 0 时走 +Y,F 要 加 Xe。这个符号和偏差函数的定义方式有关,不同教材可能正负相反,但逻辑必须自洽。

3.3 圆弧插补的推导

圆弧插补比直线复杂一些。以第一象限逆圆为例,圆心在原点,起点 S(Xs,Ys),终点 E(Xe,Ye),当前点 P(Xi,Yi)。

偏差函数用点到圆心的距离平方差来定义:

F = Xi² + Yi² - R²

其中 R 是圆弧半径。判断逻辑:

  • F = 0:点在圆弧上
  • F > 0:点在圆外
  • F < 0:点在圆内

对于逆圆,进给方向是:

  • F >= 0:点在圆外或圆上,走 -X 方向(向圆内靠)
  • F < 0:点在圆内,走 +Y 方向(向圆外靠)

递推公式同样可以简化。走 -X 步(Xi-1 = Xi - 1):

F_new = (Xi-1)² + Yi² - R² = F_old - 2*Xi + 1

走 +Y 步(Yi+1 = Yi + 1):

F_new = Xi² + (Yi+1)² - R² = F_old + 2*Yi + 1

你看,递推公式里只有加减法和乘 2(左移一位),没有平方运算。这就是逐点比较法的精髓——用递推把复杂计算变成简单操作。

3.4 圆弧插补的实现

void arc_interp_ccw(int Xs, int Ys, int Xe, int Ye) {
    int R2 = Xs*Xs + Ys*Ys;  // 半径平方
    int F = 0;
    int x = Xs, y = Ys;
    int step = abs(Xe - Xs) + abs(Ye - Ys);  // 近似总步数
    
    while (step > 0) {
        if (F >= 0) {
            // 走 -X
            x--;
            F -= 2*x + 1;  // 注意:x 已经减了,所以是 2*x+1
            printf("Step -X, pos(%d,%d)\n", x, y);
        } else {
            // 走 +Y
            y++;
            F += 2*y + 1;  // y 已经加了,所以是 2*y+1
            printf("Step +Y, pos(%d,%d)\n", x, y);
        }
        step--;
    }
}

个人经验:圆弧插补的终点判断比直线麻烦。我习惯用“当前点与终点的坐标差”来判断是否到达,而不是单纯依赖步数计数器。因为圆弧路径可能不是单调的,步数估算容易出错。

3.5 不同象限的处理

不同象限的处理,说白了就是“符号变换”。直线和圆弧在不同象限的进给方向、偏差更新符号都会变化。

先看直线插补的象限处理。下表总结了四个象限的进给方向:

象限 F >= 0 进给方向 F < 0 进给方向 F >= 0 偏差更新 F < 0 偏差更新
第一象限 +X +Y F - Ye F + Xe
第二象限 -X +Y F + Ye F - Xe
第三象限 -X -Y F + Ye F - Xe
第四象限 +X -Y F - Ye F + Xe

你发现规律了吗?其实只要把终点坐标的绝对值代入第一象限的算法,然后根据象限决定正负方向就行。我习惯的做法是:先把终点坐标取绝对值,算出步数,再根据象限映射到实际方向。

圆弧插补的象限处理更复杂一些,因为圆弧有顺圆和逆圆之分,还有跨象限的情况。下面这张图展示了第一象限逆圆和顺圆的进给方向对比:

圆弧插补象限与方向示意 X Y 逆圆 顺圆 第一象限 第二象限 第三象限 第四象限 说明:逆圆(CCW)沿逆时针方向走,F>=0 走 -X,F<0 走 +Y 顺圆(CW)沿顺时针方向走,F>=0 走 -Y,F<0 走 +X

跨象限的圆弧处理,我个人的做法是“分段处理”。把圆弧按象限边界(0°、90°、180°、270°)切成若干段,每段单独调用对应象限的插补逻辑。这样代码清晰,不容易出错。

曾经踩过的坑:有一次做跨象限圆弧插补,我偷懒没分段,直接用统一的递推公式。结果在象限边界附近出现了“抖动”——刀具在边界上来回走,就是不往前走。后来老老实实分段处理,问题就解决了。

3.6 本章小结

逐点比较法的直线和圆弧插补,核心就三点:

  • 偏差函数:直线用叉积,圆弧用距离平方差
  • 递推公式:把乘法和平方变成加减法
  • 象限映射:用绝对值统一处理,再映射到实际方向

这套方法虽然看起来“土”,但在资源受限的嵌入式系统里,它比 DDA(数字微分分析器)更稳定,比数据采样法更简单。我到现在做低成本的步进电机控制方案,首选还是逐点比较法。