Best library for drawing geometric diagrams

I’m looking to be able to produce something that looks like this (not too worried about the glyphs, I can add them later), and wondered what the best package to use would be?

The features I need are concentric circles, and to be able to say “draw a line a 6 degrees” and that sort of thing.

Thanks!

Bokeh is one option, compare e.g. this example

Though the custom glyphs may be a little work, depending on your requirements.

3 Likes

Thank you! I’ll give that a try. I’m not too fussed about the glyphs, happy to add those afterwards in Photoshop or even (shock, horror) Powerpoint :slight_smile:

Just to clarify, Bokeh is primarily a tool for generating dynamic web visualizations in browsers, although it doe also have APIs for generating PNGs and SVGs (with some optional dependencies installed). But if your only use case is static images, you might also look at Matplotlib.

1 Like

Just a quick update here - after asking around a bit, I ended up using PIL (Pillow) and was able to write a Python program to produce exactly what I wanted, including glyphs - here’s the finished product, and it’s a lot crisper than the version I originally made, which involved Photoshopping screenshots of the output of a program and adding lots of (not very well lined up) lines and glyphs! And of course, it’s very customisable.

Thanks to people on here and other Python communities for giving me suggestions - I’m very much a Python newbie, so just knowing I wasn’t asking for the impossible was very helpful, and gave me the confidence to just try it all out!

3 Likes

Hello! I am trying to do this too. Can you provide a little bit more detail? Thanks

Can you share the code please?

Hi @mica-92 and @pije76 - sorry for the late reply, only just seen both of your posts!

Here’s my code:

def getangle(deg, sign):
 absdeg=signs.find(sign)/2*30+deg
 return int(absdeg)

def drawline(inner, outer, absdeg):
 # the line will always be from sdist pixels from the origin to edist from the origin at an angle of angle
 # angle is: 0=0Li, 330=0Sc, 300=0Sg etc
 # conversion rule is mod(540-absdeg,360) to get the angle we want
 angle=(540-absdeg)%360
 sdist=1024-circles[inner][0]
 edist=1024-circles[outer][0]
 sl=cmath.rect(sdist,radians(angle))
 el=cmath.rect(edist,radians(angle))
 draw.line([(1024*rf+sl.real*rf,1024*rf+sl.imag*rf),(1024*rf+el.real*rf,1024*rf+el.imag*rf)], fill='black', width=2*rf)

def getpos(ringo, ringi, sdeg, edeg):
 # get the position of the centre of the segment
 # eg 3,5,getangle(12,"Pi"),getangle(16,"Pi") will return the middle of the 12-16 segment in the terms ring
 sdist=1024-circles[ringi][0]
 edist=1024-circles[ringo][0]
 midpoint=int((edist-sdist)/2)+sdist
 angle=(sdeg+edeg)/2
 angle=(540-angle)%360
 coor=cmath.rect(midpoint,radians(angle))
 return [int((coor.real+1024)*3), int((coor.imag+1024)*3)]

def drawglyph(x, y, glyph, colour, sfont):
 draw.text((x,y), glyph, fill=colour, anchor="mm", font=sfont)

def parse_terms(terms):
 parsed=[]
 for i in range(12):
  start=i*30
  chunk = terms[i*21:i*21+20]
  for j in range(5):
   thisterm=chunk[j*4:j*4+4]
   end=int(thisterm[0:2])+i*30
   plglyph=planet_glyphs[int(planets.find(thisterm[2:4])/2)]
   parsed.append([start, end, plglyph])
   start=end
 return parsed

def placerot(glyph, deg, colour):
 boxsize=240
 halfbox=int(boxsize/2)
 im2=Image.new('RGB', (boxsize,boxsize), (255,255,255))
 draw2=ImageDraw.Draw(im2)
 draw2.text((halfbox,halfbox), glyph, colour, anchor='mm', font=fontrot2)
 # Now work out rotation
 # 135 = 45deg, 90=0 deg, 45=-45deg, 5=-85, 175=85, 185=same as 5, 270=0, 315=same as 135
 rot=deg
 if rot>180:
  rot=rot-180
 rot=rot-90
 im2=im2.rotate(rot, expand=0, fillcolor='white')
 # We now have a rotated glyph, so now work out where this goes in the trip segment
 segpos=getpos(7, 6, deg, deg)
 tup=[segpos[0]-halfbox, segpos[1]-halfbox, segpos[0]+halfbox, segpos[1]+halfbox]
 Image.Image.paste(im, im2, tup)
 

