Last active
November 8, 2025 16:49
-
-
Save MathiasSven/b9f3614cfca7b94c21a7614d765bc89b to your computer and use it in GitHub Desktop.
A Python 3 version of David Eppstein’s LombardiSpirograph.py graph drawing script. This version is based on the mirror from https://github.com/rbrito/lombardi-spirograph and has been converted using 2to3. The gist also includes a default.nix file for packaging.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { pkgs ? import <nixpkgs> { } }: | |
| let | |
| inherit (pkgs) runCommand fetchgit python3; | |
| in | |
| runCommand "lombardi-spirograph" { | |
| src = fetchgit { | |
| url = "https://gist.github.com/b9f3614cfca7b94c21a7614d765bc89b.git"; | |
| rev = "81d2107849a401a007c89a2150bab64bc2fb1bb6"; | |
| sha256 = "sha256-K5g2VDlYKW1QQ3sMXE+cZ4RSLT36tC2Xuk3onLUhdrI="; | |
| }; | |
| buildInputs = [ python3 ]; | |
| } '' | |
| install $src/LombardiSpirograph.py -D $out/bin/LombardiSpirograph | |
| patchShebangs $out/bin/LombardiSpirograph | |
| '' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| # -*- coding: utf-8 -*- | |
| # | |
| # Copyright (C) 2010 David Eppstein | |
| # | |
| # Permission is hereby granted, free of charge, to any person obtaining a | |
| # copy of this software and associated documentation files (the "Software"), | |
| # to deal in the Software without restriction, including without limitation | |
| # the rights to use, copy, modify, merge, publish, distribute, sublicense, | |
| # and/or sell copies of the Software, and to permit persons to whom the | |
| # Software is furnished to do so, subject to the following conditions: | |
| # | |
| # The above copyright notice and this permission notice shall be included in | |
| # all copies or substantial portions of the Software. | |
| # | |
| # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL | |
| # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER | |
| # DEALINGS IN THE SOFTWARE. | |
| """ | |
| LombardiSpirograph - draw rotationally symmetric drawings in Lombardi style | |
| David Eppstein, UC Irvine, March 2010 | |
| For usage information type "python LombardiSpirography.py" | |
| without any additional arguments. | |
| """ | |
| import sys | |
| from math import cos, e, pi, sin, tan | |
| from optparse import OptionParser | |
| # ============================================================ | |
| # Pre-determined graphs by name | |
| # ============================================================ | |
| namedGraphs = { | |
| "andrasfai4": "11-ad", | |
| "andrasfai5": "14-gad", | |
| "andrasfai6": "17-dag", | |
| "andrasfai7": "20-jdag", | |
| "antihole7": "7-cb", | |
| "antihole8": "8-dab", | |
| "antihole9": "9-cab", | |
| "antiprism4": "4-a1-a", | |
| "antiprism5": "5-a1-a", | |
| "antiprism6": "6-a1-a", | |
| "brinkmann": "7-c2-1-b", | |
| "c5xc6": "15-c5-c", | |
| "cogwheel3": "3-x-1-0", | |
| "cogwheel4": "4-x-1-0", | |
| "cogwheel5": "5-x-1-0", | |
| "cogwheel6": "6-x-1-0", | |
| "complete5": "5-ab", | |
| "complete6-a": "6-cab", | |
| "complete6-b": "5-x-ab", | |
| "complete6-c": "3-a02-a", # minimum-crossing drawing | |
| "complete7": "7-bac", | |
| "complete8-a": "8-dcab", | |
| "complete8-b": "7-x-bac", | |
| "crown5": "10-ac", | |
| "crown6": "6-a04-a", | |
| "crown7": "14-cae", | |
| "cube": "4-a-a", | |
| "cuboctahedron": "4-a1-1-a", | |
| "dodecahedron-a": "5-a-1-0-a", | |
| "dodecahedron-b": "10-a-b", | |
| "desargues": "10-a-c", | |
| "durer": "6-a-b", | |
| "dyck": "8-a-2-0-c", | |
| "f40": "10-a-4-0-a", | |
| "grotzsch": "5-x-1-b", | |
| "hypercube": "8-c2-a", | |
| "icosahedron": "3-a01-01-1-a", | |
| "icosidodecahedron": "10-a1-2-b", | |
| "k33": "6-ca", | |
| "k44": "8-ca", | |
| "k55": "10-eac", | |
| "k66": "12-eac", | |
| "mobiuskantor": "8-a-c", | |
| "nauru": "12-a-e", | |
| "octahedron": "3-a1-a", | |
| "paley13": "13-dac", | |
| "pappus": "6-c2-0-a", | |
| "prism3": "3-a-a", | |
| "prism5": "5-a-a", | |
| "prism6": "6-a-a", | |
| "petersen": "5-a-b", | |
| # "shrikhande": "8-ba1-bc", # requires arcs to pass through vertices | |
| "sun3": "3-a1-0", | |
| "sun4": "4-ba1-0", | |
| "sun5": "5-ba1-0", | |
| "sun6": "6-cba1-0", | |
| "tetrahedron-a": "3-x-a", | |
| "tetrahedron-b": "4-ba", | |
| "utility": "6-ca", | |
| "wagner": "8-da", | |
| "wheel4": "4-x-a", | |
| "wheel5": "5-x-a", | |
| "wheel6": "6-x-a", | |
| } | |
| # ============================================================ | |
| # Command-line options | |
| # ============================================================ | |
| parser = OptionParser() | |
| parser.add_option("-f", "--format", dest="show_format", action="store_true", | |
| help = "describe the graph input format and exit") | |
| parser.add_option("-n", "--names", dest="show_names", action="store_true", | |
| help = "show a description of graph names and exit") | |
| parser.add_option("-s", "--scale", dest="scale", action="store", | |
| type="float", default="1.0", | |
| help = "size of overall drawing relative to default") | |
| parser.add_option("-r", "--radius", dest="radius", action="store", | |
| type="float", default="1.0", | |
| help = "radius of vertices relative to default") | |
| parser.add_option("-c", "--color", dest="color", action="store", | |
| type="string", default="red", | |
| help = "vertex color (e.g. blue or 76B3DF)") | |
| parser.add_option("-o", "--outline", dest="outline", action="store_true", | |
| help = "avoid drawing outlines around vertices") | |
| options, args = parser.parse_args() | |
| def abort(message): | |
| print(message, file=sys.stderr) | |
| sys.exit(-1) | |
| graphName = "-".join(args).lower() | |
| if options.show_format: | |
| if graphName: | |
| abort("--format option does not take any arguments") | |
| print('''The graph should be described as a sequence of alphanumeric words, | |
| separated either by spaces or by blank lines. The first word gives the | |
| order of symmetry of the drawing (the number of vertices in each | |
| concentric layer) and each subsequent word describes the vertices in | |
| a single layer of the graph. | |
| Each word after the first takes the form of a (possibly empty) sequence | |
| of letters followed by a (possibly empty) number. The letters describe | |
| edges connecting two vertices in the same layer: "a" for a connection | |
| between consecutive vertices in the same layer, "b" for a connection | |
| between vertices two steps away from each other, etc. The letters should | |
| be listed in the order the connections should appear at the vertex, | |
| starting from the edge closest to the center of the drawing and | |
| progressing outwards. Only connections that span less than half the circle | |
| are possible, except that the first layer may have connections spanning | |
| exactly half the circle. | |
| The numeric part of a word describes the connection from one layer to the | |
| next layer. If this number is zero, then vertices in the inner layer are | |
| connected to vertices in the next layer radially by straight line segments. | |
| Otherwise, pairs of vertices from the inner layer, the given number of steps | |
| apart, are connected to single vertices in the outer layer. A nonzero number | |
| written with a leading zero (e.g. "01" in place of "1") indicates that, as | |
| well as connections with the given number of steps, there should also be a | |
| radial connection from the inner layer to the next layer that has vertices | |
| aligned with it; this may not necessarily be the layer immediately outward. | |
| In the innermost layer, the special word "x" may be used to indicate that | |
| the layer consists of a single vertex at the center of the drawing. "x0" | |
| indicates that this central vertex is connected both to every vertex in the | |
| adjacent layer and also to every vertex in the next layer that is staggered | |
| with respect to the inner two layers. | |
| ''') | |
| sys.exit(0) | |
| if options.show_names: | |
| if graphName: | |
| if graphName not in namedGraphs: | |
| print('''Graph name''', graphName, '''is not recognized. | |
| Run | |
| python LombardiSpirograph --names | |
| without any command line arguments to get a list of recognized names.''') | |
| else: | |
| print(graphName, "is equivalent to", namedGraphs[graphName]) | |
| sys.exit(0) | |
| print('''This program has built into it a set of graph names that may | |
| be used as the command-line argument to specify the graph to be | |
| drawn. They are: | |
| ''') | |
| graphs = list(namedGraphs.items()) | |
| graphs.sort() | |
| graphs = [("Name", "Description"), ("====", "===========")] + graphs | |
| for name, description in graphs: | |
| print(" " + name + " " * (20 - len(name)) + description) | |
| sys.exit(0) | |
| if not graphName: | |
| print('''This program draws rotationally-symmetric graphs in Lombardi style: | |
| the edges are circular arcs that meet at equal angles at each vertex. | |
| To use it, type | |
| python LombardiSpirograph.py [graph] >output.svg | |
| to a command line, where [graph] is replaced by a name or description | |
| of the graph to be drawn. | |
| For a list of available graph names, type | |
| python LombardiSpirograph.py --names | |
| For help with the input format for graph descriptions, type | |
| python LombardiSpirograph.py --format | |
| For a list of other command line options, type | |
| python LombardiSpirograph.py --help | |
| ''') | |
| sys.exit(0) | |
| # ============================================================ | |
| # Command line parsing | |
| # ============================================================ | |
| if graphName in namedGraphs: | |
| graphName = namedGraphs[graphName] | |
| try: | |
| # Split command line argument into symmetry and level descriptors | |
| nameComponents = graphName.split("-") | |
| symmetry = int(nameComponents[0]) | |
| vertexDescriptors = nameComponents[1:] | |
| levels = len(vertexDescriptors) | |
| # Parse out the X for the descriptor at the inner level | |
| central = [False] * levels | |
| radialzero = False | |
| if vertexDescriptors[0] == "x": | |
| vertexDescriptors[0] = "" | |
| central[0] = True | |
| elif vertexDescriptors[0] == "x0": | |
| vertexDescriptors[0] = "" | |
| central[0] = True | |
| radialzero = True | |
| # Parse out the letters for the circulant at each level | |
| circulant = [None] * levels | |
| for i in range(levels): | |
| circulant[i] = [ord(x) - ord('a') + 1 for x in vertexDescriptors[i] | |
| if x >= "a" and x < "x"] | |
| vertexDescriptors[i] = vertexDescriptors[i][len(circulant[i]):] | |
| # Parse out the numbers for which other vertex at this level | |
| # connects to the same vertex at the next level | |
| connector = [0] * levels | |
| radial = [False] * levels | |
| for i in range(levels): | |
| if vertexDescriptors[i]: | |
| connector[i] = int(vertexDescriptors[i]) | |
| if connector[i] and vertexDescriptors[i][0] == "0": | |
| radial[i] = True | |
| if radialzero: | |
| radial[0] = True | |
| except: | |
| abort('''Unable to parse command line arguments. | |
| For usage type "python LombardiSpirography.py help"''') | |
| # ============================================================ | |
| # Sanity checks | |
| # ============================================================ | |
| if connector[-1]: | |
| abort("Outer level should not specify connector to next level") | |
| threshold = symmetry | |
| for c in circulant: | |
| for offset in c: | |
| if offset * 2 > threshold: | |
| abort("Circulant specification goes too far") | |
| threshold = symmetry - 1 | |
| for offset in connector: | |
| if offset >= symmetry: | |
| # if offset * 2 > symmetry: | |
| abort("Connector specification goes too far") | |
| # ============================================================ | |
| # Preliminary calculations | |
| # ============================================================ | |
| stagger = [False] * levels | |
| s = False | |
| for i in range(levels): | |
| stagger[i] = s | |
| if connector[i] % 2: | |
| s = not s | |
| if central[0] and radial[0]: | |
| stagger[0] = True | |
| inRadial = [False] * levels | |
| for i in range(levels): | |
| if radial[i]: | |
| conn = [j for j in range(i + 1, levels) if stagger[i] == stagger[j]] | |
| if not conn: | |
| abort("No layer outward of %s with same stagger" % i) | |
| radial[i] = conn[0] | |
| inRadial[conn[0]] = True | |
| indegree = [1] * (levels + 1) | |
| for i in range(levels): | |
| if connector[i]: | |
| indegree[i + 1] += 1 | |
| if inRadial[i]: | |
| indegree[i] += 1 | |
| indegree[0] = indegree[levels] = 0 | |
| # Fudge factor for some angle computations | |
| incount = [indegree[i] + 1 for i in range(levels)] | |
| if circulant[0] and circulant[0][0] * 2 == symmetry: | |
| incount[0] = 0 | |
| degree = [0] * levels | |
| for i in range(levels): | |
| if central[i]: | |
| degree[i] = symmetry | |
| else: | |
| degree[i] = indegree[i] + indegree[i + 1] | |
| degree[i] += 2 * len(circulant[i]) | |
| if i == 0 and symmetry % 2 == 0 and symmetry // 2 in circulant[i]: | |
| degree[i] -= 1 | |
| for i in range(levels): | |
| mid = -1 | |
| if degree[i] % 2 == 0: # Even degree: | |
| if i == 0: | |
| if levels == 1: # Only a single level | |
| if len(circulant[i]) % 2 == 1: | |
| mid = len(circulant[i]) // 2 | |
| else: | |
| if len(circulant[i]) % 2 == 0: | |
| mid = len(circulant[i]) // 2 | |
| elif i < levels - 1: | |
| if len(circulant[i]) % 2 == 1: | |
| mid = len(circulant[i]) // 2 | |
| elif len(circulant[i]) % 2 == 0: | |
| mid = len(circulant[i]) // 2 - 1 | |
| if mid >= 0 and mid < len(circulant[i]) and circulant[i][mid] != 1: | |
| abort("Central circulant edge can only be adjacent") | |
| # Test which edges within a single level follow line segments instead of arcs | |
| straightCirculant = [[False] * len(circulant[i]) for i in range(levels)] | |
| for i in range(levels): | |
| for j in range(len(circulant[i])): | |
| if 2 * (degree[i] * circulant[i][j] + symmetry * | |
| (2 * j + incount[i])) == degree[i] * symmetry: | |
| straightCirculant[i][j] = True | |
| # ============================================================ | |
| # Calculate vertex placements and connector radii | |
| # ============================================================ | |
| def cot(x): | |
| """ | |
| Cotangent(x). Why is this not in math? | |
| """ | |
| return tan(pi / 2 - x) | |
| def distance(p, q): | |
| """ | |
| Euclidean distance from p to q. | |
| """ | |
| return ((p.real - q.real) ** 2 + (p.imag - q.imag) ** 2) ** 0.5 | |
| radius = [0] * levels # how far from origin to make each layer | |
| conrad = [0] * levels # distance point to ctr of curvature of connector | |
| straightConnector = [False] * levels | |
| for i in range(levels): | |
| if central[i]: | |
| radius[i] = 0 | |
| elif i == 0: | |
| radius[i] = symmetry / (2 * pi) # arclen 1 around initial circle | |
| elif connector[i - 1] == 0: | |
| factor = 1 + 2 * pi / symmetry | |
| if i > 1 and not circulant[i - 1]: | |
| if connector[i - 2] % 2 != 0: | |
| factor = (1 + factor) / 2 # hack dodecahedron less exponential | |
| elif radius[i - 2]: | |
| factor = min(factor, 2 - radius[i - 2] / radius[i - 1]) | |
| # hack pappus and dyck to be less exponential | |
| radius[i] = max(1, radius[i - 1] * factor) | |
| # same radial length as previous arclen | |
| else: | |
| # calculate some angles | |
| # p = inner point at level i-1 | |
| # q = unknown location of point at level i | |
| # o = origin | |
| # O = point on line op on the other side of p | |
| # c = center of curvature of connecting arc | |
| correction = 1 | |
| if radial[i - 1]: | |
| correction = 2 | |
| poq = pi * connector[i - 1] / symmetry | |
| pcq = (inRadial[i] + 1) * pi / degree[i] - correction * pi / degree[i - 1] + poq | |
| Opq = correction * pi / degree[i - 1] + pcq / 2 | |
| try: | |
| radius[i] = radius[i - 1] / ((cot(poq) - cot(Opq)) * sin(poq)) | |
| except: | |
| abort("Unable to make connecting arcs from level %s to level %s" | |
| % (i - 1, i)) | |
| if abs(pcq) < 0.01: | |
| straightConnector[i - 1] = True | |
| else: | |
| scr = distance(radius[i - 1], radius[i] * e ** (1j * poq)) / 2 | |
| conrad[i - 1] = scr / sin(pcq / 2) | |
| if i > 0 and radius[i] < radius[i - 1]: | |
| abort("Inverted levels") | |
| # ============================================================ | |
| # Generate graphical objects | |
| # ============================================================ | |
| def rotations(level): | |
| if central[level]: | |
| return [1j] | |
| return [1j * e ** (pi * 1j * i / symmetry) for i in | |
| range(int(stagger[level]), 2 * symmetry, 2)] | |
| def vertices(): | |
| """ | |
| Return sequence of complex numbers representing vertex positions. | |
| """ | |
| for i in range(levels): | |
| for r in rotations(i): | |
| yield radius[i] * r | |
| def segments(): | |
| """ | |
| Return sequence of vertex pairs to be connected by line segments. | |
| """ | |
| if circulant[0] and circulant[0][0] * 2 == symmetry: | |
| # cross-edges in center | |
| for i in range(circulant[0][0]): | |
| r = radius[0] * e ** (pi * 2j * i / symmetry) | |
| yield (1j * r, -1j * r) | |
| for i in range(levels): | |
| if indegree[i] == 1: | |
| for r in rotations(i): | |
| yield (radius[i - 1] * r, radius[i] * r) | |
| for j in range(len(circulant[i])): | |
| if straightCirculant[i][j]: | |
| p = radius[i] | |
| q = p * e ** (pi * 2j * circulant[i][j] / symmetry) | |
| for r in rotations(i): | |
| yield (p * r, q * r) | |
| if straightConnector[i]: | |
| p = radius[i] | |
| q = radius[i + 1] | |
| poq = e ** (1j * pi * connector[i] / symmetry) | |
| for r in rotations(i): | |
| yield (p * r, q * poq * r) | |
| yield (p * r, q * r / poq) | |
| if radial[i]: | |
| for r in rotations(radial[i]): | |
| yield (radius[i] * r, radius[radial[i]] * r) | |
| maxRadius = radius[-1] | |
| def circulants(): | |
| """ | |
| Return sequence of all arcs within a single level and compute maxRadius | |
| as we do. | |
| """ | |
| global maxRadius | |
| for i in range(levels): | |
| if central[i]: | |
| continue | |
| rot = rotations(i) | |
| p = radius[i] * rot[0] | |
| for j in range(len(circulant[i])): | |
| if straightCirculant[i][j]: | |
| continue | |
| step = circulant[i][j] | |
| if step * 2 == symmetry: | |
| continue | |
| q = radius[i] * rot[step] | |
| d = distance(q, p) | |
| theta = pi * step / symmetry # angle p-origin-circle center | |
| psi = pi * (2 * j + incount[i]) / degree[i] # angle o-p-cc | |
| bulgy = theta + psi > pi / 2 | |
| if bulgy: | |
| psi -= pi / 2 | |
| else: | |
| psi += pi / 2 | |
| rad = abs(d / (2 * sin(pi - theta - psi))) | |
| phi = pi / 2 - theta - psi # angle q-p-cc | |
| if bulgy: | |
| rtoc = radius[i] * cos(theta) - rad * sin(phi) | |
| maxRadius = max(maxRadius, rtoc + rad) | |
| for k in range(symmetry): | |
| r = e ** (pi * 2j * k / symmetry) | |
| if not bulgy: | |
| yield (p * r, q * r, rad) | |
| elif theta + psi < pi / 2: | |
| yield (q * r, p * r, rad) | |
| else: | |
| yield (p * r, q * r, -rad) | |
| def connectors(): | |
| """ | |
| Return sequence of all arcs from one level to the next. | |
| """ | |
| for i in range(levels): | |
| if connector[i] and not straightConnector[i]: | |
| p = radius[i] | |
| q = radius[i + 1] | |
| poq = e ** (1j * pi * connector[i] / symmetry) | |
| for r in rotations(i): | |
| if conrad[i] > 0: | |
| yield (q * poq * r, p * r, conrad[i]) | |
| yield (p * r, q * r / poq, conrad[i]) | |
| else: | |
| yield (p * r, q * poq * r, -conrad[i]) | |
| yield (q * r / poq, p * r, -conrad[i]) | |
| def arcs(): | |
| """ | |
| Return sequence of vertex, vertex, radius triples. | |
| """ | |
| for arc in circulants(): | |
| yield arc | |
| for arc in connectors(): | |
| yield arc | |
| # ============================================================ | |
| # Draw the layout | |
| # ============================================================ | |
| # Dummy pass through arcs to find max radius | |
| for a in arcs(): | |
| pass | |
| scale = 25 * options.scale | |
| vertexRadius = 5 * options.radius * options.scale | |
| bbox = maxRadius * scale + vertexRadius + 1 | |
| bbox = 1 + int(bbox) | |
| print('''<?xml version="1.0" standalone="no"?> | |
| <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" | |
| "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | |
| <svg width="%s" height="%s" viewBox="-%s -%s %s %s" | |
| xmlns="http://www.w3.org/2000/svg" version="1.1">''' % \ | |
| (2 * bbox + 1, 2 * bbox + 1, 0, 0, 2 * bbox + 1, 2 * bbox + 1)) | |
| def place(p): | |
| """ | |
| Convert unit-free complex to pixel-scaled real coordinates. | |
| """ | |
| return (p.real * scale + bbox + 1, p.imag * scale + bbox + 1) | |
| print(' <g style="fill:none; stroke:black">') | |
| for (p, q) in segments(): | |
| print(' <line x1="%0.2f" y1="%0.2f" x2="%0.2f" y2="%0.2f" />' % \ | |
| (place(p) + place(q))) | |
| for (p, q, r) in arcs(): | |
| la = 0 | |
| if r < 0: # flag for large arc | |
| la = 1 | |
| r = -r | |
| print(' <path d="M %0.2f,%0.2f A %0.2f,%0.2f 1 %s %s %0.2f,%0.2f" />' % \ | |
| (place(p) + (scale * r, scale * r, la, la) + place(q))) | |
| print(' </g>') | |
| print(' <g style="fill:%s; stroke:%s">' % (options.color, (options.outline and "none" or "black"))) | |
| for v in vertices(): | |
| print(' <circle cx="%0.2f" cy="%0.2f" r="%s" />' % \ | |
| (place(v) + (vertexRadius, ))) | |
| print(' </g>') | |
| print('</svg>') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment