Processes G2/G3 arc commands with arc interpolation

Friends, I am trying to implement circular interpolation in Klipper. What I mean is that when klippy processes G2/G3 arc commands, it directly performs interpolation calculations on the arc, rather than first cutting it into multiple straight line segments and constructing G0/G1 commands, which is actually linear interpolation rather than circular interpolation. I have already modified the source code on the klippy side, and when running in batch mode, I obtained the correct results. The gcode file I used is:

M204 S2000
G2 X40 I20 F6000
G2 X60 I10
G2 X70 I5

The corresponding trajectory is shown in the figure below:

The X-axis velocity curve obtained in batch mode is shown in the figure below:

The Y-axis velocity curve obtained in batch mode is shown in the figure below:

In addition, I have also conducted motion control tests on the actual machine, but the situation is not optimistic. Sometimes it moves normally, and sometimes it cannot move normally. I do not know the specific reason.

Hello, and welcome! Have you read here?

Hi, thank you very much. The conclusion that “G2/G3 arc commands, apart from reducing the size of the G-code file, do not increase printing smoothness” holds, but it is based on the approach of splitting arcs into multiple straight line segments to construct G0/G1 commands, this is essentially linear interpolation, not circular interpolation. At least from my figure, it can be seen that under circular interpolation, the velocity curve is smoother, and only when the resolution parameter becomes smaller does the velocity curve approach it more closely. In the approach of linear interpolation, when the circle radius is small, the maximum velocity is reduced, this is because after the arc is split, the angle between two adjacent G0/G1 commands deviates from 180 degrees, however, theoretically, every point on a circle has two tangent directions that are opposite to each other, which means the angle should be 180 degrees. In the circular interpolation approach, this situation does not occur.

There is a lot of source code that needs to be changed, but the core is here:

// Return the XYZ coordinates given a time in a move
inline struct coord
move_get_coord(struct move *m, double move_time)
{
    // double move_dist = move_get_distance(m, move_time);
    // return (struct coord) {
    //     .x = m->start_pos.x + m->axes_r.x * move_dist,
    //     .y = m->start_pos.y + m->axes_r.y * move_dist,
    //     .z = m->start_pos.z + m->axes_r.z * move_dist };

    // arc interpolation
    double move_dist = move_get_distance(m, move_time);
    double x, y, z;

    if (m->LineType == 2)
    {
        double I = m->I;
        double J = m->J;
        double R = sqrt(I * I + J * J);
        double theta = move_dist / R;
        x = -I * cos(theta) - J * sin(theta) + I + m->start_pos.x;
        y = I * sin(theta) - J * cos(theta) + J + m->start_pos.y;
    }
    else if (m->LineType == 3)
    {
        double I = m->I;
        double J = m->J;
        double R = sqrt(I * I + J * J);
        double theta = move_dist / R;
        x = -I * cos(theta) + J * sin(theta) + I + m->start_pos.x;
        y = -I * sin(theta) - J * cos(theta) + J + m->start_pos.y;
    }
    else
    {
        x = m->start_pos.x + m->axes_r.x * move_dist;
        y = m->start_pos.y + m->axes_r.y * move_dist;
    }

    z = m->start_pos.z + m->axes_r.z * move_dist;

    return (struct coord){.x = x, .y = y, .z = z};
}

Is your goal then to prevent this?

As far as…

After reading a bit here Linear and Circular Interpolation on CNC Machines | INO , I’d guess defining F appropriately may be the issue. But that’s just that, a guess. I’m interested to see what someone else with a better understanding of what you’re trying to accomplish might say.

Unless a new F value occur, G2/G3 commands will work with the current F value.

With all due regard, it is a bit of a complication, where I understand that approximation is not perfect.
But generally, one can either increase the resolution, thus (0.1).
(For example, IIRC, I was able to do large arcs with R150, resolution like 0.01mm and velocity about 400mm/s).
Or it is possible to limit the error: RFC: gcode_arcs add max error support by nefelim4ag · Pull Request #6984 · Klipper3d/klipper · GitHub

I guess, as long as the error is less than half of the R of the nozzle, it is probably small enough.

Later in the pipeline, we have itersolver, which still has to chunk move into the steps, where later steps are compressed, and compression adds a bit of step jitter, which seems to be negligible in practice.

Regards,
-Timofey

G2/G3 only make sense with linear motion.
With stepper motors, a curve has to be segmented in sections.
And the smaller these get, the higher gets the data traffic resulting in the well known issues (TTC etc).

The question I have is are the individual moves sent to the MCU “position @ time” or velocity vectors?