# Initialise
from PIL import Image, ImageDraw, ImageFont
from math import sin, cos, tan, pi, radians, degrees
import cmath
import glob
circles=[[24,2024,True], [128,1920,True], [142,1906,False], [226,1822,True], [298,1750,False], [312,1736,False], [326,1722,True], [462,1586,True], [676,1372,True], [856,1192,True], [1024,1024,False]]
# circles is a list as follows
# Ring numbers:
# 0 Outer circle with signs
# 1 Face ring radius
# 2 Tick mark edge
# 3 Term ring radius
# 4 Long tick mark edge
# 5 Tick mark edge
# 6 Trip ring radius
# 7 Exalt radius
# 8 Rulership radius
# 9 Inner ring

signs="ArTaGeCnLeViLiScSgCpAqPi"
planets="SaJuMaSuVeMeMo"
font36=ImageFont.truetype('C:/Users/Chris/AppData/Local/Microsoft/Windows/Fonts/StarFont_Sans__True_Type.TTF', 36*3, encoding="symb")
font48=ImageFont.truetype('C:/Users/Chris/AppData/Local/Microsoft/Windows/Fonts/StarFont_Sans__True_Type.TTF', 48*3, encoding="symb")
font72=ImageFont.truetype('C:/Users/Chris/AppData/Local/Microsoft/Windows/Fonts/StarFont_Sans__True_Type.TTF', 72*3, encoding="symb")
fontrot=ImageFont.truetype('C:/Users/Chris/AppData/Local/Microsoft/Windows/Fonts/StarFont_Sans__True_Type.TTF', 48*3, encoding="symb")
fontrot2=ImageFont.truetype('C:/Users/Chris/AppData/Local/Microsoft/Windows/Fonts/StarFont_Sans__True_Type.TTF', 60*3, encoding="symb")
planet_glyphs="Sjhsgfd"     # Starfont representations of Saturn, Jupiter, Mars, Sun, Venus, Mercury, Moon
sign_glyphs="xcvbnmXCVBNM"  # Starfont representations of Aries through to Pisces
# Terms: two types, Egyptian and Ptolemy
# We'll parse each string into a list, but the string format is ddppddppddppddppddpp/ddppddppddppddppddpp/...
# The slash is to separate signs just for readability. The quintuplets are degrees and planet
# eg Aries is 0-6 Jupiter, 6-12 Venus, 12-20 Mercury, 20-25 Mars, 25-30 Saturn, represented as 06Ju12Ve20Me25Ma30Sa
termse="06Ju12Ve20Me25Ma30Sa/08Ve14Me22Ju27Sa30Ma/06Me12Ju17Ve24Ma30Sa/07Ma13Ve19Me26Ju30Sa/06Ju11Ve18Sa24Me30Ma/07Me17Ve21Ju28Ma30Sa/06Sa14Me21Ju28Ve30Ma/07Ma11Ve19Me24Ju30Sa/12Ju17Ve21Me26Sa30Ma/07Me14Ju22Ve26Sa30Ma/07Me12Ve20Ju25Ma30Sa/12Ve16Ju19Me28Ma30Sa"
termsp="06Ju14Ve21Me26Ma30Sa/08Ve15Me22Ju26Sa30Ma/07Me14Ju21Ve25Sa30Ma/06Ma13Ju20Me27Ve30Sa/06Sa13Me19Ve25Ju30Ma/07Me13Ve18Ju24Sa30Ma/06Sa11Ve19Ju24Me30Ma/06Ma14Ju21Ve27Me30Sa/08Ju14Ve19Me25Sa30Ma/06Ve12Me19Ju25Ma30Sa/06Sa12Me20Ve25Ju30Ma/08Ve14Ju20Me26Ma30Sa"
# We need to convert the list of terms into a list of triples of the form [startdeg, enddeg, glyph] so for example, the 3rd term of Gemini for Ptolemaic terms is 14-21 Gemini and is Venus, so this would be [74, 81, "j"]
terms=[parse_terms(termse), parse_terms(termsp)]

# Set up canvas
rf=3  # resize factor for antialiasing
termtype=int(input("Egyptian (0) Ptolemaic (1)? "))
im=Image.new('RGB', (2048*rf, 2048*rf), (255, 255, 255))
draw=ImageDraw.Draw(im)
draw.ellipse((24*rf, 24*rf, 2024*rf, 2024*rf), fill=(255, 255, 255), outline=(0, 0, 0))
# draw the main circles
for coords in circles:
 if coords[2]:
  draw.ellipse((coords[0]*rf, coords[0]*rf, coords[1]*rf, coords[1]*rf), fill=(255,255,255), outline=(0,0,0), width=2*rf)
