← Back to Blog
manipulationmoveccircularrobot-armtrajectoryarc

MoveC & Circular Motion: Arc, Helix & Orbital Paths

Detailed guide to MoveC — circular arc, helix and orbital trajectories for welding, polishing and assembly with robot arms.

Nguyễn Anh Tuấn20 tháng 3, 20268 min read
MoveC & Circular Motion: Arc, Helix & Orbital Paths

After mastering MoveJ and MoveL, you can program robots to move in straight lines and perform free movements. But the real world is not just straight lines — many industrial applications require circular motion: curved weld seams, polishing round surfaces, screw driving, and orbital inspection. That is where MoveC comes in.

Circular welding robot

MoveC — Circular Arc Motion

How It Works

MoveC creates motion along a circular arc through 3 points:

  1. Start point ($P_0$): Current TCP position
  2. Via point ($P_1$): Intermediate point on the arc
  3. End point ($P_2$): Target point

Three points uniquely define a plane and a circle. The controller computes:

Algorithm: Finding Circle Center from 3 Points

import numpy as np

def circle_from_3_points(p0, p1, p2):
    """
    Find center and radius of circle through 3 points in 3D.

    Returns:
        center: circle center [x, y, z]
        radius: radius
        normal: plane normal vector
    """
    v1 = p1 - p0
    v2 = p2 - p0

    normal = np.cross(v1, v2)
    normal = normal / np.linalg.norm(normal)

    m1 = (p0 + p1) / 2
    m2 = (p0 + p2) / 2

    d1 = np.cross(v1, normal)
    d2 = np.cross(v2, normal)

    A = np.column_stack([d1, -d2])
    b = m2 - m1
    params = np.linalg.lstsq(A, b, rcond=None)[0]

    center = m1 + params[0] * d1
    radius = np.linalg.norm(center - p0)

    return center, radius, normal

p0 = np.array([0.4, 0.0, 0.3])
p1 = np.array([0.3, 0.1, 0.3])
p2 = np.array([0.2, 0.0, 0.3])

center, radius, normal = circle_from_3_points(p0, p1, p2)
print(f"Center: {center}")
print(f"Radius: {radius:.4f} m")
print(f"Normal: {normal}")

URScript MoveC

# URScript
# movec(pose_via, pose_to, a=1.2, v=0.25, r=0, mode=0)
# pose_via: via point (intermediate)
# pose_to: end point (target)
# a: acceleration [m/s²]
# v: velocity [m/s]
# r: blend radius [m]
# mode: 0 = unconstrained, 1 = fixed orientation

# Simple arc
movec(p[0.3, 0.1, 0.3, 0, 3.14, 0],    # Via point
      p[0.2, 0.0, 0.3, 0, 3.14, 0],    # End point
      a=1.2, v=0.1)

# Full circle (via = opposite point through center)
movec(p[0.2, 0.0, 0.3, 0, 3.14, 0],
      p[0.4, 0.0, 0.3, 0, 3.14, 0],
      a=1.2, v=0.05)

Python Implementation

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

def generate_arc_trajectory(p0, p1, p2, n_points=100):
    """Generate circular arc trajectory through 3 points."""
    center, radius, normal = circle_from_3_points(p0, p1, p2)

    v_start = p0 - center
    v_end = p2 - center

    u = v_start / np.linalg.norm(v_start)
    w = np.cross(normal, u)

    theta_start = np.arctan2(np.dot(v_start, w), np.dot(v_start, u))
    theta_end = np.arctan2(np.dot(v_end, w), np.dot(v_end, u))

    v_via = p1 - center
    theta_via = np.arctan2(np.dot(v_via, w), np.dot(v_via, u))

    if theta_end < theta_start:
        if theta_via > theta_start or theta_via < theta_end:
            pass
        else:
            theta_end += 2 * np.pi
    else:
        if theta_start < theta_via < theta_end:
            pass
        else:
            theta_end -= 2 * np.pi

    thetas = np.linspace(theta_start, theta_end, n_points)
    positions = []
    for theta in thetas:
        point = center + radius * (np.cos(theta) * u + np.sin(theta) * w)
        positions.append(point)

    return np.array(positions), center, radius

p0 = np.array([0.4, 0.0, 0.3])
p1 = np.array([0.3, 0.1, 0.3])
p2 = np.array([0.2, 0.0, 0.3])

arc_points, center, radius = generate_arc_trajectory(p0, p1, p2)

fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
ax.plot(arc_points[:, 0], arc_points[:, 1], arc_points[:, 2],
        'b-', linewidth=2, label='Arc path')
ax.scatter(*p0, c='green', s=100, zorder=5, label='Start')
ax.scatter(*p1, c='orange', s=100, zorder=5, label='Via')
ax.scatter(*p2, c='red', s=100, zorder=5, label='End')
ax.scatter(*center, c='purple', s=80, marker='+', zorder=5, label='Center')
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')
ax.set_title(f'MoveC Arc Trajectory (r={radius:.3f}m)')
ax.legend()
plt.tight_layout()
plt.savefig('movec_arc.png', dpi=150)
plt.show()

Full Circle vs Partial Arc

Partial Arc (Most Common)

A partial circular arc — for example welding 1/4 or 1/2 of a circle:

# URScript — welding a 90-degree arc
movel(p_start_weld, a=0.5, v=0.01)  # Move to weld start
set_digital_out(1, True)            # Enable welding torch
movec(p_via_weld, p_end_weld, a=0.3, v=0.005)  # Weld along arc
set_digital_out(1, False)           # Disable welding torch
movel(p_retreat, a=1.2, v=0.25)     # Retreat

Full Circle

For a complete circle, end point = start point, via point = opposite point:

def generate_full_circle(center, radius, normal, n_points=200):
    """Generate full circle trajectory."""
    u = np.array([1, 0, 0])
    if abs(np.dot(u, normal)) > 0.9:
        u = np.array([0, 1, 0])
    u = u - np.dot(u, normal) * normal
    u = u / np.linalg.norm(u)
    w = np.cross(normal, u)

    thetas = np.linspace(0, 2*np.pi, n_points, endpoint=False)
    positions = []
    for theta in thetas:
        point = center + radius * (np.cos(theta) * u + np.sin(theta) * w)
        positions.append(point)

    return np.array(positions)

circle_path = generate_full_circle(
    center=np.array([0.3, 0.0, 0.3]),
    radius=0.1,
    normal=np.array([0, 0, 1])
)

Circular motion industrial

Helix — Circular + Linear Motion

A helix (spiral) combines circular motion with linear movement along an axis. Applications:

def generate_helix(center, radius, normal, pitch, n_turns, n_points=500):
    """
    Generate helix trajectory.

    Args:
        center: start center position
        radius: helix radius
        normal: axis direction (unit vector)
        pitch: distance traveled along axis per revolution
        n_turns: number of turns
        n_points: number of trajectory points
    """
    u = np.array([1, 0, 0])
    if abs(np.dot(u, normal)) > 0.9:
        u = np.array([0, 1, 0])
    u = u - np.dot(u, normal) * normal
    u = u / np.linalg.norm(u)
    w = np.cross(normal, u)

    total_angle = 2 * np.pi * n_turns
    thetas = np.linspace(0, total_angle, n_points)

    positions = []
    for theta in thetas:
        circle = radius * (np.cos(theta) * u + np.sin(theta) * w)
        linear = (theta / (2 * np.pi)) * pitch * normal
        positions.append(center + circle + linear)

    return np.array(positions)

# Helix for screw driving
helix_path = generate_helix(
    center=np.array([0.3, 0.2, 0.3]),
    radius=0.005,    # 5mm radius (small — simulating screw driving)
    normal=np.array([0, 0, -1]),  # Downward
    pitch=0.001,     # 1mm per revolution (thread pitch)
    n_turns=5
)

# Helix for polishing
polishing_helix = generate_helix(
    center=np.array([0.3, 0.0, 0.3]),
    radius=0.05,
    normal=np.array([0, 0, 1]),
    pitch=0.02,
    n_turns=3
)

fig = plt.figure(figsize=(14, 6))

ax1 = fig.add_subplot(121, projection='3d')
ax1.plot(helix_path[:, 0], helix_path[:, 1], helix_path[:, 2],
         'b-', linewidth=1.5)
ax1.set_title('Screw Driving Helix')

ax2 = fig.add_subplot(122, projection='3d')
ax2.plot(polishing_helix[:, 0], polishing_helix[:, 1], polishing_helix[:, 2],
         'r-', linewidth=1.5)
ax2.set_title('Polishing Helix')

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

Orbital Paths — Inspection and Scanning

Orbital path — the robot moves on a circular orbit around an object, keeping the tool pointed toward the center. Applications:

def generate_orbital_path(object_center, orbit_radius, orbit_height,
                          n_points=100, n_layers=3, layer_spacing=0.05):
    """
    Generate orbital inspection path.
    Robot orbits around object, looking inward.
    """
    positions = []
    orientations = []

    for layer in range(n_layers):
        z = orbit_height + layer * layer_spacing
        thetas = np.linspace(0, 2*np.pi, n_points, endpoint=False)

        for theta in thetas:
            x = object_center[0] + orbit_radius * np.cos(theta)
            y = object_center[1] + orbit_radius * np.sin(theta)
            positions.append([x, y, z])

            look_dir = object_center[:2] - np.array([x, y])
            yaw = np.arctan2(look_dir[1], look_dir[0])
            orientations.append(yaw)

    return np.array(positions), orientations

orbit_pos, orbit_orient = generate_orbital_path(
    object_center=np.array([0.3, 0.0, 0.2]),
    orbit_radius=0.15,
    orbit_height=0.15,
    n_points=50,
    n_layers=3,
    layer_spacing=0.04
)

fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
ax.plot(orbit_pos[:, 0], orbit_pos[:, 1], orbit_pos[:, 2],
        'b-', linewidth=1, alpha=0.7)
ax.scatter(0.3, 0.0, 0.2, c='red', s=200, marker='*', label='Object')
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_zlabel('Z (m)')
ax.set_title('Orbital Inspection Path (3 layers)')
ax.legend()
plt.tight_layout()
plt.savefig('orbital_path.png', dpi=150)
plt.show()

Common Pitfalls

1. Collinear Points

If the 3 points lie on a straight line, a circle cannot be determined — MoveC will error.

def check_collinear(p0, p1, p2, threshold=1e-6):
    """Check if 3 points are collinear."""
    v1 = p1 - p0
    v2 = p2 - p0
    cross = np.linalg.norm(np.cross(v1, v2))
    return cross < threshold

# BAD — 3 collinear points
p0 = np.array([0.2, 0.0, 0.3])
p1 = np.array([0.3, 0.0, 0.3])
p2 = np.array([0.4, 0.0, 0.3])
print(f"Collinear: {check_collinear(p0, p1, p2)}")  # True — ERROR!

2. Orientation Flipping

During circular motion, orientation can "flip" abruptly — especially when the arc exceeds 180 degrees.

Solution: Use mode=1 (fixed orientation) in URScript or divide the arc into smaller segments.

3. Radius Too Large/Small

Practical Applications

Circular Weld Seam

# URScript — circular weld seam
def circular_weld(center, radius, weld_speed=0.005):
    """Weld a circular seam."""
    import math

    start_x = center[0] + radius
    movel(p[start_x, center[1], center[2]+0.05, 0, 3.14, 0],
          a=1.2, v=0.25)
    movel(p[start_x, center[1], center[2], 0, 3.14, 0],
          a=0.5, v=0.05)

    set_digital_out(1, True)
    sleep(0.5)

    movec(
        p[center[0], center[1]+radius, center[2], 0, 3.14, 0],
        p[center[0]-radius, center[1], center[2], 0, 3.14, 0],
        a=0.3, v=weld_speed
    )
    movec(
        p[center[0], center[1]-radius, center[2], 0, 3.14, 0],
        p[start_x, center[1], center[2], 0, 3.14, 0],
        a=0.3, v=weld_speed
    )

    set_digital_out(1, False)
    movel(p[start_x, center[1], center[2]+0.05, 0, 3.14, 0],
          a=1.2, v=0.25)

Welding application

References

Conclusion

MoveC extends robot programming capabilities beyond straight lines:

In the next post, we will learn how to blend motion segments — the most important technique for optimizing cycle time while maintaining smooth motion.

Related Posts

Related Posts

TutorialIntegration: ROS 2 MoveIt2, URScript và Real Robot Deploy
ros2moveit2urscriptrobot-armdeploymentPart 8

Integration: ROS 2 MoveIt2, URScript và Real Robot Deploy

Tổng hợp toàn bộ series: tích hợp trajectory planning với MoveIt2, URScript và deploy lên robot thật qua ros2_control.

5/4/202611 min read
Deep DiveAdvanced Trajectories: Spline, B-Spline và Time-Optimal Planning
splineb-splinetime-optimaltrajectoryPart 7

Advanced Trajectories: Spline, B-Spline và Time-Optimal Planning

Kỹ thuật nâng cao: cubic spline, B-spline interpolation và TOPPRA time-optimal trajectory planning cho robot arm.

1/4/202610 min read
ISO 10218 thực hành: Risk Assessment cho robot hàn
safetyrobot-armstandards

ISO 10218 thực hành: Risk Assessment cho robot hàn

Hướng dẫn thực hiện risk assessment theo ISO 10218 cho cell robot hàn — từ hazard identification đến safety measures.

28/3/202613 min read