← Back to Blog
manipulationsplineb-splinetime-optimaltrajectory

Advanced Trajectories: Spline, B-Spline & Time-Optimal Planning

Advanced techniques: cubic spline, B-spline interpolation and TOPPRA time-optimal trajectory planning for robot arms.

Nguyễn Anh Tuấn1 tháng 4, 20267 min read
Advanced Trajectories: Spline, B-Spline & Time-Optimal Planning

In the previous posts, we built up from basic motion profiles to velocity blending. But when the robot needs to follow complex multi-waypoint paths — spray painting curved surfaces, CNC machining 3D surfaces, or time-optimal movements — we need advanced techniques: Spline interpolation and Time-Optimal Planning.

This post dives deep into cubic spline, B-spline, NURBS, and especially the TOPPRA algorithm — the state-of-the-art for time-optimal trajectory parameterization.

Advanced trajectory

Cubic Spline Interpolation

The Problem

Given $n$ waypoints, we want to create a smooth curve (C2 continuous) that passes through every point. A single high-degree polynomial (degree $n-1$) suffers from Runge's phenomenon — large oscillations between points.

Solution: Piecewise Cubic Spline

Instead of one high-degree polynomial, use many cubic polynomials, one between each pair of adjacent waypoints:

S_i(t) = a_i + b_i(t - t_i) + c_i(t - t_i)² + d_i(t - t_i)³

With constraints:

import numpy as np
from scipy.interpolate import CubicSpline
import matplotlib.pyplot as plt

# 10 waypoints for robot arm (joint 1 position)
t_waypoints = np.array([0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5])
q_waypoints = np.array([0, 15, 45, 60, 50, 30, 55, 70, 45, 10])  # degrees

# Cubic spline interpolation
cs = CubicSpline(t_waypoints, q_waypoints, bc_type='clamped')

# Dense evaluation
t_dense = np.linspace(0, 4.5, 1000)
q_dense = cs(t_dense)
qd_dense = cs(t_dense, 1)
qdd_dense = cs(t_dense, 2)
qddd_dense = cs(t_dense, 3)

fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True)

axes[0].plot(t_dense, q_dense, 'b-', linewidth=2)
axes[0].scatter(t_waypoints, q_waypoints, c='red', s=80, zorder=5,
               label='Waypoints')
axes[0].set_ylabel('Position (deg)')
axes[0].set_title('Cubic Spline Through 10 Waypoints')
axes[0].legend()
axes[0].grid(True)

axes[1].plot(t_dense, qd_dense, 'r-', linewidth=2)
axes[1].set_ylabel('Velocity (deg/s)')
axes[1].grid(True)

axes[2].plot(t_dense, qdd_dense, 'g-', linewidth=2)
axes[2].set_ylabel('Acceleration (deg/s²)')
axes[2].grid(True)

axes[3].plot(t_dense, qddd_dense, 'm-', linewidth=1.5)
axes[3].set_ylabel('Jerk (deg/s³)')
axes[3].set_xlabel('Time (s)')
axes[3].grid(True)

plt.tight_layout()
plt.savefig('cubic_spline.png', dpi=150)
plt.show()

Multi-Joint Spline Trajectory

import roboticstoolbox as rtb
import numpy as np
from scipy.interpolate import CubicSpline
import matplotlib.pyplot as plt

ur5e = rtb.models.UR5()

waypoints = np.array([
    [0, -np.pi/3, np.pi/3, -np.pi/2, np.pi/2, 0],
    [np.pi/6, -np.pi/4, np.pi/4, -np.pi/3, np.pi/3, np.pi/6],
    [np.pi/3, -np.pi/6, np.pi/6, -np.pi/4, np.pi/4, np.pi/3],
    [np.pi/4, -np.pi/3, np.pi/2, -np.pi/2, np.pi/3, np.pi/6],
    [np.pi/6, -np.pi/4, np.pi/3, -np.pi/3, np.pi/4, np.pi/4],
    [0, -np.pi/3, np.pi/3, -np.pi/2, np.pi/2, 0],
])

t_way = np.linspace(0, 5, len(waypoints))
t_dense = np.linspace(0, 5, 500)
q_traj = np.zeros((len(t_dense), 6))

for j in range(6):
    cs = CubicSpline(t_way, waypoints[:, j], bc_type='clamped')
    q_traj[:, j] = cs(t_dense)

tcp_positions = []
for q in q_traj:
    T = ur5e.fkine(q)
    tcp_positions.append(T.t)
tcp_positions = np.array(tcp_positions)

fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
ax.plot(tcp_positions[:, 0], tcp_positions[:, 1], tcp_positions[:, 2],
        'b-', linewidth=2, label='Spline TCP path')

for q in waypoints:
    T = ur5e.fkine(q)
    ax.scatter(*T.t, c='red', s=80, zorder=5)

ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')
ax.set_title('Multi-Joint Cubic Spline — TCP Path')
ax.legend()
plt.tight_layout()
plt.savefig('multi_joint_spline.png', dpi=150)
plt.show()

B-Spline — Smoother Curves, Non-Interpolating

Differences from Cubic Spline

Property Cubic Spline B-Spline
Passes through control points Yes (interpolating) No (approximating)
Smoothness C2 C(k-1) for degree k
Local control Weak Strong (changing 1 point only affects local region)
Convex hull Not guaranteed Curve lies within convex hull

B-Spline does not pass through control points but is "attracted" toward them — creating curves that are smoother and easier to control.

from scipy.interpolate import make_interp_spline
import numpy as np
import matplotlib.pyplot as plt

control_points = np.array([
    [0.0, 0.0], [0.1, 0.3], [0.3, 0.5], [0.5, 0.2],
    [0.7, 0.6], [0.9, 0.4], [1.0, 0.0],
])

t_ctrl = np.linspace(0, 1, len(control_points))
bspline = make_interp_spline(t_ctrl, control_points, k=3)

t_dense = np.linspace(0, 1, 500)
path = bspline(t_dense)

cs_x = CubicSpline(t_ctrl, control_points[:, 0])
cs_y = CubicSpline(t_ctrl, control_points[:, 1])

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(path[:, 0], path[:, 1], 'b-', linewidth=2, label='B-Spline')
ax.plot(cs_x(t_dense), cs_y(t_dense), 'r--', linewidth=2, label='Cubic Spline')
ax.scatter(control_points[:, 0], control_points[:, 1],
           c='green', s=100, zorder=5, label='Control Points')
ax.set_title('B-Spline vs Cubic Spline')
ax.legend()
ax.grid(True)
ax.set_aspect('equal')
plt.tight_layout()
plt.savefig('bspline_vs_cubic.png', dpi=150)
plt.show()

NURBS — Non-Uniform Rational B-Splines

NURBS extends B-Splines with:

NURBS is the standard in CAD/CAM and CNC robotics.

Spline curves

Time-Optimal Trajectory Planning — TOPPRA

The Problem

Given a geometric path (from a spline), find the time parameterization $s(t)$ such that:

TOPPRA Algorithm (Pham & Pham, 2018)

TOPPRA (Time-Optimal Path Parameterization based on Reachability Analysis) is state-of-the-art:

import numpy as np
import toppra
import toppra.constraint as constraint
import toppra.algorithm as algo
import matplotlib.pyplot as plt

# Define geometric path (spline through waypoints)
waypoints = np.array([
    [0, -np.pi/3, np.pi/3, -np.pi/2, np.pi/2, 0],
    [np.pi/6, -np.pi/4, np.pi/4, -np.pi/3, np.pi/3, np.pi/6],
    [np.pi/3, -np.pi/6, np.pi/6, -np.pi/4, np.pi/4, np.pi/3],
    [np.pi/4, -np.pi/3, np.pi/2, -np.pi/2, np.pi/3, np.pi/6],
    [np.pi/6, -np.pi/4, np.pi/3, -np.pi/3, np.pi/4, np.pi/4],
    [0, -np.pi/3, np.pi/3, -np.pi/2, np.pi/2, 0],
])

ss = np.linspace(0, 1, len(waypoints))
path = toppra.SplineInterpolator(ss, waypoints)

# Velocity and acceleration constraints
vlim = np.array([[-2.0, 2.0]] * 2 + [[-3.0, 3.0]] * 4)
alim = np.array([[-5.0, 5.0]] * 2 + [[-10.0, 10.0]] * 4)

pc_vel = constraint.JointVelocityConstraint(vlim)
pc_acc = constraint.JointAccelerationConstraint(alim)

# Run TOPPRA
instance = algo.TOPPRA([pc_vel, pc_acc], path,
                        parametrizer="ParametrizeConstAccel")
trajectory = instance.compute_trajectory()

