From Calculus to Code: A Circle Rolling on a Parabola

Category
Roulette of a Circle Rolling on a Parabola
Back to top

Introduction

This post is a follow-up to the discussion on Cycloidal Curves, where we explored circles rolling along straight paths or other circular orbits. The mathematics governing those roulettes is relatively straightforward because the rolling paths are defined by simple geometric relationships. For example, when a circle rolls on another circle without slipping, the distance traveled along the path is described by the simple formula $s = r\theta$.

I began to wonder: what happens if a circle rolls along a path with a more complex geometry? The first curve that came to mind was the parabola. Let’s explore the derivation for a Parabolic Roulette.

Back to top

Case 1: The rolling circle is above the parabola

Given the parabola:
$y = kx^2$
$y' = 2kx$

The angle of tangent line $\alpha$ is given by:
$\tan \alpha = 2kx$
$\alpha = \arctan 2kx$

parabolic-cycloid-case-1.png

 

Center of Rolling Circle $(x_C, y_C)$
Using the normal line to the curve at point $(x, y)$:
$x_C = x - r \sin \alpha$
$y_C = y + r \cos \alpha$

Arc Length, $s$:
To determine how far the circle has rolled, we calculate the arc length of the parabola from the vertex:
$s = \displaystyle \int \sqrt{1 + (y')^2} \, dx = \int \sqrt{1 + (2kx)^2} \, dx$
$s = \dfrac{1}{4k} \left[ 2kx \sqrt{1 + (2kx)^2} + \ln \left( 2kx + \sqrt{1 + (2kx)^2} \right) \right]$

(See the "Details of Integration" below for the full trigonometric substitution steps.)

Angle of Rotation $\beta$:
Assuming the circle rolls without slipping, the arc length $s$ must equal the circular arc $r \beta$:
$r \beta = s$
$\beta = s/r$

Coordinates of the Tracing Point $P$:
To find the position of point $P(x_P, y_P)$, we define the angle $\theta$ relative to the circle's center:
$\theta + \beta - \alpha + \dfrac{\pi}{2} = 2\pi$
$\implies \theta = \dfrac{3\pi}{2} + \alpha - \beta$

$x_P = x_C + r \cos \theta$
$y_P = y_C + r \sin \theta$

 

Back to top

Case 2: The rolling circle is below the parabola

If the circle rolls on the concave side (underneath) of the parabola, the logic remains the same, but the orientation of the center and the rotation angle adjust accordingly.

parabolic-cycloid-case-2.png

 

With reference to the above figure.

Center of rolling circle:
$x_C = x + r \sin \alpha$
$y_C = y - r \cos \alpha$
 

Location of $P$:
$\theta = \dfrac{\pi}{2} + \alpha + \beta$

 

While the parametric equations for the coordinates of $P$ remain identical to those in Case 1, they rely on a different geometric definition for $\theta$.

$x_P = x_C + r \cos \theta$
$y_P = y_C + r \sin \theta$

 

Back to top

Animate in Manim

The ultimate goal of this post is to animate the parabolic roulette with mathematical precision. The code shown below was developed with AI assistance, while its mathematical formulation was carefully verified by MATHalino. Since the focus of this article is the mathematics behind the curve, the discussion that follows highlights how the key calculus results are translated into Python code.

Component Equation Python Code
Parabola $y = kx^2$
def y_parabola(x): return k * x**2
Tangent angle $\alpha = \arctan (2kx)$
def alpha(x): return np.arctan(2 * k * x)
Arc length $\displaystyle s = \int \sqrt{1+(2kx)^2} \, dx$
def arc_length(x):
    return 0.5 * x * np.sqrt(1 + 4 * k**2 * x**2)
        + np.arcsinh(2 * k * x) / (4 * k)
Center above parabola $x_C = x - r \sin \alpha$
$y_C = y + r \cos \alpha$
def center_point_above(x):
    a = alpha(x)
    return np.array([
        x - r * np.sin(a),
        y_parabola(x) + r * np.cos(a),
        0
    ])
Center below parabola $x_C = x + r \sin \alpha$
$y_C = y - r \cos \alpha$
def center_point_below(x):
    a = alpha(x)
    return np.array([
        x + r * np.sin(a),
        y_parabola(x) - r * np.cos(a),
        0
    ])
Tracing angle above $\theta = \frac{3\pi}{2} + \alpha - \frac{s}{r}$ inside tracer_point_above(x):
theta = 1.5 * PI + a - s / r
Tracing angle below $\theta = \frac{\pi}{2} + \alpha + \frac{s}{r}$ inside tracer_point_below(x):
theta = PI / 2 + a + s / r
Coordinates of $(x_P, y_P)$ $x_P = x_C + r \cos \theta$
$y_P = y_C + r \sin \theta$
c = center_point_above(x) # For Case 1
c = center_point_below(x) # For Case 2
return np.array([
    c[0] + r * np.cos(theta),
    c[1] + r * np.sin(theta),
    0

Video Output

 

Full code for Manim

from manim import *
import numpy as np

class ParabolicRouletteCombinedSideBySide(Scene):
    def construct(self):
        # -----------------------------
        # Parameters
        # -----------------------------
        k = 0.25
        r = 1.0
        x_max = 18.0

        parabola_color = RED
        circle_color_above = BLUE
        tracer_color_above = YELLOW

        circle_color_below = YELLOW
        tracer_color_below = BLUE

        LEFT_SHIFT = 14
        RIGHT_SHIFT = 14

        # -----------------------------
        # Camera / framing
        # -----------------------------
        self.camera.frame_width = 56
        self.camera.frame_height = 32
        self.camera.frame_center = np.array([0, 13, 0])

        # -----------------------------
        # Helper functions
        # -----------------------------
        def y_parabola(x):
            return k * x**2

        def alpha(x):
            return np.arctan(2 * k * x)

        def arc_length(x):
            return 0.5 * x * np.sqrt(1 + 4 * k**2 * x**2) + np.arcsinh(2 * k * x) / (4 * k)

        def shift_left(p):
            return p + np.array([-LEFT_SHIFT, 0, 0])

        def shift_right(p):
            return p + np.array([RIGHT_SHIFT, 0, 0])

        # -------- ABOVE scene (left panel) --------
        def center_point_above(x):
            a = alpha(x)
            return np.array([
                x - r * np.sin(a),
                y_parabola(x) + r * np.cos(a),
                0
            ])

        def tracer_point_above(x):
            a = alpha(x)
            s = arc_length(x)
            theta = 1.5 * PI + a - s / r
            c = center_point_above(x)
            return np.array([
                c[0] + r * np.cos(theta),
                c[1] + r * np.sin(theta),
                0
            ])

        def mirrored_center_point_above(x):
            c = center_point_above(x)
            return np.array([-c[0], c[1], 0])

        def mirrored_tracer_point_above(x):
            p = tracer_point_above(x)
            return np.array([-p[0], p[1], 0])

        # -------- BELOW scene (right panel) --------
        def center_point_below(x):
            a = alpha(x)
            return np.array([
                x + r * np.sin(a),
                y_parabola(x) - r * np.cos(a),
                0
            ])

        def tracer_point_below(x):
            a = alpha(x)
            s = arc_length(x)
            theta = PI / 2 + a + s / r
            c = center_point_below(x)
            return np.array([
                c[0] + r * np.cos(theta),
                c[1] + r * np.sin(theta),
                0
            ])

        def mirrored_center_point_below(x):
            c = center_point_below(x)
            return np.array([-c[0], c[1], 0])

        def mirrored_tracer_point_below(x):
            p = tracer_point_below(x)
            return np.array([-p[0], p[1], 0])

        # -----------------------------
        # Parabolas
        # -----------------------------
        parabola_left_right = ParametricFunction(
            lambda t: shift_left(np.array([t, y_parabola(t), 0])),
            t_range=[0, x_max],
            color=parabola_color,
            stroke_width=5,
        )

        parabola_left_left = ParametricFunction(
            lambda t: shift_left(np.array([-t, y_parabola(t), 0])),
            t_range=[0, x_max],
            color=parabola_color,
            stroke_width=5,
            #stroke_opacity=0.35,
        )

        parabola_right_right = ParametricFunction(
            lambda t: shift_right(np.array([t, y_parabola(t), 0])),
            t_range=[0, x_max],
            color=parabola_color,
            stroke_width=5,
        )

        parabola_right_left = ParametricFunction(
            lambda t: shift_right(np.array([-t, y_parabola(t), 0])),
            t_range=[0, x_max],
            color=parabola_color,
            stroke_width=5,
            #stroke_opacity=0.35,
        )

        # -----------------------------
        # Trackers
        # -----------------------------
        x_tracker_left = ValueTracker(0)
        x_tracker_right = ValueTracker(0)

        # -----------------------------
        # Left panel: ABOVE
        # -----------------------------
        rolling_circle_above = always_redraw(
            lambda: Circle(radius=r, color=circle_color_above, stroke_width=4)
            .move_to(shift_left(center_point_above(x_tracker_left.get_value())))
        )

        center_dot_above = always_redraw(
            lambda: Dot(
                shift_left(center_point_above(x_tracker_left.get_value())),
                color=circle_color_above,
                radius=0.05
            )
        )

        tracer_dot_above = always_redraw(
            lambda: Dot(
                shift_left(tracer_point_above(x_tracker_left.get_value())),
                color=tracer_color_above,
                radius=0.07
            )
        )

        radius_line_above = always_redraw(
            lambda: Line(
                shift_left(center_point_above(x_tracker_left.get_value())),
                shift_left(tracer_point_above(x_tracker_left.get_value())),
                color=circle_color_above,
                stroke_width=4
            )
        )

        trace_above = TracedPath(
            tracer_dot_above.get_center,
            stroke_color=tracer_color_above,
            stroke_width=5
        )

        rolling_circle_above_mirror = always_redraw(
            lambda: Circle(
                radius=r,
                color=circle_color_above,
                stroke_width=3,
                #stroke_opacity=0.35
            ).move_to(shift_left(mirrored_center_point_above(x_tracker_left.get_value())))
        )

        tracer_dot_above_mirror = always_redraw(
            lambda: Dot(
                shift_left(mirrored_tracer_point_above(x_tracker_left.get_value())),
                color=tracer_color_above,
                radius=0.06
            )#.set_opacity(0.35)
        )

        radius_line_above_mirror = always_redraw(
            lambda: Line(
                shift_left(mirrored_center_point_above(x_tracker_left.get_value())),
                shift_left(mirrored_tracer_point_above(x_tracker_left.get_value())),
                color=circle_color_above,
                stroke_width=3,
                #stroke_opacity=0.35
            )
        )

        trace_above_mirror = TracedPath(
            tracer_dot_above_mirror.get_center,
            stroke_color=tracer_color_above,
            stroke_width=4,
            #stroke_opacity=0.35
        )

        # -----------------------------
        # Right panel: BELOW
        # -----------------------------
        rolling_circle_below = always_redraw(
            lambda: Circle(radius=r, color=circle_color_below, stroke_width=4)
            .move_to(shift_right(center_point_below(x_tracker_right.get_value())))
        )

        center_dot_below = always_redraw(
            lambda: Dot(
                shift_right(center_point_below(x_tracker_right.get_value())),
                color=circle_color_below,
                radius=0.055
            )
        )

        tracer_dot_below = always_redraw(
            lambda: Dot(
                shift_right(tracer_point_below(x_tracker_right.get_value())),
                color=tracer_color_below,
                radius=0.07
            )
        )

        radius_line_below = always_redraw(
            lambda: Line(
                shift_right(center_point_below(x_tracker_right.get_value())),
                shift_right(tracer_point_below(x_tracker_right.get_value())),
                color=circle_color_below,
                stroke_width=4
            )
        )

        trace_below = TracedPath(
            tracer_dot_below.get_center,
            stroke_color=tracer_color_below,
            stroke_width=5
        )

        rolling_circle_below_mirror = always_redraw(
            lambda: Circle(
                radius=r,
                color=circle_color_below,
                stroke_width=3,
                #stroke_opacity=0.35
            ).move_to(shift_right(mirrored_center_point_below(x_tracker_right.get_value())))
        )

        tracer_dot_below_mirror = always_redraw(
            lambda: Dot(
                shift_right(mirrored_tracer_point_below(x_tracker_right.get_value())),
                color=tracer_color_below,
                radius=0.06
            )#.set_opacity(0.35)
        )

        radius_line_below_mirror = always_redraw(
            lambda: Line(
                shift_right(mirrored_center_point_below(x_tracker_right.get_value())),
                shift_right(mirrored_tracer_point_below(x_tracker_right.get_value())),
                color=circle_color_below,
                stroke_width=3,
                #stroke_opacity=0.35
            )
        )

        trace_below_mirror = TracedPath(
            tracer_dot_below_mirror.get_center,
            stroke_color=tracer_color_below,
            stroke_width=4,
            #stroke_opacity=0.35
        )
        
        # -----------------------------
        # Add objects
        # -----------------------------
        self.add(
            parabola_left_left, parabola_left_right,
            parabola_right_left, parabola_right_right
        )

        self.add(
            trace_above_mirror, trace_above,
            trace_below_mirror, trace_below
        )

        self.add(
            rolling_circle_above_mirror, radius_line_above_mirror, tracer_dot_above_mirror,
            rolling_circle_above, radius_line_above, center_dot_above, tracer_dot_above,

            rolling_circle_below_mirror, radius_line_below_mirror, tracer_dot_below_mirror,
            rolling_circle_below, radius_line_below, center_dot_below, tracer_dot_below
        )

        # -----------------------------
        # Animate
        # -----------------------------
        self.play(
            x_tracker_left.animate.set_value(x_max),
            x_tracker_right.animate.set_value(x_max),
            run_time=12,
            rate_func=linear
        )

        self.wait(2)
Back to top