# now draw the 12 lines from outer wheel to inner circle
for i in range(12):
 drawline(9, 0, i*30)
# now draw tick marks on term ring
for i in range(360):
 if i%30 != 0:
  oring=5
  if i%10 == 0:
   oring=4  # long tick
  drawline(6, oring, i)
# now draw term segments
chosenterm=terms[termtype]  # 0 will give us the Egyptian terms, 1 the Ptolemaic ones
for pos in chosenterm:
 drawline(6, 3, pos[1])
# now draw face segments
for i in range(36):
 if i%30 !=0:
  drawline(3, 1, i*10)
# finally, draw tick marks for face
for i in range(360):
 if i%10:
  drawline(2, 1, i)

# The lines and circles are done, so now place glyphs
# All glyphs are upright EXCEPT for triplicities - if the angle would be more than 90 deg anticlockwise from the vertical (which happens for Aries to Virgo), subtract from 90

# Trips:
# Font size is 72, and needs to be cusp+6, cusp+13, cusp+20
# eg placerot('j',186,'red'), placerot2('h',193,'red'), placerot2('S',200,'red') using these glyphs as an (incorrectim.re) example as they're the biggest
# So the degree position is signno*30+6, signno*30+13, signno*30+20

if termtype==0:
 # Egyptian terms, so we're doing Dorothean triplicities
 tripparams=[['red','s','j','S'], ['green', 'g','d','h'], ['darkorange', 'S','f','j'], ['blue', 'g','h','d']]  # by element - so fire is red, Sun, Jupiter, Saturn
 for i in range(12):
  element=i%4
  param=tripparams[element]
  for j in range(3):
   deg=i*30+8+(j*8)  # so 7th sign (i=6) maps to 186+0, 186+7, 186+14
   if i<6:
    index=j+1
   else:
    index=3-j  # Because of the way placerot works, the orientation needs to be reversed for the top half of the chart
   placerot(param[index], deg, param[0])
else:
 # Ptolemaic terms, so we're using just day and night ruler and a different colour scheme
 tripparams=[['red','s','darkred','j'], ['mediumseagreen','g','darkgreen','d'], ['darkorange','S','saddlebrown','f'], ['cornflowerblue','h','darkblue','h']]
 for i in range(12):
  element=i%4
  param=tripparams[element]
  for j in range(2):
   deg=i*30+11+(j*8)  # so 7th sign maps to 190+0, 190+7
   if i<6:
    index=j*2
   else:
    index=(1-j)*2
   placerot(param[index+1], deg, param[index])

# Inner ring: rulerships
rulers='hgfdsfghjSSj'
for i in range(12):
 xy=getpos(9, 8, i*30, (i+1)*30)
 colour='red'
 if i>9:
  colour='blue'
 if i<4:
  colour='blue'
 drawglyph(xy[0], xy[1], rulers[i], colour, font72)

# Exaltations
# No pattern to these so just place directly
exalts=[[0,'s'], [1,'d'], [3,'j'], [5,'f'], [6,'S'], [9,'h'], [11,'g']]
for ex in exalts:
 xy=getpos(8, 7, ex[0]*30, (ex[0]+1)*30)
 drawglyph(xy[0], xy[1], ex[1], 'black', font72)

# Terms
chosenterm=terms[termtype]  # 0 will give us the Egyptian terms, 1 the Ptolemaic ones
for pos in chosenterm:
 xy=getpos(6, 3, pos[0], pos[1])
 drawglyph(xy[0], xy[1], pos[2], 'black', font36)

# Faces
facelist='hsgfdSj'
for i in range(36):
 glyph=facelist[i%7]
 xy=getpos(3, 1, i*10, i*10+10)
 drawglyph(xy[0], xy[1], glyph, 'black', font72)

# Signs
elcolours=['red', 'green', 'darkorange', 'blue']
for i in range(12):
 glyph=sign_glyphs[i]
 colour=elcolours[i%4]
 xy=getpos(1, 0, i*30, i*30+30)
 drawglyph(xy[0], xy[1], glyph, colour, font72)

#Finally, a little dot in the middle
draw.ellipse((1020*rf, 1020*rf, 1028*rf, 1028*rf), fill=(0,0,0), outline=(0,0,0), width=2*rf)

im.resize((2048,2048), Image.LANCZOS)
im.show()
if termtype==0:
 im.save('d:/zz/egyptian.png', 'PNG')
else:
 im.save('d:/zz/ptolemaic.png', 'PNG')