# An object-oriented SVG torus

The code below, torus.py, defines a class Torus for drawing an SVG image of a torus. The Torus class itself is a subclass of Shape, a more general class for depicting 3D objects in an SVG image, defined in shape.py. A usage example is given in the code of draw_torus.py, which creates this image:

In this image, a small amount of noise has been added to the coordinates of the "quads" that make up the torus, distorting its surface slightly.

torus.py:

from shape import Shape
import numpy as np

class Torus(Shape):
def __init__(self, c, a, ntheta=30, nphi=60):
self.c, self.a = c, a
self.ntheta, self.nphi = ntheta, nphi

# Create a mesh of points on the angular coordinates, theta and phi.
theta = np.linspace(0, 2.*np.pi, self.ntheta)
phi = np.linspace(0, 2.*np.pi, self.nphi)
self.theta, self.phi = np.meshgrid(theta, phi)

def get_xyz(self):
"""Get the cartesian coordinates of a point on the torus."""

x = (self.c + self.a*np.cos(self.theta)) * np.cos(self.phi)
y = (self.c + self.a*np.cos(self.theta)) * np.sin(self.phi)
z = self.a * np.sin(self.theta)
self.xyz = np.stack((x, y, z), axis=-1)
self.y = self.xyz[:,:,1]

def setup_torus(self):
self.get_xyz()

# Calculate the coordinates of the quads, and keep track of each quad's
# mean position in the y-axis (depth-axis) in ypos.

# To look inside the torus, we may not want to draw it all. Specify the
# indexes of the phi coordinates of the quads to draw as j1, j2 here:
j1, j2 = self.nphi//2, self.nphi
j1, j2 = 0, self.nphi
ni, nj = self.ntheta, j2-j1
self.ypos = np.empty(ni*nj)
for i in range(self.ntheta):
for j in range(j1, j2):
ip, jp = (i+1) % self.ntheta, (j+1) % self.nphi
idx = i*nj+j-j1
(self.bx[j,ip], self.by[j,ip]),
(self.bx[jp,ip], self.by[jp,ip]),
(self.bx[jp,i], self.by[jp,i]))
ym = np.mean( (self.y[j,i], self.y[j,ip],
self.y[jp,ip], self.y[jp,i]) )
self.ypos[idx] = ym
# Get the indexes of the quads sorted by their distance from the camera
self.idy = np.argsort(self.ypos)


shape.py:

import numpy as np

class Shape:
def __init__(self):
pass

def distort(self, noise=0.1):
self.xyz += noise * (np.random.random(self.xyz.shape)-0.5)
self.y = self.xyz[:,:,1]

def rotate_xyz(self, alpha, beta, gamma):
"""Rotate the point(s) P=xyz by Rz(alpha)Ry(beta)Rx(gamma).P"""

ca, sa = np.cos(alpha), np.sin(alpha)
Rz = np.array(((1,0,0),(0,ca,-sa),(0,sa,ca)))
cb, sb = np.cos(beta), np.sin(beta)
Ry = np.array(((cb,0,sb),(0,1,0),(-sb,0,cb)))
cg, sg = np.cos(gamma), np.sin(gamma)
Rx = np.array(((cg,-sg,0),(sg,cg,0),(0,0,1)))
R = Rz @ Ry @ Rx
self.xyz = self.xyz @ R.T
self.y = self.xyz[:,:,1]

def get_perspective_view(self, C, E):
"""
Simple perspective projection of point A onto display plane at E
as seen by camera at C. Returns bx,by the image plane coordinates
of the projection (which are the x,z coordinates of B).

"""

D = self.xyz - C
r = E[1] / D[:,:,1]
B = r[:,:,None] * D + E
self.bx, self.by = B[:,:,0], B[:,:,2]


Also required, is palettes.py:

    # Some different colour schemes for our torus.
import random

def rgb_to_html(rgb):
return '#{:02x}{:02x}{:02x}'.format(*rgb)

def html_to_frac(html):
r, g, b = int(html[1:3], 16), int(html[3:5], 16), int(html[5:7], 16)
return r/255, g/255, b/255

pastel_colours = ['#e0bbe4', '#957dad', '#d291bc', '#fec8d8', '#ffdfd3']

elmer_colours = [[68,90,233], [112,0,128], [103,204,33], [217,17,29],
[228,151,200], [239,240,233], [23,16,16], [242,97,9], [251,236,21]]
for i, rgb in enumerate(elmer_colours):
elmer_colours[i] = rgb_to_html(rgb)

"""Map the ramp i=0->b onto a triangle i=0->1->0."""
b = len(idy)
r = b - abs(i - b/2)
rgb = (int(r/b*255),)*3
return rgb_to_html(rgb)

c1, c2 = html_to_frac(c1), html_to_frac(c2)
d = c2[0]-c1[0], c2[1]-c1[1], c2[2]-c1[2]
b = len(idy)
r = 1 - abs(i - b/2) / b
rgb = (c1[0] + r*d[0], c1[1] + r*d[1], c1[2] + r*d[2])
rgb = (int(s*255) for s in rgb)
return rgb_to_html(rgb)

colour_funcs = {
'grey': lambda i, _: '#dddddd',
'white': lambda i, _: '#ffffff',
'pastels': lambda i, _: random.choice(pastel_colours),
'elmer': lambda i, _: random.choice(elmer_colours),
}


Finally, the example, draw_torus.py:

import numpy as np
from torus import Torus
from palettes import colour_funcs

# The major and minor radius of the torus.
c, a = 2.5, 0.5
# Pick a colour scheme from the get_colours dictionary
palette_args = '#0000ff', '#00ff00'
# Image dimensions and scaling factors from torus units to image units.
width, height = 800, 600
scalex, scaley = 140, 140
# Tait-Bryan angles for intrinsic rotation of the figure.
alpha, beta, gamma = 90, 25, 10
# Camera position, C, and projection plane position, E (relative to C).
cx, cy, cz = 0, 6, 0
ex, ey, ez = 0, 3, 0
C, E = np.array((cx,cy,cz)), np.array((ex,ey,ez))
# Distortion noise factor
noise = 0.1

def preamble(fo):
"""The SVG preamble and styles."""

print('<?xml version="1.0" encoding="utf-8"?>\n'

'<svg xmlns="http://www.w3.org/2000/svg"\n' + ' '*5 +
.format(width, height), file=fo)

print("""
<defs>
<style type="text/css"><![CDATA[""", file=fo)

print('path {stroke-width: 0.5px; stroke: #000;}', file=fo)

print("""]]></style>
</defs>""", file=fo)

torus = Torus(c, a)
torus.setup_torus()
torus.rotate_xyz(alpha, beta, gamma)
torus.distort(noise)

torus.get_perspective_view(C, E)

# Draw the torus as a SVG image.
get_colour = colour_funcs[palette]
with open('torus.svg', 'w') as fo:
preamble(fo)
for i in torus.idy:
colour = get_colour(i, torus.idy, *palette_args)
print('<path d="M{},{} L{},{} L{},{} L{},{} Z" fill="{}"/>'.format(
file=fo)
print('</svg>', file=fo)

Current rating: 5