if trajectory is not None:
    print(f"Time-optimal duration: {trajectory.duration:.3f} s")

    t_grid = np.linspace(0, trajectory.duration, 500)
    q_traj = trajectory(t_grid)
    qd_traj = trajectory(t_grid, 1)
    qdd_traj = trajectory(t_grid, 2)

    fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)

    for j in range(6):
        axes[0].plot(t_grid, np.degrees(q_traj[:, j]), label=f'J{j+1}')
        axes[1].plot(t_grid, qd_traj[:, j], label=f'J{j+1}')
        axes[2].plot(t_grid, qdd_traj[:, j], label=f'J{j+1}')

    axes[0].set_ylabel('Position (deg)')
    axes[0].set_title(f'TOPPRA Time-Optimal Trajectory (T={trajectory.duration:.2f}s)')
    axes[0].legend(ncol=3)
    axes[0].grid(True)

    axes[1].set_ylabel('Velocity (rad/s)')
    axes[1].grid(True)

    axes[2].set_ylabel('Acceleration (rad/s²)')
    axes[2].set_xlabel('Time (s)')
    axes[2].grid(True)

    plt.tight_layout()
    plt.savefig('toppra_trajectory.png', dpi=150)
    plt.show()

Minimum-Time vs Minimum-Jerk Tradeoff

Criterion Time-Optimal (TOPPRA) Minimum-Jerk
Objective As fast as possible As smooth as possible
Cycle time Minimum Longer
Vibration Can be high (accel at limits) Very low
Use when High throughput, rigid payload Delicate payload, precision
Computation O(n) — fast Depends on method

Application: Spray Painting Complex Shapes

import numpy as np
import matplotlib.pyplot as plt

def generate_painting_path(surface_func, u_range, v_range,
                           n_passes, standoff=0.05):
    """Generate spray painting path over a surface."""
    v_values = np.linspace(v_range[0], v_range[1], n_passes)
    u_dense = np.linspace(u_range[0], u_range[1], 50)
    all_points = []

    for i, v in enumerate(v_values):
        u_range_dir = u_dense if i % 2 == 0 else u_dense[::-1]
        for u in u_range_dir:
            point = surface_func(u, v)
            point[2] += standoff
            all_points.append(point)

    return np.array(all_points)

def curved_surface(u, v):
    return np.array([u, v, 0.1 * np.sin(2*np.pi*u) * np.cos(np.pi*v)])

path = generate_painting_path(curved_surface, (0, 0.5), (0, 0.3), 8, 0.03)

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')

u_grid = np.linspace(0, 0.5, 50)
v_grid = np.linspace(0, 0.3, 50)
U, V = np.meshgrid(u_grid, v_grid)
Z = 0.1 * np.sin(2*np.pi*U) * np.cos(np.pi*V)
ax.plot_surface(U, V, Z, alpha=0.2, color='gray')

ax.plot(path[:, 0], path[:, 1], path[:, 2], 'r-', linewidth=1.5,
        label='Painting path')
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')
ax.set_title('Spray Painting — Spline Path Over Curved Surface')
ax.legend()
plt.tight_layout()
plt.savefig('painting_path.png', dpi=150)
plt.show()

References

Conclusion

Advanced trajectory planning techniques:

In the final post of this series, we will integrate all knowledge into MoveIt2, URScript and real robot deployment.

Related Posts

Related Posts

Deep DiveMotion Profiles: Trapezoidal, S-Curve và Polynomial Trajectories
trapezoidals-curvetrajectorymotion-profilePart 6

Motion Profiles: Trapezoidal, S-Curve và Polynomial Trajectories

So sánh chi tiết 3 loại motion profile phổ biến — trapezoidal, S-curve và polynomial — với code Python từ đầu.

28/3/202612 min read
TutorialMoveC và Circular Motion: Arc, Helix và Orbital Paths
moveccircularrobot-armtrajectoryarcPart 4

MoveC và Circular Motion: Arc, Helix và Orbital Paths

Hướng dẫn chi tiết MoveC — chuyển động cung tròn, helix và orbital cho hàn, đánh bóng và lắp ráp với robot arm.

20/3/202611 min read
TutorialMoveJ và MoveL: Joint Motion và Linear Motion chi tiết
movejmovelrobot-armtrajectoryurscriptPart 3

MoveJ và MoveL: Joint Motion và Linear Motion chi tiết

Hiểu sâu MoveJ (joint interpolation) và MoveL (linear interpolation) — hai lệnh nền tảng trong lập trình robot công nghiệp.

16/3/202610 min read