Part 1: Basic Linear Transformations¶
1. Experiment Design¶
2. Visual Elements¶
3. Control Design¶
4. Figure Display¶
The final visualization contains:
the original three-dimensional figure and the transformed figure
three-dimensional coordinate axes (red, green, and blue for the x-, y-, and z-axes, respectively)
synchronized camera view controls
# Colab: enable the custom widget manager so that FigureWidget and the sliders are interactive; skipped elsewhere
try:
from google.colab import output
output.enable_custom_widget_manager()
except ImportError:
pass
# Import the required libraries
import plotly.graph_objs as go # for creating figure objects
import numpy as np # math library, in particular for numerical computation and array operations
from plotly import subplots # for creating multiple subplots
import ipywidgets as widgets # for creating interactive widgets
from ipywidgets import VBox # for creating a container that stacks widgets vertically
from IPython.display import display # for displaying widgets in a Jupyter Notebook
# Create the sphere data
def create_sphere_data():
phi = np.linspace(0, 2*np.pi, 50) # angle phi from 0 to 2π, 50 points
theta = np.linspace(-np.pi/2, np.pi/2, 50) # angle theta from -π/2 to π/2, 50 points
phi, theta = np.meshgrid(phi, theta) # create a grid for the spherical coordinates
# Convert spherical coordinates to Cartesian coordinates (x, y, z)
x = np.cos(theta) * np.cos(phi) # x coordinate: from the spherical coordinate formula
y = np.cos(theta) * np.sin(phi) # y coordinate: from the spherical coordinate formula
z = np.sin(theta) # z coordinate: from the spherical coordinate formula
# To get different colors, compute color values from theta and phi
color_theta = (theta + np.pi/2) / np.pi # normalize theta to the range [0, 1] for the color mapping
color_phi = phi / (2*np.pi) # normalize phi to the range [0, 1] for the color mapping
return x, y, z, color_theta, color_phi
# Create the cube data
def create_cube_data():
# Create all faces of the cube centered at the origin with edge length 2
n_points = 20 # number of points per edge
# store the points of all faces
all_x, all_y, all_z = [], [], []
# Create the 12 edges of the cube
# the 4 edges of the bottom face (z = -1)
for i in range(n_points):
t = -1 + 2*i/(n_points-1)
# bottom edges
all_x.extend([-1, 1, t, t])
all_y.extend([t, t, -1, 1])
all_z.extend([-1, -1, -1, -1])
# the 4 edges of the top face (z = 1)
for i in range(n_points):
t = -1 + 2*i/(n_points-1)
# top edges
all_x.extend([-1, 1, t, t])
all_y.extend([t, t, -1, 1])
all_z.extend([1, 1, 1, 1])
# the 4 vertical edges
for i in range(n_points):
t = -1 + 2*i/(n_points-1)
# vertical edges at the four corners
all_x.extend([-1, 1, -1, 1])
all_y.extend([-1, -1, 1, 1])
all_z.extend([t, t, t, t])
# convert to numpy arrays
x = np.array(all_x)
y = np.array(all_y)
z = np.array(all_z)
# Create the color mapping
color_theta = (z + 1) / 2 # color based on the z coordinate (0 to 1)
color_phi = (np.arctan2(y, x) + np.pi) / (2 * np.pi) # color based on the angle
return x, y, z, color_theta, color_phi
# Initialize the shape selector
shape_selector = widgets.ToggleButtons(
options=[('Sphere', 'sphere'), ('Cube', 'cube')],
value='sphere',
description='Shape:',
disabled=False,
button_style='',
tooltips=['Select the unit sphere', 'Select the unit cube'],
)
# Create the subplots (3D figures in 2 rows and 2 columns)
f = subplots.make_subplots(
rows=2, cols=2, # create subplots in 2 rows and 2 columns
specs=[[{'is_3d': True}, {'is_3d': True}], [{'is_3d': True}, {'is_3d': True}]] # every subplot is a 3D plot
)
# Define the interactive widgets for the transformation matrix
matrix_widgets = [
[widgets.Text(value=str(float(i == j)), layout=widgets.Layout(width='50px')) for j in range(3)] # create a 3x3 array of Text widgets
for i in range(3)
] # initialize a 3x3 array of widgets
# Create the UI that displays the transformation matrix
matrix_ui = VBox([
widgets.Label("Transformation matrix (3x3):"), # title
widgets.VBox([widgets.HBox(row) for row in matrix_widgets]) # put each row of widgets in an HBox and all rows in a VBox
])
# Define a function that applies the coordinate transformation
def transform_coords(x, y, z, matrix):
# Flatten the input x, y, z coordinates and stack them into a 2D array
coords = np.vstack([x.flatten(), y.flatten(), z.flatten()])
# Transform the coordinates with the transformation matrix
transformed = np.dot(matrix, coords)
# Return the transformed coordinates according to the dimensions of the original data
if len(x.shape) == 2: # sphere (2D arrays)
return transformed[0].reshape(x.shape), transformed[1].reshape(x.shape), transformed[2].reshape(x.shape)
else: # cube (1D arrays)
return transformed[0], transformed[1], transformed[2]
# Define a function that gets the transformation matrix
def get_matrix():
# Read the values entered by the user from the widgets and build a 3x3 transformation matrix
return np.array([[float(cell.value) for cell in row] for row in matrix_widgets])
# Define a function that creates the coordinate axes
def create_axes(scene, matrix=None):
axis_length = 1.5 # length of the coordinate axes
colors = ['red', 'green', 'blue'] # colors of the x-, y-, and z-axes
axes = np.array([[axis_length, 0, 0], [0, axis_length, 0], [0, 0, axis_length]]) # define the three coordinate axes
# If a transformation matrix is given, transform the coordinate axes
if matrix is not None:
axes = np.dot(matrix, axes.T).T # transformed coordinate axes
# Return the 3D scatter data of each coordinate axis
return [
go.Scatter3d(
x=[0, axes[i, 0]], y=[0, axes[i, 1]], z=[0, axes[i, 2]],
mode='lines',
line=dict(color=colors[i], width=5),
showlegend=False,
scene=scene
) for i in range(3)
]
# Get the data of the sphere and the cube
sphere_x, sphere_y, sphere_z, sphere_color_theta, sphere_color_phi = create_sphere_data()
cube_x, cube_y, cube_z, cube_color_theta, cube_color_phi = create_cube_data()
# Initial transformation matrix
matrix = get_matrix()
sphere_x_t, sphere_y_t, sphere_z_t = transform_coords(sphere_x, sphere_y, sphere_z, matrix)
cube_x_t, cube_y_t, cube_z_t = transform_coords(cube_x, cube_y, cube_z, matrix)
# Add the sphere traces (initially visible)
f.add_trace(dict(type='surface', x=sphere_x, y=sphere_y, z=sphere_z, scene='scene1',
surfacecolor=sphere_color_theta, colorscale='Viridis',
colorbar=dict(title='θ',x=1, y=0.85, len=0.4), visible=True), 1, 1)
f.add_trace(dict(type='surface', x=sphere_x_t, y=sphere_y_t, z=sphere_z_t, scene='scene2',
surfacecolor=sphere_color_theta, colorscale='Viridis', showscale=False, visible=True), 1, 2)
f.add_trace(dict(type='surface', x=sphere_x, y=sphere_y, z=sphere_z, scene='scene3',
surfacecolor=sphere_color_phi, colorscale='rdpu',
colorbar=dict(title='φ',x=1, y=0.185, len=0.4), visible=True), 2, 1)
f.add_trace(dict(type='surface', x=sphere_x_t, y=sphere_y_t, z=sphere_z_t, scene='scene4',
surfacecolor=sphere_color_phi, colorscale='rdpu', showscale=False, visible=True), 2, 2)
# Add the cube traces (initially hidden)
f.add_trace(dict(type='scatter3d', x=cube_x, y=cube_y, z=cube_z, mode='markers',
marker=dict(color=cube_color_theta, colorscale='Viridis', size=4, opacity=0.8),
scene='scene1', name='Original cube', visible=False), 1, 1)
f.add_trace(dict(type='scatter3d', x=cube_x_t, y=cube_y_t, z=cube_z_t, mode='markers',
marker=dict(color=cube_color_theta, colorscale='Viridis', size=4, opacity=0.8),
scene='scene2', name='Transformed cube', showlegend=False, visible=False), 1, 2)
f.add_trace(dict(type='scatter3d', x=cube_x, y=cube_y, z=cube_z, mode='markers',
marker=dict(color=cube_color_phi, colorscale='rdpu', size=4, opacity=0.8),
scene='scene3', name='Original cube', showlegend=False, visible=False), 2, 1)
f.add_trace(dict(type='scatter3d', x=cube_x_t, y=cube_y_t, z=cube_z_t, mode='markers',
marker=dict(color=cube_color_phi, colorscale='rdpu', size=4, opacity=0.8),
scene='scene4', name='Transformed cube', showlegend=False, visible=False), 2, 2)
# Add the coordinate axes
axis_traces = []
for i, scene in enumerate(['scene1', 'scene2', 'scene3', 'scene4']):
# only scene2 and scene4 need the transformed coordinate axes
axis_matrix = matrix if scene in ['scene2', 'scene4'] else None
for line in create_axes(scene, axis_matrix):
f.add_trace(line, 2 - (i % 2), 1 + int(i / 2))
# Update the layout
f.update_layout(
title='3D Figures, Linear Transformation, and Coordinate Axes',
height=1000,
width=1000
)
# Create the FigureWidget
fig = go.FigureWidget(f)
# Synchronize the cameras
def cam_change(layout, camera):
fig.layout.scene2.camera = camera
fig.layout.scene3.camera = camera
fig.layout.scene4.camera = camera
fig.layout.scene1.on_change(cam_change, 'camera')
# Define the update function
def update_plot(change):
matrix = get_matrix()
# update the transformed coordinates
if shape_selector.value == 'sphere':
sphere_x_t, sphere_y_t, sphere_z_t = transform_coords(sphere_x, sphere_y, sphere_z, matrix)
# show the sphere, hide the cube
fig.data[0].visible = True # sphere original theta
fig.data[1].visible = True # sphere transformed theta
fig.data[2].visible = True # sphere original phi
fig.data[3].visible = True # sphere transformed phi
fig.data[4].visible = False # cube original theta
fig.data[5].visible = False # cube transformed theta
fig.data[6].visible = False # cube original phi
fig.data[7].visible = False # cube transformed phi
# update the transformed sphere data
with fig.batch_update():
fig.data[1].x, fig.data[1].y, fig.data[1].z = sphere_x_t, sphere_y_t, sphere_z_t
fig.data[3].x, fig.data[3].y, fig.data[3].z = sphere_x_t, sphere_y_t, sphere_z_t
else:
cube_x_t, cube_y_t, cube_z_t = transform_coords(cube_x, cube_y, cube_z, matrix)
# show the cube, hide the sphere
fig.data[0].visible = False # sphere original theta
fig.data[1].visible = False # sphere transformed theta
fig.data[2].visible = False # sphere original phi
fig.data[3].visible = False # sphere transformed phi
fig.data[4].visible = True # cube original theta
fig.data[5].visible = True # cube transformed theta
fig.data[6].visible = True # cube original phi
fig.data[7].visible = True # cube transformed phi
# update the transformed cube data
with fig.batch_update():
fig.data[5].x, fig.data[5].y, fig.data[5].z = cube_x_t, cube_y_t, cube_z_t
fig.data[7].x, fig.data[7].y, fig.data[7].z = cube_x_t, cube_y_t, cube_z_t
# update the transformed coordinate axes
# trace indices of the coordinate axes: 8-19 (3 axes per subplot)
# when they were added, the (row, col) arguments of add_trace set the actual subplot (overriding scene), so they sit in:
# 8, 9, 10: bottom left, scene3 (original axes, unchanged)
# 11, 12, 13: top left, scene1 (built from the initial matrix, unchanged)
# 14, 15, 16: bottom right, scene4 (transformed axes, to be updated)
# 17, 18, 19: top right, scene2 (transformed axes, to be updated)
# (the transformed axes of scene2 and scene4 have the same coordinates, so the two updates below are interchangeable)
transformed_axes_scene2 = create_axes('scene2', matrix)
transformed_axes_scene4 = create_axes('scene4', matrix)
with fig.batch_update():
# update the axes of scene4 (bottom right, transformed) - indices 14, 15, 16
for i, line in enumerate(transformed_axes_scene2):
fig.data[14 + i].x = line.x
fig.data[14 + i].y = line.y
fig.data[14 + i].z = line.z
# update the axes of scene2 (top right, transformed) - indices 17, 18, 19
for i, line in enumerate(transformed_axes_scene4):
fig.data[17 + i].x = line.x
fig.data[17 + i].y = line.y
fig.data[17 + i].z = line.z
# Listen for value changes of the matrix widgets
for row in matrix_widgets:
for cell in row:
cell.observe(update_plot, names='value')
# Listen for changes of the shape selector
shape_selector.observe(update_plot, names='value')
# Combine the controls
controls = VBox([shape_selector, matrix_ui])
# Display the interactive widgets and the figure
display(VBox([controls, fig]))Loading...
Part 2: Observation and Reflection¶
Answer the following four questions, explaining your conclusions with what you actually did in the interactive demonstration.