3D Wireframes in SVG (via Python)

Contents

Overview

SVG is great for line art. It scales nicely for high DPI displays without using much bandwidth. However SVG was not designed for 3D, so it does not provide mechanisms for applying perspective transformation or hidden surface elimination.

These limitations can be overcome for simple meshes by baking the perspective transformation, carefully ordering the paths within the SVG document, and paying attention to the winding direction of projected polygons.

In this post I will show how to use Python to generate vector art as seen at the top of the page, including the fully lit 3D Möbius tube.

To see the complete code that I used to generate all the SVG images on this page, go to this GitHub repo.


Designing the Rendering API

First we need to define the classic ingredients that you’ll find in almost any 3D renderer: classes for a viewport, camera, mesh, and scene:

import numpy

from typing import NamedTuple, Callable, Sequence


class Viewport(NamedTuple):
    minx: float = -0.5
    miny: float = -0.5
    width: float = 1.0
    height: float = 1.0


class Camera(NamedTuple):
    view: numpy.ndarray
    projection: numpy.ndarray


class Mesh(NamedTuple):
    faces: numpy.ndarray
    style: dict = None
    shader: Callable[[int, float], dict] = None


class Scene(NamedTuple):
    meshes: Sequence[Mesh]

The viewport defines the rectangular region within the final image that the camera projects to. This can be left set to its default values unless the image contains multiple panels.

The camera encompasses the view matrix and the projection matrix. We can use pyrr to generate these; it provides create_look_at and create_perspective_projection functions.

The mesh has a list of faces, a shader, and a style dictionary that gets applied to the SVG group that represents the mesh.

Wait, a shader in SVG? Well, in this context the “shader” is an optional callback function that consumes a mesh face and produces a style dictionary that gets applied to the projected polygon.

The mesh also contains a three-dimensional numpy array called faces whose shape is n⨯m⨯3 where n is the number of faces and m is the number of vertices per face (e.g. m=4 for quad meshes). The last axis has a length of 3 because the mesh consists of X Y Z coordinates.

Using the API

Before we get to the implementation of our SVG generator, let’s look at how we’d use the above classes to create an image that looks like this:

First, we need to come up with the face data for the octahedron. Simple enough:

def octahedron():
    """Construct an eight-sided polyhedron"""
    f =  sqrt(2.0) / 2.0
    verts = numpy.float32([ ( 0, -1,  0), (-f,  0,  f), ( f,  0,  f), ( f,  0, -f), (-f,  0, -f), ( 0,  1,  0) ])
    triangles = numpy.int32([ (0, 2, 1), (0, 3, 2), (0, 4, 3), (0, 1, 4), (5, 1, 2), (5, 2, 3), (5, 3, 4), (5, 4, 1) ])
    return verts[triangles]

The above code snippet generates a 8⨯3⨯3 face array by dereferencing the vertex buffer using numpy’s “fancy indexing” feature.

Next, let’s set up the scene and invoke the renderer. Note the use of the aforementioned pyrr module to compute proper 4x4 matrices.

import pyrr

projection_matrix = pyrr.matrix44.create_perspective_projection(fovy=25, aspect=1, near=10, far=100)
view_matrix = pyrr.matrix44.create_look_at(eye=[25, -20, 60], target=[0, 0, 0], up=[0, 1, 0])
camera = Camera(view_matrix, projection_matrix)

style = dict(
    fill='white', fill_opacity='0.75',
    stroke='black', stroke_linejoin='round', stroke_width='0.005')

mesh = Mesh(15.0 * octahedron(), style=style)
view = View(camera, Scene([mesh]))
Engine([view]).render('octahedron.svg')

The stroke width is very small because our renderer normally sets up a SVG viewBox with width and height of 1.0, spanning the region from [-0.5, -0.5] to [+0.5, +0.5].

Our implementation will use the svgwrite module, which accepts style dictionaries that map sensibly to SVG attributes. Note that we use round for joining strokes, which is necessary for making a nice wireframe.