I’ve always assumed the the position @ time model. If that is true no mater how the interpolation is calculated the actual motion is small linear moves. There may be host processing advantage to “real” arcs but the same resolution ends up with the same TTC exposure no matter the math fed into itersolver.

[quote=“cardoc, post:10, topic:26307”]
The question I have is are the individual moves sent to the MCU “position @ time” or velocity vectors?
[/quote]

It is position/time, basically, we have a “start clock” for the stepper, for example, N
And then we send a compressed poly “curve”:
step_move count=10 interval=100 add=1
so, the first step is N + 100, interval = interval + add = 100 + 1 = 101, count–
Next is N + 100 + 101, interval = 101 + 1, count–
Next is N + 100 + 101 + 102…, count–
& etc

So, yeah, as long as the source G-code has the same resolution as interpolated arcs, the result would be the same.


Where the current code should be resilient for TTC related to arcs. Arcs are split into moves through the generator (so there are moves to step forward).
The itersolve happens for each stepper in separate threads; thus, it is parallel.

There are some old performance-related topics: Itersolve/InputShaper/PressureAdvanced precessing overhead

-Timofey

So assuming the bottleneck is loading/unloading the move buffer there is no advantage to changes in the way Klippy handles arcs. It is still preferred to let the slicer perform the interpolation and deal with the larger g-code files like we always have?

Hi, thank you for your reply, I’m guessing you mean that a smaller resolution value results in a trajectory that more closely approximates a genuine arc, right? , but when I set resolution=0.01, it gives me “MCU shutdown: Timer too close”, only when the radius is small, it works:

; linear interpolation
G0 X100 Y100 F6000
G2 I5       ; it is ok
; G2 I50    ; TTC

Besides that, in the batch mode, there is WARNING like:

Hi, friends, I think my arc interpolations works now! Let me run some tests to see if there are any other issues.

As you are able to test in batch mode, you can simply add time python3 ... to measure the actual CPU usage.
I strongly recommend fixing the CPU frequency of all cores to do such tests.

Such a warning is okayish, in batch mode, because in batch mode it cannot “block” the further addition of G-code commands. Whereas under normal printing, I would expect that it blocks as soon as we hit 1s buffer mark or so.

(where you can share the log with TTC, but I guess this is out of scope of the current discussion rn).

-Timofey

M204 S5000
G0 X100 Y100 F6000
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50
G2 I50

I’m surprised that your implementation is so much faster/cheaper, I guess this is because it can avoid traveling the small moves back and forth, as an arc is probably represented as one “move”.

Normally x2 of microsteps x2 of cpu load, and input shaper/pressure advance have it’s own overhead (as they have to travel through the moves depending on the necessary time window).

Otherwise, at least from data above, it seems that on a “real” machine, for a 52s print time, it will only use 3.4s of CPU time, so 3.4 / 52 ~= 6.5% on average.

-Timofey

Yes, an arc is represented as one “move”. I should clarify that in my case, I disabled both input shaper and pressure advance, because—as you mentioned earlier—this is a complex issue, and I haven’t modified the source code for input shaping and pressure advance yet. That will take some time.

For what it is worth, I suspect another complexity would be the handling of lookahead (ie, square_corner_velocity). The current code uses the angle between each move to determine the inter-move velocity. If circular moves were implemented directly at the itersolve layer then the lookahead code would need to be able to calculate the start/end movement angles of circular moves.

Cheers,
-Kevin

Hi, thank you for your reply, the lookahead code treats linear and circular segments equally, because I think, for the lookahead code, an arc is nothing more than a line segment that has two tangents.

# Class to track each move request
class Move:
    def __init__(self, toolhead, start_pos, end_pos, speed):
        # source code ...
        # 圆弧插补
        self.LineType = 1
        self.tangent_s = [self.axes_r[0], self.axes_r[1]]
        self.tangent_e = [self.axes_r[0], self.axes_r[1]]
