Fernet Frames on Helix¶

A helix can be desribred as the set points

$$(x,y,z) = (r \cos(\theta), r \sin(\theta), t \cdot \theta))$$

where the constants $r,t$ describe the radius & slope.

It has tangent vector $$ T = (r -\sin(\theta), r \cos(\theta), t). $$

The normal vector $N$ can be computed by taking the coordinate-wise derivative of $T$ and then dividing by its length

$$ N = \frac{\dot{T}}{|\dot{T}|}$$

The bi-normal $B$ is perpendicular to both and can be computed as

$$ B = T \times N .$$

The collection $\{T,N,B\}$ is called a Fernet frame. Each point of the helix has a such a set of orthogonal vectors based there and below we visualize the transition from one frame to another.

In [ ]:
# import libraries
import os
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
import plotly.colors as pcolors
import plotly.offline as pyo
# Initialize Plotly offline mode, so we can display plots of the notebook in the .html file
pio.renderers.default = 'notebook'
pyo.init_notebook_mode()
# set theme
# pio.templates.default = "plotly_dark"

Define Variables¶

In [ ]:
# Get a list of colors at specific intervals to color the frames along the helix
# sample_points = [0, 0.4, 0.75] # darker colors for T, N, B
sample_points = [1.0, 0.6, 0.4] # lighter colors for T, N, B
sampled_colors = pcolors.sample_colorscale(pcolors.sequential.Viridis, samplepoints=sample_points)

# Define parameters for the helix
num_points = 700
num_turns = 5
radius = 1
pitch = 0.5
t = np.linspace(-num_turns * np.pi, num_turns * np.pi, num_points)

# s subset of t to animate over
N = 400
num_s_points = N
s = np.linspace(min(t), max(t), N)

# Calculate the x, y, and z coordinates
x = radius * np.cos(t)
y = radius * np.sin(t)
z = pitch * t

# subset coordinates for animation
# for drawing the Frenet frame at each point on the helix
xx = radius * np.cos(s)
yy = radius * np.sin(s)
zz = pitch * s

# for drawing with fixed axes
xm = np.min(x) - 1.5
xM = np.max(x) + 1.5
ym = np.min(y) - 1.5
yM = np.max(y) + 1.5
zm = np.min(z) - 1.5
zM = np.max(z) + 1.5

Compute Frenet Frame¶

In [13]:
# compute Frenet frame
def create_frenet_frame(t, radius, pitch, k):
    """
    Create the Frenet-Serret frame (Tangent, Normal, Binormal) at a point on the helix curve.
    """
    # Tangent vector
    dx = -radius * np.sin(t[k])
    dy = radius * np.cos(t[k])
    dz = pitch
    T = np.array([dx, dy, dz])
    T = T / np.linalg.norm(T)

    # Normal vector
    ddx = -radius * np.cos(t[k])
    ddy = -radius * np.sin(t[k])
    ddz = 0
    N = np.array([ddx, ddy, ddz])
    N = N / np.linalg.norm(N)

    # Binormal vector
    B = np.cross(T, N)
    B = B / np.linalg.norm(B)

    return T, N, B

T = np.empty((3,0))
N = np.empty((3,0))
B = np.empty((3,0)) 

for k in range(num_s_points):
    T_k, N_k, B_k = create_frenet_frame(s, radius, pitch, k)
    T = np.column_stack((T, T_k))
    N = np.column_stack((N, N_k))
    B = np.column_stack((B, B_k))

Coordinates of T,N,B¶

In [14]:
vx = T[0, :]  # (vx, vy) is the velocity vector
vy = T[1, :]  # (vx, vy) is the velocity vector
vz = T[2, :]  # (vx, vy, vz) is the velocity vector

speed = np.sqrt(vx ** 2 + vy ** 2, vz ** 2)  # speed = |velocity| = sqrt(vx^2 + vy^2 + vz^2)
ux = vx / speed  # (ux, uy) unit tangent vector, (-uy, ux) unit normal vector
uy = vy / speed
uz = vz / speed

xend = xx + ux  # end coordinates for the unit tangent vector at (xx, yy)
yend = yy + uy
zend = zz + uz

xnoe = xx + N[0, :]  # end coordinates for the unit normal vector at (xx,yy)
ynoe = yy + N[1, :]
znoe = zz - N[2, :]  

xboe = xx + B[0, :]  # end coordinates for the unit binormal vector at (xx,yy)
yboe = yy + B[1, :]
zboe = zz + B[2, :]  

First Create Static Figure¶

For plotting animations, it's important to keep in mind that that the base plots needs a copy of everything you want to animate. So since we want to see the frames move along the curve, we need a $\textit{trace}$ for both the frame and the curve. We need separate traces for the {T,N,B} because we want to paint each one a different color.

In [ ]:
# Create figure
frames = []
for k in range(num_s_points):
    frames.append(
        go.Frame(
            data=[
                # Draw the helix curve
                go.Scatter3d(x=x, y=y, z=z,
                     name="curve",
                     mode="lines",
                     line=dict(width=8, color="blue")),
                # Draw the Tangent vector   
                go.Scatter3d(
                x=[xx[k], xend[k]],
                y=[yy[k], yend[k]],
                z=[zz[k], zend[k]],
                mode="lines",
                line=dict(
                color=sampled_colors[0],  # Numerical array for color mapping
                width=8)),
                # Draw the Normal vector
                go.Scatter3d(
                x=[xx[k], xnoe[k]],
                y=[yy[k], ynoe[k]],
                z=[zz[k], znoe[k]],
                mode="lines",
                line=dict(
                        color=sampled_colors[1],  # Numerical array for color mapping
                        width=8)) , 
                # Draw the Binormal vector
                go.Scatter3d(
                x=[xx[k], xboe[k]],
                y=[yy[k], yboe[k]],
                z=[zz[k], zboe[k]],
                mode="lines",
                line=dict(
                        color=sampled_colors[2],  # Numerical array for color mapping
                        width=8)) 
                ],
            traces=[0, 1, 2,3],
            name=f"frame{k}"
        )
    )
  

Next Create the Individual Frames¶

In [ ]:
  fig = go.Figure(
    data=[
        go.Scatter3d(x=x, y=y, z=z,
                     name="curve",
                     mode="lines",
                     line=dict(width=8, color="blue")),

        # Initial state of the Tangent vector
        go.Scatter3d(x=[xx[0], xend[0]],
                     y=[yy[0], yend[0]],
                     z=[zz[0], zend[0]],
                     mode="lines",
                     line=dict(color=sampled_colors[0], width=8),
                     name="Tangent"),

        # Initial state of the Normal vector
        go.Scatter3d(x=[xx[0], xnoe[0]],
                     y=[yy[0], ynoe[0]],
                     z=[zz[0], znoe[0]],
                     mode="lines",
                     line=dict(color=sampled_colors[1], width=8),
                     name="Normal"),

        # Initial state of the Binormal vector
        go.Scatter3d(x=[xx[0], xboe[0]],
                     y=[yy[0], yboe[0]],
                     z=[zz[0], zboe[0]],
                     mode="lines",
                     line=dict(color=sampled_colors[2], width=8),
                     name="Binormal")
    ],
        layout=go.Layout(
            scene=dict(
                xaxis=dict(range=[xm, xM], autorange=False, zeroline=False),
                yaxis=dict(range=[ym, yM], autorange=False, zeroline=False),
                zaxis=dict(range=[zm, zM], autorange=False, zeroline=False),
                aspectratio=dict(x=1, y=1, z=2)),
            title=dict(text="Moving Frenet Frame Along a Helix"),
            hovermode="closest",
            updatemenus=[
                dict(type="buttons",
                    buttons=[
                        dict(label="Play",
                            method="animate",
                            args=[None]),
                        dict(label="Pause",
                            method="animate",
                            args=[[None], {"frame": {"duration": 0, "redraw": False},
                                            "mode": "immediate",
                                            "transition": {"duration": 0}}])
                    ])
                ],
            ),
    frames=frames
    )

fig.show()

Save Animation as .html¶

In [17]:
# Define a path in a known writable location (e.g., user's home directory)
output_directory = os.path.expanduser("~/g/teaching/ma541/python/") # Or any other writable path
output_file_path = os.path.join(output_directory, "helix-fernet-frame-ani.html")

try:
    pio.write_html(fig, output_file_path, auto_open=True)
    print(f"Plotly figure saved successfully to: {output_file_path}")
except OSError as e:
    print(f"Error saving Plotly figure: {e}")
    print("Please ensure you have write permissions to the specified directory.")
# Save the figure as an HTML file

# - - - - run in terminal to convert notebook to html with dark theme
# jupyter nbconvert --execute --to html /path/to/examplej.ipynb --HTMLExporter.theme=dark
Plotly figure saved successfully to: /Users/josh/g/teaching/ma541/python/helix-fernet-frame-ani.html