PyVista is a Python package for 3D plotting and mesh analysis through a streamlined interface for the Visualization Toolkit (VTK). I learned how to use the package in the past few weeks, and decided to build something fun to explore Python-based 3D visualization on the web.
I had so much fun learning how to use the package, I decided to write up the process as a tutorial. Hope you found it helpful!
What should I build?
As a engineering / programming nerd, I have always been a big fan of XKCD comics. (In fact, I've been known to possess an uncanny set of esoteric knowledge about XKCD comic references, and I often put a healthy dosage of xkcd comics in my conference talks... but that's a topic for another post!) So, when I was thinking of 2D scenes that I want to render, I immediately think of XKCD's red spiders.
Red spiders appear in at least 7 different XKCD comics, although not many recently. After looking through the xkcd spider comic, I landed on the Red Spiders Cometh scene. This post will walk through how we can turn this 2D scene:
To a 3D scene that you can view and zoom on the web:
Installing PyVista
PyVista has an excellent collection of documentation. Following through the Installation instructions, I was able to pip install into my conda environment:
pip install pyvista
Hello spider (our arachnid solider)
In this tutorial, we'll stick with the stock spider model that is available in PyVista examples.
(Side note: XKCD spider actually should only have six legs, but I am not good enough with editing 3D models to remove legs, so the stock spider will have to do for now.)
We can easily make our spider come to life in 3D using just a few lines of code:
# hello_spider.py
import pyvista as pv
from pyvista import examples
if __name__ == "__main__":
pv.set_plot_theme("document")
plotter = pv.Plotter()
spider = examples.download_spider()
plotter.add_mesh(spider, color="red") # spider
plotter.show()
Say hi to our spider soldier:
Hello spider on a box (our assault unit)
In the xkcd comic, our spider foot solder doesn't just float around in free space. We need to give it a transport - a box! Fortunately, PyVista provides an easy way to plot a box, right out-of-the-box.
Now let's put our spider on its own box. We had to do some scaling, rotation, and translation to make our soldier land on its own transport with all eight legs. Also, we took care to make sure that the center of our box is at the origin, which will make our task later of multiplying our assault unit easier.
# hello_spider_on_box.py
import pyvista as pv
from pyvista import examples
def get_unit_cell_box() -> pv.PolyData:
"""Return a box unit. The box has length 1 in all 3 dimensions, and is
centered at the origin.
Having the box centered at origin will make it easier for rotating the
spider on a box.
Returns:
pv.PolyData: ``pv.Polydata`` containing the box unit.
"""
default_box = pv.Box()
default_box.points /= 2
return default_box
def get_unit_cell_spider() -> pv.PolyData:
"""Return a spider unit. The spider has legspan that is slightly smaller
than the box face, and is in a position so it appears to be standing on the
box unit.
Having the spider unit standing on the box centered at origin will make it
easier for rotating the spider on a box.
Returns:
pv.PolyData: ``pv.Polydata`` containing the spider unit.
"""
default_spider = examples.download_spider()
default_spider.points /= 6
default_spider.translate([-0.5, -0.5, 0.4])
default_spider.rotate_z(-110)
return default_spider
def main() -> pv.Plotter:
"""Main function for rendering the 3D scene for spider on a box.
Args:
None
Returns:
pv.Plotter: pyvista plotter for plotting the 3D scene.
"""
plotter = pv.Plotter()
spider = get_unit_cell_spider()
box = get_unit_cell_box()
plotter.add_mesh(spider, color="red") # spider
plotter.add_mesh(box, color="tan", show_edges=True) # box
return plotter
if __name__ == "__main__":
pv.set_plot_theme("document")
p = main()
p.show()
And now we have a assault unit! Our solider stares, with resolve, into the distance.
Hello buildings
If you look at the original comic, you would notice that the spider army has a target of their invasion - namely, the numerous buildings at a distance. Unfortunately there were no "stock buildings" that I can find in PyVista examples, but PyVista does have the ability to read a variety of 3D file types, so I did a google search and found this Buildings and Skyscrapers 3D model (.obj file), created by Angel V Mendez on Sketchfab, and made available through creative commons licensing. I downloaded the .obj file and save them to disk, and I can simply use pyvista.read() function to read them.
"""Get a simple building."""
import os
import pyvista as pv
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "data")
def get_buildings() -> pv.PolyData:
"""Return a set of buildings, which was downloaded from sketchfab and saved
in project file.
Returns:
pv.PolyData: ``pv.Polydata`` containing the buildings.
"""
default_buildings = pv.read(
os.path.join(
DATA_DIR, "buildings-and-skyscrapers", "source", "buildings.obj"
)
)
default_buildings.rotate_x(90)
default_buildings.translate([-4, -4, 0])
return default_buildings
def main(color_buildings="lightgray") -> pv.Plotter:
"""Main function for rendering the 3D scene.
Args:
color_buildings (str, optional): color of the buildings. Defaults to
"lightgray".
Returns:
pv.Plotter: pyvista plotter for plotting the 3D scene.
"""
plotter = pv.Plotter()
buildings = get_buildings()
buildings.points *= 1
buildings.translate([0, 0, -10])
plotter.add_mesh(buildings, color=color_buildings, show_edges=True)
return plotter
if __name__ == "__main__":
pv.set_plot_theme("document")
p = main()
p.show()
Our unsuspecting victim.
The spiders, they are multiplying
You cannot hope to conquer a small-sized city with a single giant spider alone. We'll need a lot more. Fortunately, we can easily multiply our spider-on-a-box assault unit through code. We can also rotate and translate the assault units to make each spider solider occupy different faces of the box. We can even scale the size of our assault unit through a simple multiplication / division!
This is where placing our unit spider-box assault unit in the origin (coordinate (0, 0, 0)) is helpful. The rotate_x, rotate_y, and rotate_z methods in pv.PolyData will rotate the meshes with respect to x, y, or z axis (right-handed coordinate). Placing our assault unit at the origin will allow us to rotate our assault unit as many times we want, and the spider will be able to cling snugly with the box. After we are happy with the rotational placement, we can call translate method to spread out our assault units to other locations.
"""Main script to kick off a pyvista 3D visualization window.
To run::
python xkcd_red_spider/hello_spider_army.py
"""
from typing import List, Tuple, Union
import pyvista as pv
from pyvista import examples
def get_unit_cell_box() -> pv.PolyData:
"""Return a box unit. The box has length 1 in all 3 dimensions, and is
centered at the origin.
Having the box centered at origin will make it easier for rotating the
spider on a box.
Returns:
pv.PolyData: ``pv.Polydata`` containing the box unit.
"""
default_box = pv.Box()
default_box.points /= 2
return default_box
def get_unit_cell_spider() -> pv.PolyData:
"""Return a spider unit. The spider has legspan that is slightly smaller
than the box face, and is in a position so it appears to be standing on the
box unit.
Having the spider unit standing on the box centered at origin will make it
easier for rotating the spider on a box.
Returns:
pv.PolyData: ``pv.Polydata`` containing the spider unit.
"""
default_spider = examples.download_spider()
default_spider.points /= 6
default_spider.translate([-0.5, -0.5, 0.4])
default_spider.rotate_z(-110)
return default_spider
def process_spider_box_unit_cell(
spider: pv.PolyData = get_unit_cell_spider(),
box: pv.PolyData = get_unit_cell_box(),
scale: float = 1.0,
rotation: List[Tuple[str, float]] = None,
translation: List[Union[int, float]] = None,
) -> Tuple[pv.PolyData, pv.PolyData]:
"""Process the spider-box unit cell through operations including scaling,
rotations, and translations.
Args:
spider (pv.PolyData, optional): Polydata containing the spider unit.
Defaults to get_unit_cell_spider().
box (pv.PolyData, optional): Polydata containing the box unit. Defaults
to get_unit_cell_box().
scale (float, optional): scaling factor. Defaults to 1.0.
rotation (List[Tuple[str, float]], optional): list of steps for
rotation, in the form of list of tuples, and the tuple containing
the direction (``"x"``, ``"y"``, or ``"z"``) in the first element,
and the degrees in the second direction. Example:
``[("x", 90), ("z", 180)]``. Under the hood, the
`rotate_x <https://docs.pyvista.org/core/common.html#pyvista.Common.rotate_x>`_,
`rotate_y <https://docs.pyvista.org/core/common.html#pyvista.Common.rotate_y>`_, and
`rotate_z <https://docs.pyvista.org/core/common.html#pyvista.Common.rotate_z>`_
methods in ``pv.PolyData`` are called. Defaults to None.
translation (List[Union[int, float]], optional): Length of 3 list or
array to translate the polydata. Under the hood, the
`translate <https://docs.pyvista.org/core/common.html#pyvista.Common.translate>`_
method in ``pv.PolyData`` is called. Defaults to None.
Returns:
Tuple[pv.PolyData, pv.PolyData]: A tuple of ``pv.Polydata`` containing the spider and box.
"""
spider.points *= scale
box.points *= scale
if isinstance(rotation, list):
for step in rotation:
if step[0] == "x":
spider.rotate_x(step[1])
if step[0] == "y":
spider.rotate_y(step[1])
if step[0] == "z":
spider.rotate_z(step[1])
if isinstance(translation, list):
spider.translate(translation)
box.translate(translation)
return (spider, box)
def main() -> pv.Plotter:
"""Main function for rendering the 3D scene for spider on a box.
Args:
None
Returns:
pv.Plotter: pyvista plotter for plotting the 3D scene.
"""
plotter = pv.Plotter()
spider_1, box_1 = process_spider_box_unit_cell(
spider=get_unit_cell_spider(), box=get_unit_cell_box(), scale=1.0
)
spider_2, box_2 = process_spider_box_unit_cell(
spider=get_unit_cell_spider(),
box=get_unit_cell_box(),
scale=1.2,
rotation=[("y", 90)],
translation=[2, 0, 0],
)
spider_3, box_3 = process_spider_box_unit_cell(
spider=get_unit_cell_spider(),
box=get_unit_cell_box(),
scale=1.4,
rotation=[("x", 90)],
translation=[4, 0, 0],
)
spider_4, box_4 = process_spider_box_unit_cell(
spider=get_unit_cell_spider(),
box=get_unit_cell_box(),
scale=1.6,
rotation=[("z", 90)],
translation=[6, 0, 0],
)
plotter.add_mesh(spider_1, color="red")
plotter.add_mesh(spider_2, color="red")
plotter.add_mesh(spider_3, color="red")
plotter.add_mesh(spider_4, color="red")
plotter.add_mesh(box_1, color="tan")
plotter.add_mesh(box_2, color="tan")
plotter.add_mesh(box_3, color="tan")
plotter.add_mesh(box_4, color="tan")
return plotter
if __name__ == "__main__":
pv.set_plot_theme("document")
p = main()
p.show()
Here is our mathematically-generated, 4-unit spider assault squad.
Commencing assault
Now we have everything we need to put together our 3D scene for "Red spiders cometh". What I did then was to manually label the coordinates of the assault unit in the original 2D scene, and translate them into coordinates that my Python program can understand. I also reproduced the rotational steps to make them into the right orientation.
from typing import Dict, List, Tuple
# Hand-crafted spider army coords that mimic the xkcd comic: Red Spiders Cometh
# https://xkcd.com/126/
XKCD_SPIDER_ARMY_COORD = {
(1, 0): None,
(0, 3): [("z", -90), ("y", 180)],
(-1, -2): [("z", 0), ("y", 180)],
(3, -2): [("z", 0), ("y", 180)],
(5, 2): [("z", 180), ("y", -90)],
(6, -1): [("z", 90)],
(8, 1): None,
(10, -1): [("y", -90)],
(-2, 2): [("z", -90)],
(-4, 2): [("y", 90)],
(-6, -1): [("y", 180)],
(-8, 2): [("x", -90)],
(-8, -2): [("y", 90)],
(-10, -3): None,
}
def get_xkcd_spider_army(
spider_army_coord: Dict[Tuple[int, int], List[Tuple[str, int]]] = None,
extra_spider: bool = True,
) -> List[Tuple[pv.PolyData, pv.PolyData]]:
"""Generate the xkcd spider army through the army coordinates.
Args:
spider_army_coord (Dict[Tuple[int, int], List[Tuple[str, int]]], optional): Coordinates
and rotation steps of the red spider army. Check XKCD_SPIDER_ARMY_COORD for the
example setting. Defaults to None.
extra_spider (bool, optional): whether or not to add extra spiders on two boxes, to
improve fidelity with the original comic. Defaults to True.
Returns:
List[Tuple[pv.PolyData, pv.PolyData]]: list of (spider, box) ``pv.PolyData`` tuples.
"""
if spider_army_coord is None:
spider_army_coord = XKCD_SPIDER_ARMY_COORD
spider_army = []
for spider_unit_coord, spider_unit_rotation in spider_army_coord.items():
spider_army.append(
process_spider_box_unit_cell(
spider=get_unit_cell_spider(),
box=get_unit_cell_box(),
rotation=spider_unit_rotation,
translation=list(spider_unit_coord) + [0],
)
)
# Add two extra spiders for fidelity with xkcd comic
if extra_spider and (spider_army_coord == XKCD_SPIDER_ARMY_COORD):
spider_army += [
process_spider_box_unit_cell(
spider=get_unit_cell_spider(),
box=get_unit_cell_box(),
rotation=[("x", 90)],
translation=[-1, -2, 0],
),
process_spider_box_unit_cell(
spider=get_unit_cell_spider(),
box=get_unit_cell_box(),
rotation=[("z", 180)],
translation=[-4, 2, 0],
),
]
return spider_army
And now, you can see the re-created scene of "Red Spiders Cometh" in 3D!
Commemorate our conquest on the web
So far, we've been rendering the 3D scene with PyVista on the local machine. One cool thing about PyVista is: you can easily export your scene, and use vtkjs to allow our scene of conquest to be rendered in a website.
All it takes is one line of export_vtkjs code to export our scene to .vtkjs:
vtkjs_file_path = os.path.join(DATA_DIR, "red_spiders_cometh")
p.export_vtkjs(vtkjs_file_path)
Then, we'll be able to render the scene from within a browser! (I borrowed the code from another repo, dennissergeev/exoconvection-apj-2020)
So now, here I present to you, Red Spiders Cometh, now in 3D! (Clicking the gif will bring you to the website that you can play with yourself.
Last words
I have really enjoy my experience using PyVista so far. The library has a really extensive documentation and use cases, and I have had numerous cases where I was fiddling with things to see if they work in PyVista, and it turned out to work in my first try, which is always a pleasant surprise when you are playing with a new tool.
All the code examples (along with a more modularized code base) can be found in this repo: ikding/xkcd_red_spider_3d. Enjoy!
P.S. in case anyone is interested, here are all six XKCD comics in which the red spider was referenced:
Comments
comments powered by Disqus