Microsoft Fabric: Introduction to 3D programming
Motivation
TL;DR the code is at the bottom
I once read an article about a person who managed to install Doom on a Canon printer, and a few days ago I remembered that article when thinking about Fabric. While it could be some project, I was inspired for fun to try something a little less.
Since my teens, I have always had something for programming spinning 3D cubes, where I started out on the Amiga and had a great time when Microsoft later released Microsoft XNA Studio.
So spinning cube it was.
Rotation calculation
I’m using a traditional yaw, pitch, and roll 3D rotation matrix. You can read how it works on Wikipedia: (https://en.wikipedia.org/wiki/Rotation_matrix)
The ASCII cube
As the output field in Microsoft Fabric supports text and has a print statement, I started by trying to make a pure text ASCII cube. The result was not good. It seems like the notebook has issues with write–clear–write text, even at very slow frame rates.
I did make an attempt with double buffering, a method where a frame is drawn while another is shown. It did not make it better.
The Matplotlib cube
I remembered Matplotlib supports 3D, and it renders nicely in a notebook. Also, when removing the axes in the plot, it looks more like an animation.
Unfortunately, I couldn’t get the notebook to draw the spinning cube in real time, it was almost the same problem as with the ASCII cube. I then
learned about Matplotlib’s animation function and used it to pre-calculate an animation, save it, and then load it. Then I had a wireframe spinning cube:
A wireframe cube can often look normal, then suddenly appear distorted, and then return to looking normal due to perspective. One way to fix this is to add some texture.
The textured Matplotlib cube
I was happy to learn that Matplotlib has a function to texture a plane in 3D space, almost like Microsoft XNA Studio.
Use it as a visual for reporting
The only Fabric functionality used so far is the Notebook, and it can run both on Spark compute and in Python Notebooks. Now that we can texture, what if we pull some data and map it onto the cube? Again, Matplotlib can help us.
import matplotlib.pyplot as plt
# Put in a Spark quey here.
spark_df = spark.sql("SELECT id, name, department FROM customer LIMIT 5")
pandas_df = spark_df.toPandas()
fig, ax = plt.subplots(figsize=(12, 6))
ax.axis('off')
table = ax.table(cellText=pandas_df.values, colLabels=pandas_df.columns, loc='center', cellLoc='center'
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 1.5)
plt.savefig("result_table.png", bbox_inches='tight', dpi=200)
plt.close(fig)
The table might a bit difficult to read.
Conclusion
Often it is the journey and not the destination, and I learned a lot about Matplotlib and the arbitrary lag in Fabric Notebooks’ output field on my journey. Experiments like this are for me like doodling on paper, and sometimes something comes up. Feel free to leave a comment if you have a solution for real-time rendering.
The code
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.animation import PillowWriter
from mpl_toolkits.mplot3d import Axes3D
from PIL import Image as PILImage
from IPython.display import Image
from functools import partial
# Best to use quadratic images. I use 256x256
image = PILImage.open("/lakehouse/default/Files/Images/CubeImage.png").convert("RGB")
#image = PILImage.open("result_table.png").convert("RGB")
texture_image_size = 32
texture_image = image.resize((texture_image_size, texture_image_size), PILImage.LANCZOS)
texture = np.asarray(texture_image) / 255.0
scale = 3
cube_vertices = np.array([
[-1, -1, -1],
[ 1, -1, -1],
[ 1, 1, -1],
[-1, 1, -1],
[-1, -1, 1],
[ 1, -1, 1],
[ 1, 1, 1],
[-1, 1, 1]
]) * scale
cube_edges = [
(0,1), (1,2), (2,3), (3,0), # bottom square
(4,5), (5,6), (6,7), (7,4), # top square
(0,4), (1,5), (2,6), (3,7) # vertical edges
]
def face_plane_coords(face_index):
u = np.linspace(-1, 1, texture_image_size)
v = np.linspace(-1, 1, texture_image_size)
U, V = np.meshgrid(u, v)
if face_index == 0:
X0 = U * scale
Y0 = V * scale
Z0 = np.ones_like(U) * scale
elif face_index == 1:
X0 = -U * scale
Y0 = V * scale
Z0 = -np.ones_like(U) * scale
elif face_index == 2:
X0 = np.ones_like(U) * scale
Y0 = V * scale
Z0 = -U * scale
elif face_index == 3:
X0 = -np.ones_like(U) * scale
Y0 = V * scale
Z0 = U * scale
elif face_index == 4:
X0 = U * scale
Y0 = np.ones_like(U) * scale
Z0 = -V * scale
else:
X0 = U * scale
Y0 = -np.ones_like(U) * scale
Z0 = V * scale
return X0, Y0, Z0
def create_rotation_matrix(angle):
rotation_x = np.array([
[1, 0, 0],
[0, np.cos(angle), -np.sin(angle)],
[0, np.sin(angle), np.cos(angle)]
])
rotation_y = np.array([
[ np.cos(angle), 0, np.sin(angle)],
[0, 1, 0],
[-np.sin(angle), 0, np.cos(angle)]
])
rotation_z = np.array([
[np.cos(angle), -np.sin(angle), 0],
[np.sin(angle), np.cos(angle), 0],
[0, 0, 1]
])
return rotation_z @ rotation_y @ rotation_x
def clear_mattplot_axes(axes):
axes.cla()
axes.set_axis_off()
axes.xaxis.pane.set_visible(False)
axes.yaxis.pane.set_visible(False)
axes.zaxis.pane.set_visible(False)
axes.set_xlim([-3, 3])
axes.set_ylim([-3, 3])
axes.set_zlim([-3, 3])
axes.set_box_aspect([1, 1, 1])
def texture_dice_faces(rotation_matrix, axes):
for dice_face_number in range(6):
X0, Y0, Z0 = face_plane_coords(dice_face_number)
pts = np.vstack((X0.flatten(), Y0.flatten(), Z0.flatten())) # (3, N)
rotated = rotation_matrix @ pts
Xr = rotated[0, :].reshape((texture_image_size, texture_image_size))
Yr = rotated[1, :].reshape((texture_image_size, texture_image_size))
Zr = rotated[2, :].reshape((texture_image_size, texture_image_size))
axes.plot_surface(Xr, Yr, Zr, rstride=1, cstride=1, facecolors=texture, shade=False)
def draw_edges(rotation_matrix, axes):
verts = (rotation_matrix @ cube_vertices.T).T
for start, end in cube_edges:
xs = [verts[start, 0], verts[end, 0]]
ys = [verts[start, 1], verts[end, 1]]
zs = [verts[start, 2], verts[end, 2]]
axes.plot(xs, ys, zs, color="k", linewidth=1.5)
def animate(frame, axes):
clear_mattplot_axes(axes)
angle = np.radians(frame * (360 / 30))
rotation_matrix = create_rotation_matrix(angle)
texture_dice_faces(rotation_matrix, axes)
draw_edges(rotation_matrix, axes)
return []
fig = plt.figure(figsize=(3, 3), dpi=80)
axes = fig.add_subplot(111, projection='3d')
clear_mattplot_axes(axes)
precalc_animation = animation.FuncAnimation(fig, partial(animate, axes=axes), frames=30, interval=50, blit=False)
precalc_animation.save("textured_cube_with_edges.gif", writer=PillowWriter(fps=20))
plt.close(fig)
Image(filename="textured_cube_with_edges.gif")