The render is kicked off using the Engine class, which is the only API type that we haven’t mentioned yet. This brings us to the next section…

Implementation

The engine is responsible for consuming a scene description and generating an SVG file. At a high level it simply iterates though the views and creates a SVG group for each mesh:

import numpy
import svgwrite

class Engine:

    def __init__(self, views):
        self.views = views

    def render(self, filename, size=(512,512), viewBox='-0.5 -0.5 1.0 1.0'):
        drawing = svgwrite.Drawing(filename, size, viewBox=viewBox)
        for view in self.views:
            projection = numpy.dot(view.camera.view, view.camera.projection)
            for mesh in view.scene.meshes:
                drawing.add(self._create_group(drawing, projection, view.viewport, mesh))
        drawing.save()

Note the usage of numpy.dot to multiply one 4x4 matrix with another. The resulting matrix will be used to project the homogeneous coordinates onto the viewing plane.

The real meat of the renderer is in the engine’s _create_group method, which consumes a mesh and produces an SVG group containing a list of polygons. Some of this code is similar to the OpenGL vertex pipeline.

def _create_group(self, drawing, projection, viewport, mesh):
    faces = mesh.faces
    shader = mesh.shader or (lambda face_index, winding: {})
    default_style = mesh.style or {}

    # Extend each point to a vec4, then transform to clip space.
    faces = numpy.dstack([faces, numpy.ones(faces.shape[:2])])
    faces = numpy.dot(faces, projection)

    # Apply perspective transformation.
    xyz, w = faces[:, :, :3], faces[:, :, 3:]
    faces = xyz / w

    # Apply the viewport transform to X and Y.
    faces[:, :, 0:1] = (1.0 + faces[:, :, 0:1]) * viewport.width / 2
    faces[:, :, 1:2] = (1.0 - faces[:, :, 1:2]) * viewport.height / 2
    faces[:, :, 0:1] += viewport.minx
    faces[:, :, 1:2] += viewport.miny

    # Sort faces roughly from back to front.
    z_centroids = -np.sum(faces[:, :, 2], axis=1)
    for face_index in range(len(z_centroids)):
        z_centroids[face_index] /= len(faces[face_index])
    face_indices = np.argsort(z_centroids)
    faces = faces[face_indices]

    # Compute the winding direction of each polygon.
    p0, p1, p2 = faces[:, 0, :], faces[:, 1, :], faces[:, 2, :]
    windings = np.cross(p2 - p0, p1 - p0)

    # Determine the style for each polygon and add it to the group.
    group = drawing.g(**default_style)
    for face_index, face in enumerate(faces):
        style = shader(face_indices[face_index], windings[face_index])
        if style is not None:
            group.add(drawing.polygon(face[:, 0:2], **style))

    return group

Some interesting things to note in the above implementation:

Examples

Parametric Sphere and Klein Bottle

The above image was generated by evaluating parametric equations.

First we define a function that consumes LOD factors (slices and stacks) and a callback function that evaluates a parametric equation. It produces a quad mesh. In abbreviated form, the function looks like this:

def parametric_surface(slices, stacks, func):
    verts = numpy.float32([])
    for i in range(slices + 1):
        for j in range(stacks):
            ...
            verts.append(func(theta, phi))

    faces = numpy.int32([])
    for i in range(slices):
        for j in range(stacks):
            ...
            faces.append((a, b, c, d))

    return verts[faces]

(For the complete code, see the GitHub repo.)

We also need to provide callback functions for the shapes of interest:

def sphere(u, v):
    x = sin(u) * cos(v)
    y = cos(u)
    z = -sin(u) * sin(v)
    return x, y, z

def klein(u, v):
    u = u * 2
    if u < pi:
        x = 3 * cos(u) * (1 + sin(u)) + (2 * (1 - cos(u) / 2)) * cos(u) * cos(v)
        z = -8 * sin(u) - 2 * (1 - cos(u) / 2) * sin(u) * cos(v)
    else:
        x = 3 * cos(u) * (1 + sin(u)) + (2 * (1 - cos(u) / 2)) * cos(v + pi)
        z = -8 * sin(u)
    y = -2 * (1 - cos(u) / 2) * sin(v)
    return x, y, z

