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$
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.
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)