class Move_Arc(Move):
    def __init__(self, toolhead, start_pos, end_pos, speed, IJ, G23):
        self.toolhead = toolhead
        self.start_pos = tuple(start_pos)
        self.end_pos = tuple(end_pos)
        self.accel = toolhead.max_accel
        self.junction_deviation = toolhead.junction_deviation
        self.timing_callbacks = []
        velocity = min(speed, toolhead.max_velocity)
        self.is_kinematic_move = True
        self.axes_d = axes_d = [ep - sp for sp, ep in zip(start_pos, end_pos)]
        self.LineType = 2
        if G23 == False: self.LineType = 3
        self.I = I = IJ[0]
        self.J = J = IJ[1]
        Radius = math.sqrt(I * I + J * J)
        dx = axes_d[0]
        dy = axes_d[1]
        tangent_s = [0., 0.]
        tangent_e = [0., 0.]
        U = [-I / Radius, -J / Radius]
        V = [(dx - I) / Radius, (dy - J) / Radius]
        beta = math.acos(max(-1., min(1., U[0]*V[0] + U[1]*V[1])))
        cross = U[0] * V[1] - U[1] * V[0]
        if G23 == True:   # G2 clockwise
            Theta = beta
            if cross >= 0: Theta = 2.0 * math.pi - beta
            tangent_s[0] = -J / Radius
            tangent_s[1] =  I / Radius
            tangent_e[0] =  (dy - J) / Radius
            tangent_e[1] =  (I - dx) / Radius
        else:             # G3 counter-clockwise
            Theta = beta
            if cross <= 0: Theta = 2.0 * math.pi - beta
            tangent_s[0] =  J / Radius
            tangent_s[1] = -I / Radius
            tangent_e[0] = -(dy - J) / Radius
            tangent_e[1] = -(I - dx) / Radius
        self.tangent_s = tangent_s
        self.tangent_e = tangent_e
        self.move_d = move_d = Radius * Theta
        if move_d < .000000001:  # Extrude only move
            self.end_pos = ((start_pos[0], start_pos[1], start_pos[2])
                            + self.end_pos[3:])
            axes_d[0] = axes_d[1] = axes_d[2] = 0.
            self.move_d = move_d = max([abs(ad) for ad in axes_d[3:]])
            inv_move_d = 0.
            if move_d: inv_move_d = 1. / move_d
            self.accel = 99999999.9
            velocity = speed
            self.is_kinematic_move = False
        else:
            inv_move_d = 1. / move_d
        self.axes_r = [d * inv_move_d for d in axes_d]
        # X, Y axes must be active for arc moves
        self.axes_r[0] = 0.5 * (1.0 - self.axes_r[3])
        self.axes_r[1] = 0.5 * (1.0 - self.axes_r[3])
        self.min_move_t = move_d / velocity
        self.max_start_v2 = 0.
        self.max_cruise_v2 = velocity**2
        self.delta_v2 = 2.0 * move_d * self.accel
        self.next_junction_v2 = 999999999.9
        self.max_mcr_start_v2 = 0.
        self.mcr_delta_v2 = 2.0 * move_d * toolhead.mcr_pseudo_accel
    def calc_junction(self, prev_move):
        if not self.is_kinematic_move or not prev_move.is_kinematic_move:
            return
        # Allow extra axes to calculate maximum junction
        ea_v2 = [ea.calc_junction(prev_move, self, e_index+3)
                 for e_index, ea in enumerate(self.toolhead.extra_axes)]
        # Find max velocity using tangent vectors at junction
        junction_cos_theta = -(self.tangent_s[0] * prev_move.tangent_e[0]
                               + self.tangent_s[1] * prev_move.tangent_e[1])
        sin_theta_d2 = math.sqrt(max(0.5 * (1.0 - junction_cos_theta), 0.))
        cos_theta_d2 = math.sqrt(max(0.5 * (1.0 + junction_cos_theta), 0.))
        one_minus_sin_theta_d2 = 1. - sin_theta_d2
        if one_minus_sin_theta_d2 > 0. and cos_theta_d2 > 0.:
            R_jd = sin_theta_d2 / one_minus_sin_theta_d2
            move_jd_v2 = R_jd * self.junction_deviation * self.accel
            pmove_jd_v2 = R_jd * prev_move.junction_deviation * prev_move.accel
            quarter_tan_theta_d2 = .25 * sin_theta_d2 / cos_theta_d2
            move_centripetal_v2 = self.delta_v2 * quarter_tan_theta_d2
            pmove_centripetal_v2 = prev_move.delta_v2 * quarter_tan_theta_d2
            max_start_v2 = min([self.max_cruise_v2,
                                prev_move.max_cruise_v2,
                                prev_move.next_junction_v2,
                                prev_move.max_start_v2 + prev_move.delta_v2,
                                move_jd_v2, pmove_jd_v2,
                                move_centripetal_v2, pmove_centripetal_v2]
                               + ea_v2)
            self.max_start_v2 = max_start_v2
            self.max_mcr_start_v2 = min(
                max_start_v2,
                prev_move.max_mcr_start_v2 + prev_move.mcr_delta_v2)

junction_cos_theta is determined by:

        junction_cos_theta = -(self.tangent_s[0] * prev_move.tangent_e[0]
                        + self.tangent_s[1] * prev_move.tangent_e[1])