Parametric Sphere with Thick Borders

Note that the above wireframe has varying width. The trick is to completely avoid using stroke. Instead we vary the fill style of each face by examining the divisibility of the face index.

slices, stacks, radius = 64, 64, 12
faces = radius * parametric_surface(slices, stacks, sphere)

antialiasing = "auto" # use 'crispEdges' to fix cracks

def shader(face_index, winding):
    slice = int(face_index / 64)
    stack = int(face_index % 64)
    if slice % 3 == 0 or stack % 3 == 0:
        return dict(fill='black', fill_opacity='1.0', stroke='none', shape_rendering=antialiasing)
    return dict(fill='white', fill_opacity='0.75', stroke='none', shape_rendering=antialiasing)

scene = Scene(Mesh(faces, shader))
Engine([View(camera, scene)]).render('parametric_sphere.svg')

Dashed Lines for Hidden Faces

The above scene culls away some of the faces to reveal the inside of the mesh. We draw the sphere in two passes: first backfacing triangles, then frontfacing triangles.

def backface_shader(face_index, winding):
    if winding >= 0: return None
    return dict(
        fill='#7f7fff', fill_opacity='1.0',
        stroke='black', stroke_linejoin='round',        
        stroke_width='0.001', stroke_dasharray='0.01')

def frontface_shader(face_index, winding):
    if winding < 0 or faces[face_index][0][2] > 0.9: return None
    return dict(
        fill='#7fff7f', fill_opacity='0.6',
        stroke='black', stroke_linejoin='round',
        stroke_width='0.003')

scene = svg3d.Scene()
scene.add_mesh(svg3d.Mesh(faces, backface_shader))
scene.add_mesh(svg3d.Mesh(faces, frontface_shader))
svg3d.Engine([svg3d.View(camera, scene)]).render('sphere_shell.svg')

Diffuse and Specular Lighting

Since the shading callback is given a face index, it can look at the original face and compute a facet normal. This allows us to generate reasonable lighting. Not exactly photorealistic but this is vector art! Here’s the shader I used for the above effect. Note that it culls away backfaces to help optimize the SVG a bit.

def frontface_shader(face_index, winding):
    if winding < 0: return None
    face = eyespace_faces[face_index]
    p0, p1, p2 = face[0], face[1], face[2]
    normal = pyrr.vector.normalize(pyrr.vector3.cross(p1 - p0, p2 - p0))
    df = max(0, numpy.dot(normal, LightDir))
    sf = pow(max(0, numpy.dot(normal, Hhat)), Shininess)
    color = df * numpy.float32([1, 1, 0]) + sf * numpy.float32([1, 1, 1])
    color = numpy.power(color, 1.0 / 2.2)
    return dict(
        fill=rgb(*color), fill_opacity='1.0',
        stroke='black', stroke_width='0.001')

Let’s wrap up with one more example of lighting:

The above shape is another parametric surface, similar to the Klein bottle and sphere. The parametric callback looks like this:

sign = numpy.sign

def mobius_tube(u, v):
    R = 1.5
    n = 3
    u = u * 2
    x = (1.0*R + 0.125*sin(u/2)*pow(abs(sin(v)), 2/n)*sign(sin(v)) + 0.5*cos(u/2)*pow(abs(cos(v)), 2/n)*sign(cos(v)))*cos(u)
    y = (1.0*R + 0.125*sin(u/2)*pow(abs(sin(v)), 2/n)*sign(sin(v)) + 0.5*cos(u/2)*pow(abs(cos(v)), 2/n)*sign(cos(v)))*sin(u)
    z = -0.5*sin(u/2)*pow(abs(cos(v)), 2/n)*sign(cos(v)) + 0.125*cos(u/2)*pow(abs(sin(v)), 2/n)*sign(sin(v))
    return x, y, z

Thanks for reading this post! Some references: