Metaphysics is a pair of sculptures I made. This bare bones repository stores metaphysics.py
, the code I wrote to help me create them. Feel free to run it yourself and improve on it if you're inspired to!
Visit my website for lots more information about this project.
The die of dominoes is the focus of the code here, since constructing it properly requires a series of fairly complex calculations.
This code accomplishes two key goals:
- List the sequence of dominoes that cover the die.
- Determine the minimum number of domino sets required to make the die.
Moreover, it does so in essentially a "scale free" way: the same goals can be accomplished for larger (or smaller) versions of the sculptures by simply changing a few parameters.
The latter goal (determining min_num_sets
) necessitates a long sequence of intermediate calcuations. To help you follow the code below, here's a brief summary of the major steps:
- Number the squares from 0 to
num_squares
- 1. - Calculate
domino
andnumber
for a square from the square numbersquare
. - Define the dots with coordinates.
- Define the collections of dots for each side.
- Calculate the white areas from the collections of dots.
- Convert the dots and white areas from local into global coordinates.
- Find the full and half dominoes that constitute the dots and white areas.
- Count how many full and half dominoes constitute them.
- Determine
min_num_sets
from these counts.
The die of dominoes is a cube tiled by a circular domino train in a Hilbert curve pattern. (To be technically precise, it's a polygonal approximation to a Hilbert curve, since the Hilbert curve itself is the infinite limit of such approximations.) The standard Hilbert curve is open and fills the unit square, such that many copies of it fill the real number plane (
There are many ways one could describe such a Hilbert curve, but it's useful to (arbitrarily) pick out "start" and "end" points for clarity.
# 'start' of Hilbert curve Orientation of dots:
# v___________ ___________ ___________ ___________
# | | | | | |
# | | | | | + |
# | Side | Side | | + | |
# | 1i | 2i | | | + |
# |___________|___________|___________ |___________|___________|___________
# | | | | | |
# | | | | + + | + + + |
# | Side | Side | | | |
# | 1ii | 2ii | | + + | + + + |
# |___________|___________|___________ |___________|___________|___________
# | | | | | |
# | | | | + + | + |
# | Side | Side | | + | + |
# | 1iii | 2iii | | + + | + |
# |___________|___________| |___________|___________|
# ^
# 'end' of Hilbert curve
Also shown above is the orientation of dots on the die. Note that this is quite a particular orientation. Not only is the order of numbers specific, but so is the way the dots are laid out on each side. For example, the 2-dot side could have dots on the other diagonal instead, but it doesn't. All of this is based on physical dice I used as references. A fun fact I learned in the process of inspecting and researching them: dice are designed such that two opposite sides' dots always sum to 7!
The full Hilbert curve is composed of (traditional, open) Hilbert curves on each side of the cube. Each of these corresponds to either a "type 1" or "type 2" tiling (hence the 1 and 2 indices in the side names).
For a type 1 tiling, the Hilbert curve starts in the upper left and ends in the upper right corner. For a type 2 tiling, the curve starts in the upper left and ends in the lower left corner.
# start end start
# v___________v v___________
# | | | |
# | | | |
# | Type 1 | x | Type 2 | y
# | | |---- > | | |---- >
# |___________| | |___________| |
# y v ^ x v
# end
Note how this changes the local coordinate axes, as the diagram above indicates.
These are both rotations of the "standard" Hilbert curve, which is the default for the hilbertcurve
package I leverage below. That standard curve starts in the lower left and ends in the lower right corner:
# ___________
# | |
# | |
# | Standard | y ^
# | | |
# |___________| |---- >
# ^ ^ x
# start end
To keep track of things, it's helpful to label the sides and dots. I label the sides, intuitively enough, based on the number of dots they have. For consistency, I list them in the order the appear on the diagram shown above, following standard left to right and top to bottom ordering: Side 1, Side 2, Side 4, etc.
I label each dot with one index corresponding to the side it's on and a second index corresponding to its ordering on the side, left to right and top to bottom from the perspective of the diagram above. And again, I list them in order: Dot 1A, Dot 2A, Dot 2B, Dot 4A, Dot 4B, Dot 4C, Dot 4D, etc. For clarity:
# Labeling of dots:
# ___________ ___________
# | | 2A |
# | 1A | + |
# | + | 2B |
# | | + |
# |___________|___________|___________
# | 4A 4B | 6A 6B 6C |
# | + + | + + + |
# | 4C 4D | |
# | + + | + + + |
# |___________|___________|___________
# | 5A 5B | 3A |
# | + 5C + | 3B + |
# | 5D + 5E | 3C + |
# | + + | + |
# |___________|___________|
It's also helpful to have coordinates for each side ("local" coordinates) and for the whole cube ("global" coordinates). Local coordinates always start at [0,0], but global coordinates start at different values for different sides so they're always unique. (See elsewhere below for more details.)
Similarly, these local and global coordinates correspond to squares the local and global Hilbert curves pass through. Locally (on each side), the squares are indexed starting at 0. Globally, the squares are indexed starting at 0 on Side 1 and with higher indices across the cube.
To run this code yourself, you'll need to install/import the following modules:
import math
from cmath import sqrt
from turtle import st
# for simple data tables
from tabulate import tabulate
# for colors in tables
from colorama import init, Back, Fore
# for Hilbert curve calculations
from hilbertcurve.hilbertcurve import HilbertCurve
# for Hilbert curve diagrams
import matplotlib.pyplot as plt
square
indexes the squares the Hilbert curve runs through, starting at 0.domino
indexes the dominoes tiling the cube, starting at 1. Note that here the tiling is defined to begin with a half domino. It could begin with a full one — both are valid tilings in line with the Hilbert curve, so it's a matter of choice. (I explain in Boundary Conditions why choosing the half domino approach was important for this project.)term
indexes the term in my Wallis-like domino train product. Each such term includes two fractions multiplied together.number
is the number of a particular half domino on a square.
Given
square
, find which domino tiles it.
# The 2 here is not a variable because only real, standard dominoes (which cover two squares) are considered.
def get_domino(square):
return math.trunc(math.floor((square + 1) / 2)) + 1
Given
square
, find which term it corresponds to.
# The 4 here is not a variable because my Wallis-like domino train product always has 2 fractions with 4 numerator/denominator values.
def get_term(square):
return math.trunc(math.floor((square + 1) / 4)) + 1
Given
square
, find which domino number covers it.
# The 7s here are not variables because only real, standard half dominoes (which have 7 possible values, from 0 to 6) are considered. The 4 here is not a variable because my Wallis-like domino train product always has 2 fractions with 4 numerator/denominator values.
def get_number(square):
term = get_term(square)
# numerators and denominators of first and second fractions
num_1 = (2 * term - 1) % 7
den_1 = num_2 = (2 * term + math.trunc(math.floor((term - 1) / 7))) % 7
den_2 = (2 * term + 1) % 7
# a condition to pick out which numerator or denominator to set the number of a square to
# the number is just one of the two sections of a domino
condition = (square + 1) % 4
if condition == 0: return num_1
elif condition == 1: return den_1
elif condition == 2: return num_2
else: return den_2
Important: Note that these are paramaters for the local Hilbert curves on one side of the cube, not the global Hilbert curve covering the whole cube.
This uses the hilbertcurve
package.
-
iterations
is the number of iterations of (the polygonal approximation to) the Hilbert curve. For Metaphysics, this will be 4 for the smallest scale version but greater for the larger scale versions. -
dimensions
is the number of spatial dimensions. For Metaphysics, this will always be 2, since each local Hilbert curve corresponds to a tiling of one side of a cube (which has 2 dimensions). -
num_squares
is the number of squares in a Hilbert curve with so many iterations and of so many dimensions. (Since for Metaphysics I'm always using 2 dimensions, I use the more specific term "squares" rather than the fully general "hypercubes".) In general, a Hilbert curve fills a hypercube with$2^{i \cdot d}$ unit hypercubes contained within in it, where$i$ isiterations
and$d$ isdimensions
. For 4 iterations and 2 dimensions, that's a square with$2^8 = 256$ unit squares contained within it.
For Metaphysics, I'm using 6 connecting Hilbert curves (in 2 different orientations) to cover the surface of a cube.
Note that this package produces a Hilbert curve that begins at the lower left and ends at the lower right corner. As a result, no matter the orientation of the Hilbert curve considered here, I pick coordinates such that [0,0] is at the beginning and [sqrt(num_squares)
,0] is at the end.
iterations = 4
dimensions = 2
hilbert_curve = HilbertCurve(iterations, dimensions)
num_coordinates_per_side = 2 ** iterations
num_squares = num_coordinates_per_side ** dimensions
"Dots" are the dots (sometimes called "pips") on a die.
For Metaphysics, I'm using two Hilbert curve orientations, which means there are two types of tiling. These have different coordinate orientations, as described above.
The dots have two indices. The first (1, 2, 3, ...) indicates the side of the die the dot is on. The second (A, B, C, ...) indicates the order of the dot on its side of the die. They're ordered from left to right and top to bottom from the perspective of the diagram above.
These are currently defined only for a Hilbert curve tiling with 256 squares. Ideally, they'd be defined independently of the number of squares, with variables rather than numbers, but doing this is complicated because the shape of each dot and the spacing between dots and the cube sides should change with the number of squares. So, I'm skipping this for now.
Important: The dots are first defined in "local" coordinates, where each side's coordinates goes from [0,0] to [sqrt(num_squares)
- 1, sqrt(num_squares)
- 1], i.e. [15,15]. (Confusingly enough, these are "global variables" in the programming sense!) The get_global_coordinates()
function further below will later transform these local coordinates into global ones, where each side's coordinates start at a multiple of sqrt(num_squares)
times the index of the side in the ordering shown in the diagram above (starting at 0). In global coordinates, the first side starts at [0,0], the second at [sqrt(num_squares)
,sqrt(num_squares)
] i.e. [16,16], the third at [2 * sqrt(num_squares)
, 2 * sqrt(num_squares)
] i.e. [32,32], and so on.
dot_1A = [[6,7], [6,8], [7,6], [7,7], [7,8], [7,9], [8,6], [8,7], [8,8], [8,9], [9,7], [9,8]]
dot_2A = [[1,12], [1,13], [2,11], [2,12], [2,13], [2,14], [3,11], [3,12], [3,13], [3,14], [4,12], [4,13]]
dot_2B = [[11,2], [11,3], [12,1], [12,2], [12,3], [12,4], [13,1], [13,2], [13,3], [13,4], [14,2], [14,3]]
dot_4A = [[1,2], [1,3], [2,1], [2,2], [2,3], [2,4], [3,1], [3,2], [3,3], [3,4], [4,2], [4,3]]
dot_4B = [[11,2], [11,3], [12,1], [12,2], [12,3], [12,4], [13,1], [13,2], [13,3], [13,4], [14,2], [14,3]]
dot_4C = [[1,12], [1,13], [2,11], [2,12], [2,13], [2,14], [3,11], [3,12], [3,13], [3,14], [4,12], [4,13]]
dot_4D = [[11,12], [11,13], [12,11], [12,12], [12,13], [12,14], [13,11], [13,12], [13,13], [13,14], [14,12], [14,13]]
dot_6A = [[1,2], [1,3], [2,1], [2,2], [2,3], [2,4], [3,1], [3,2], [3,3], [3,4], [4,2], [4,3]]
dot_6B = [[1,7], [1,8], [2,6], [2,7], [2,8], [2,9], [3,6], [3,7], [3,8], [3,9], [4,7], [4,8]]
dot_6C = [[1,12], [1,13], [2,11], [2,12], [2,13], [2,14], [3,11], [3,12], [3,13], [3,14], [4,12], [4,13]]
dot_6D = [[11,2], [11,3], [12,1], [12,2], [12,3], [12,4], [13,1], [13,2], [13,3], [13,4], [14,2], [14,3]]
dot_6E = [[11,7], [11,8], [12,6], [12,7], [12,8], [12,9], [13,6], [13,7], [13,8], [13,9], [14,7], [14,8]]
dot_6F = [[11,12], [11,13], [12,11], [12,12], [12,13], [12,14], [13,11], [13,12], [13,13], [13,14], [14,12], [14,13]]
dot_5A = [[1,2], [1,3], [2,1], [2,2], [2,3], [2,4], [3,1], [3,2], [3,3], [3,4], [4,2], [4,3]]
dot_5B = [[11,2], [11,3], [12,1], [12,2], [12,3], [12,4], [13,1], [13,2], [13,3], [13,4], [14,2], [14,3]]
dot_5C = [[6,7], [6,8], [7,6], [7,7], [7,8], [7,9], [8,6], [8,7], [8,8], [8,9], [9,7], [9,8]]
dot_5D = [[1,12], [1,13], [2,11], [2,12], [2,13], [2,14], [3,11], [3,12], [3,13], [3,14], [4,12], [4,13]]
dot_5E = [[11,12], [11,13], [12,11], [12,12], [12,13], [12,14], [13,11], [13,12], [13,13], [13,14], [14,12], [14,13]]
dot_3A = [[1,12], [1,13], [2,11], [2,12], [2,13], [2,14], [3,11], [3,12], [3,13], [3,14], [4,12], [4,13]]
dot_3B = [[6,7], [6,8], [7,6], [7,7], [7,8], [7,9], [8,6], [8,7], [8,8], [8,9], [9,7], [9,8]]
dot_3C = [[11,2], [11,3], [12,1], [12,2], [12,3], [12,4], [13,1], [13,2], [13,3], [13,4], [14,2], [14,3]]
dots = [dot_1A, dot_2A, dot_2B, dot_4A, dot_4B, dot_4C, dot_4D, dot_6A, dot_6B, dot_6C, dot_6D, dot_6E, dot_6F, dot_5A, dot_5B, dot_5C, dot_5D, dot_5E, dot_3A, dot_3B, dot_3C]
dot_names = ['Dot 1A', 'Dot 2A', 'Dot 2B', 'Dot 4A', 'Dot 4B', 'Dot 4C', 'Dot 4D', 'Dot 6A', 'Dot 6B', 'Dot 6C', 'Dot 6D', 'Dot 6E', 'Dot 6F', 'Dot 5A', 'Dot 5B', 'Dot 5C', 'Dot 5D', 'Dot 5E', 'Dot 3A', 'Dot 3B', 'Dot 3C']
"Sides" are the sides of a dice. The variables below list the dots on each side. This is somewhat redundant, since the dot names encode the side they're on (with their first index), but it's useful to have this information consolidated.
Important: generic_side
here is a generic side in local coordinates. This makes it possible to calculate white areas in local coordinates using dots in local coordinates together with this generic side.
side_1_dots = [dot_1A]
side_2_dots = [dot_2A, dot_2B]
side_4_dots = [dot_4A, dot_4B, dot_4C, dot_4D]
side_6_dots = [dot_6A, dot_6B, dot_6C, dot_6D, dot_6E, dot_6F]
side_5_dots = [dot_5A, dot_5B, dot_5C, dot_5D, dot_5E]
side_3_dots = [dot_3A, dot_3B, dot_3C]
sides_dots = [side_1_dots, side_2_dots, side_4_dots, side_6_dots, side_5_dots, side_3_dots]
num_sides_dots = len(sides_dots)
side_names = ['Side 1', 'Side 2', 'Side 4', 'Side 6', 'Side 5', 'Side 3']
# This is defined so that it can be passed along with dots (defined above), since the order and length of the two lists match.
dots_side_list = [side_1_dots, side_2_dots, side_2_dots, side_4_dots, side_4_dots, side_4_dots, side_4_dots, side_6_dots, side_6_dots, side_6_dots, side_6_dots, side_6_dots, side_6_dots, side_5_dots, side_5_dots, side_5_dots, side_5_dots, side_5_dots, side_3_dots, side_3_dots, side_3_dots]
Calculate a generic side, i.e. one in local coordinates. This includes [0,0], [0,1], [0,2], ..., [1,0], [1,1], [1,2], ..., [sqrt(num_squares),sqrt(num_squares)].
def get_generic_side():
generic_side = []
for i in range(0, int(math.sqrt(num_squares))):
for j in range(0, int(math.sqrt(num_squares))):
generic_side.append([i,j])
return generic_side
generic_side = get_generic_side()
A "white area" is the part of a side that isn't the dots.
Important: The white areas are first defined in local coordinates. The get_global_coordinates()
function below will later transform them into global coordinates.
Given a particular side (which is a list of dots), find the list of coordinates for its white area. This is done by removing the coordinates for the dots on the given side.
def get_white_area(side_dots):
# I find this quite nonintuitive, but white_area = generic_side doesn't work here because that syntax just creates a reference to the original list rather than creating a copy of that list. So, it's necessary to explictly copy the list so we can make changes to the new list values without changing the corresponding original list values. There are many ways to do this: see https://stackoverflow.com/questions/2612802/list-changes-unexpectedly-after-assignment-why-is-this-and-how-can-i-prevent-it.
# For some reason that I have been unable to figure out, `white_area = generic_side[:]`, `white_area = generic_side.copy()`, and the like do NOT work. But `white_area = get_generic_side()` does!
# white_area = generic_side[:]
white_area = get_generic_side()
# remove the coordinates corresponding to dots
for k in range(0, len(side_dots)):
for l in range(0, len(side_dots[k])):
# side[k] is a dot, and side[k][l] is a coordinate in that dot
# list order matters, so this will remove e,g, [2,5] but not [5,2]
white_area.remove(side_dots[k][l])
return white_area
white_area_1 = get_white_area(side_1_dots)
white_area_2 = get_white_area(side_2_dots)
white_area_4 = get_white_area(side_4_dots)
white_area_6 = get_white_area(side_6_dots)
white_area_5 = get_white_area(side_5_dots)
white_area_3 = get_white_area(side_3_dots)
white_areas = [white_area_1, white_area_2, white_area_4, white_area_6, white_area_5, white_area_3]
white_area_names = ['White Area for Side 1', 'White Area for Side 2', 'White Area for Side 4', 'White Area for Side 6', 'White Area for Side 5', 'White Area for Side 3']
# This is defined so that it can be passed along with white areas, since the order and length of the two lists match.
white_areas_side_list = [side_1_dots, side_2_dots, side_4_dots, side_6_dots, side_5_dots, side_3_dots]
Create a Hilbert curve diagram.
This adapts code from the GitHub repo of the
hilbertcurve
package. The side index is that of the ordering of sides defined above. This function creates a diagram for one side at a time.Note that, currently, this does not adjust the orientation of the Hilbert curve to be type 1 or 2 for a given side (as defined above). All Hilbert curves it produces are in "standard" orientation.
def create_hilbert_curve_diagram(side_index):
# this has to be at the beginning, not with the other 'plt' statements below
plt.figure(figsize = (10,10))
min_coordinate = 0
max_coordinate = num_coordinates_per_side - 1
cmin = min_coordinate - 0.5
cmax = max_coordinate + 0.5
colors = ['red', 'blue', 'black', 'green', 'purple', 'cyan', 'gray']
line_widths = [32, 16, 8, 4, 2, 1, 0.5]
offset = 0
dx = 0.5
for i in range(iterations, iterations - 1, -1):
curve = HilbertCurve(i, dimensions)
num_coordinates_per_side_i = 2 ** i
num_points = 2 ** (i * dimensions)
points = []
for j in range(num_points):
points.append(curve.point_from_distance(j))
points = [
[(point[0] * num_coordinates_per_side / num_coordinates_per_side_i) + offset,
(point[1] * num_coordinates_per_side / num_coordinates_per_side_i) + offset]
for point in points]
connectors = range(3, num_points, 4)
color = colors[i - 1]
# '+ len(line_widths) - iterations' so it starts at a smaller line width (later in the list) when iterations is smaller than the number of line width values
# Note that to increase iterations beyond this number, more line width values (and colors) should be added
line_width = line_widths[i - 1 + len(line_widths) - iterations]
for k in range(num_points - 1):
if k in connectors:
line_style = '--'
alpha = 0.5
else:
line_style = '-'
alpha = 1.0
plt.plot((points[k][0], points[k + 1][0]), (points[k][1], points[k + 1][1]),
color = color, linewidth = line_width, linestyle = line_style, alpha = alpha)
for l in range(num_points):
plt.scatter(points[l][0], points[l][1], 60, color = color)
plt.text(points[l][0] + 0.1, points[l][1] + 0.1, str(l + side_index * num_points), color = color)
offset += dx
dx *= 2
plt.title('Hilbert Curve Pattern for ' + str(side_names[side_index]))
plt.grid(alpha = 0.3)
plt.xlim(cmin, cmax)
plt.ylim(cmin, cmax)
plt.xlabel('x', fontsize = 16)
plt.ylabel('y', fontsize = 16)
plt.tight_layout()
plt.savefig(str(side_names[side_index]) + ' - ' + str(iterations) + ' iterations, ' + str(dimensions) + ' dimensions.png')
create_hilbert_curve_diagram(0)
Example output:
# Colors for tables
backs = [Back.LIGHTBLUE_EX, Back.WHITE, Back.GREEN, Back.YELLOW, Back.LIGHTMAGENTA_EX, Back.CYAN, Back.LIGHTRED_EX]
num_colors = len(backs)
Print a table of values for square, domino, term, and number.
def print_values():
column_headers = ['Square', 'Domino', 'Term', 'Number']
# array of rows
data = []
# num_squares is per side, but we want to tile all sides of the cube
for square in range(0, num_squares * num_sides_dots):
domino = get_domino(square)
term = get_term(square)
number = get_number(square)
# setting different colors for different numbers (mainly because it's very easy to confuse 0 and 6 when reading the table)
# see https://compucademy.net/python-tables-for-multiplication-and-addition/
color = backs[number % num_colors]
data.append([square, domino, term, f'{color}{number}{Back.RESET}'])
# add dash to simulate 'fraction' line on domino
if square % 2 == 1: data.append(['', '', '', '-'])
# add spacing to make it easier to see pairs of domino numbers
else: data.append(['........', '........', '......', '........'])
print(tabulate(data, column_headers, tablefmt = "pretty"))
print_values()
Example output (fragment):
+----------+----------+--------+----------+
| Square | Domino | Term | Number |
+----------+----------+--------+----------+
| 0 | 1 | 1 | 2 |
| ........ | ........ | ...... | ........ |
| 1 | 2 | 1 | 2 |
| | | | - |
| 2 | 2 | 1 | 3 |
| ........ | ........ | ...... | ........ |
| 3 | 3 | 2 | 3 |
| | | | - |
| 4 | 3 | 2 | 4 |
| ........ | ........ | ...... | ........ |
| 5 | 4 | 2 | 4 |
| | | | - |
| 6 | 4 | 2 | 5 |
| ........ | ........ | ...... | ........ |
| 7 | 5 | 3 | 5 |
| | | | - |
| 8 | 5 | 3 | 6 |
| ........ | ........ | ...... | ........ |
| 9 | 6 | 3 | 6 |
| | | | - |
| 10 | 6 | 3 | 0 |
Print counts of how many times each number (0 through 6) appears.
def print_number_counts():
column_headers = ['0', '1', '2', '3', '4', '5', '6']
# array of rows
data = [[0, 0, 0, 0, 0, 0, 0]]
for square in range(0, num_squares * num_sides_dots):
number = get_number(square)
data[0][number] += 1
print("Number counts:")
print(tabulate(data, column_headers))
print_number_counts()
Example output:
Number counts:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
220 218 220 220 220 218 220
In local coordinates, each side's coordinates goes from [0,0] to [sqrt(num_squares) - 1
, sqrt(num_squares) - 1
].
In global coordinates, each side's coordinates start at a multiple of sqrt(num_squares)
times the index of the side in the ordering shown in the diagram above (starting at 0). So, the first side starts at [0,0], the second at [sqrt(num_squares)
,sqrt(num_squares)
], the third at [2 * sqrt(num_squares)
, 2 * sqrt(num_squares)
], and so on.
Note that a single coordinate is a list of numbers (with a number of elements equal to dimensions
, which for Metaphysics is always 2), e.g. [2,5]. Coordinates (plural) are lists of such lists, e.g. [[2,5], [6,3], [7,7]].
Given local coordinates and a side, get the corresponding global coordinates.
This function requires a list of lists input, even for a single coordinate.
def get_global_coordinates(local_coordinates, side_dots):
global_coordinates = []
for i in range(0, len(local_coordinates)):
global_coordinate = []
for j in range(0, len(local_coordinates[i])):
global_coordinate.append(local_coordinates[i][j] + sides_dots.index(side_dots) * int(math.sqrt(num_squares)))
if (j == len(local_coordinates[i]) - 1):
global_coordinates.append(global_coordinate)
return global_coordinates
Given local coordinates and a side, set the corresponding global coordinates.
This function requires a list of lists input, even for a single coordinate.
def set_global_coordinates(local_coordinates, side_dots):
for i in range(0, len(local_coordinates)):
for j in range(0, len(local_coordinates[i])):
local_coordinates[i][j] += sides_dots.index(side_dots) * int(math.sqrt(num_squares))
Given local coordinates and a side, set the corresponding global coordinates in a batch.
This function requires a list of a list of lists input.
Note that the order and length of the two lists (
local_coordinates_list
andside_list
) must match so that eachlocal_coordinates
matches the appropriate side.
def set_global_coordinates_batch(local_coordinates_list, side_list):
for i in range(0, len(local_coordinates_list)): set_global_coordinates(local_coordinates_list[i], side_list[i])
# Transform dots and white areas from local into global coordinates.
set_global_coordinates_batch(dots, dots_side_list)
set_global_coordinates_batch(white_areas, white_areas_side_list)
Given (a list of) global coordinates (e.g. a dot), find the squares (ordered along the Hilbert curve) that the list includes.
Note that either local or global coordinates can be inputted, but the output will always be global square numbers.
The input list of coordinates is in the number of the dimensions of the Hilbert curve (always 2 for Metaphysics).
The output is an (ordered) list of coordinates in 1 dimension, since the Hilbert curve itself is 1-dimensional (at least "stretched out", since the "curled up" curve has fractal Hausdorff dimension 2).
def get_squares(coordinates):
# Calculate the side index as a kind of offset: how many times the coordinate values can be divided by sqrt(num_squares). (We can used any coordinate value to find this — coordinates[0][0] is just an arbitrary choice.) For example, if the coordinate value is 18 and sqrt(num_squares) is 16, the offset is 1 because 18 can be divided by 16 once. This ia also the side index of that coordinate: it's on the second side.
# This index could instead be passed into the function, but it's helpful to calcuate it here so that's not necessary.
# Note that this should always be an integer: math.trunc() and math.floor() are just safeguards.
side_index = int((coordinates[0][0] - (coordinates[0][0] % int(math.sqrt(num_squares)))) / int(math.sqrt(num_squares)))
local_coordinates = []
for coordinate in coordinates:
local_coordinate = []
for i in range(0, len(coordinate)):
# Mod by sqrt(num_squares) to make the coordinate local, so that distances_from_points from the hilbertcurve package can be used to calculate local square numbers.
local_coordinate.append(coordinate[i] % int(math.sqrt(num_squares)))
local_coordinates.append(local_coordinate)
points = local_coordinates
distances = hilbert_curve.distances_from_points(points)
# Finally, calculate global square values simply by adding num_squares (per side), scaled by the side index
global_squares = []
for distance in distances: global_squares.append(distance + (side_index * num_squares))
return global_squares
Print squares for a given list of coordinates.
The relevant group of coordinates and their names must be passed also.
def print_squares(coordinates, coordinates_group, coordinate_names):
print('Squares for ' + coordinate_names[coordinates_group.index(coordinates)] + ':')
print(get_squares(coordinates))
print_squares(dot_1A, dots, dot_names)
Example output:
Squares for Dot 1A:
[43, 124, 41, 42, 127, 126, 214, 213, 128, 129, 212, 131]
Given the domino number of a square, find the other square with that domino number.
There's only one, and it's either the previous or next one.
def get_other_domino_square(square):
previous_square = square - 1
next_square = square + 1
if get_domino(square) == get_domino(previous_square): return previous_square
else: return next_square
Given (a list of) coordinates (e.g. a dot), find the full and half dominoes that compose it.
def get_dominoes(coordinates):
full_dominoes = []
half_dominoes = []
coordinates_squares = get_squares(coordinates)
# the squares already 'used', or included in a full or half domino already added
used_squares = []
for square in coordinates_squares:
other_domino_square = get_other_domino_square(square)
# if the other domino square isn't used
if (other_domino_square not in used_squares):
# if it's in coordinates, add to full dominoes
if (other_domino_square in coordinates_squares):
domino = [get_number(square), get_number(other_domino_square)]
# sort to avoid counting e.g. [2,5] and [5,2] separately — they should be treated as the same
domino.sort()
full_dominoes.append(domino)
# add squares to used list
# not strictly necessary to add 'square', since we're iterating over it (i.e. the for loop takes care of not considering it multiple times), but it's more intuitive to also consider it 'used'
used_squares.extend([square, other_domino_square])
# else, add to half dominoes
else:
half_domino = get_number(square)
half_dominoes.append(half_domino)
used_squares.append(square)
return full_dominoes, half_dominoes
Given (lists of) full and half dominoes (e.g. for a single dot), count how many there are of each type.
Order doesn't matter for full dominoes, e.g. [2,5] and [5,2] are considered the same. This will be used in table data, so notice that there are row headers included (which aren't themselves counts, of course).
The table data for half dominoes has only one row, so there's no need for a row header there.
def get_dominoes_counts(full_dominoes, half_dominoes):
# the first values are row headers
full_dominoes_counts = [[0], [1], [2], [3], [4], [5], [6]]
# no need for row headers — there's only one row
half_dominoes_counts = [[]]
for i in range(0, 7):
for j in range (0, i + 1):
# They're already sorted in get_dominoes(), so no need to count both [i,j] and [j,i].
# If it seems odd that it's [j,i] below, that's only because j is never greater than i given this iteration strategy, so it should come first because sort(), used in get_dominoes(), puts smaller numbers first (i.e. ascending order).
full_dominoes_count = full_dominoes.count([j,i])
# Using append() here takes care of the ordering, so no need to use the j index.
full_dominoes_counts[i].append(full_dominoes_count)
half_dominoes_count = half_dominoes.count(i)
half_dominoes_counts[0].append(half_dominoes_count)
return full_dominoes_counts, half_dominoes_counts
Given (a list of a list of) coordinates (e.g. a list of dots), find the sum of counts for full and half dominoes.
def get_sum_dominoes_counts(coordinates):
# initilialize with zero values so they can later be overwritten (to avoid 'index out of range' error)
sum_full_dominoes_counts = [
[0, 0],
[1, 0, 0],
[2, 0, 0, 0],
[3, 0, 0, 0, 0],
[4, 0, 0, 0, 0, 0],
[5, 0, 0, 0, 0, 0, 0],
[6, 0, 0, 0, 0, 0, 0, 0]]
sum_half_dominoes_counts = [[0, 0, 0, 0, 0, 0, 0]]
for i in range(0, len(coordinates)):
full_dominoes, half_dominoes = get_dominoes(coordinates[i])
full_dominoes_counts, half_dominoes_counts = get_dominoes_counts(full_dominoes, half_dominoes)
# add up full domino counts
for j in range(0, len(full_dominoes_counts)):
# start at 1 since the first items are just row headers
for k in range(1, len(full_dominoes_counts[j])):
# adjust by frequency
sum_full_dominoes_counts[j][k] += (full_dominoes_counts[j][k])
# add up half domino counts
for l in range(0, len(half_dominoes_counts[0])):
# adjust by frequency
sum_half_dominoes_counts[0][l] += (half_dominoes_counts[0][l])
return sum_full_dominoes_counts, sum_half_dominoes_counts
Given (a list of a list of) coordinates (e.g. a list of dots), print tables of full and half domino counts.
The "names" input is a list of names for each list of coordinates.
def print_dominoes_counts(coordinates, names):
full_dominoes_headers = ['#', 0, 1, 2, 3, 4, 5, 6]
half_dominoes_headers = [0, 1, 2, 3, 4, 5, 6]
for i in range(0, len(coordinates)):
full_dominoes, half_dominoes = get_dominoes(coordinates[i])
full_dominoes_counts, half_dominoes_counts = get_dominoes_counts(full_dominoes, half_dominoes)
print('Full Dominoes for ' + names[i] + ':')
print(tabulate(full_dominoes_counts, full_dominoes_headers))
print('Half Dominoes for ' + names[i] + ':')
print(tabulate(half_dominoes_counts, half_dominoes_headers))
sum_full_dominoes_counts, sum_half_dominoes_counts = get_sum_dominoes_counts(coordinates)
print('Full Dominoes for All:')
print(tabulate(sum_full_dominoes_counts, full_dominoes_headers))
print('Half Dominoes for All:')
print(tabulate(sum_half_dominoes_counts, half_dominoes_headers))
print_dominoes_counts(dots, dot_names)
print_dominoes_counts(white_areas, white_area_names)
Example output (fragment):
Full Dominoes for Dot 1A:
# 0 1 2 3 4 5 6
--- --- --- --- --- --- --- ---
0 0
1 0 0
2 1 0 1
3 0 0 0 0
4 0 0 0 1 0
5 0 0 0 0 0 0
6 0 0 0 0 0 0 0
Half Dominoes for Dot 1A:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
1 0 2 1 1 1 0
Full Dominoes for Dot 2A:
# 0 1 2 3 4 5 6
--- --- --- --- --- --- --- ---
0 0
1 0 0
2 0 1 0
3 1 0 0 0
4 0 0 0 0 0
5 0 0 0 0 0 0
6 1 0 1 0 0 0 0
Half Dominoes for Dot 2A:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
...
Full Dominoes for All:
# 0 1 2 3 4 5 6
--- --- --- --- --- --- --- ---
0 1
1 4 3
2 3 3 1
3 3 4 3 3
4 3 3 5 3 1
5 2 1 5 5 3 2
6 4 3 3 1 3 3 3
Half Dominoes for All:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
13 14 11 12 13 17 10
Full Dominoes for White Area for Side 1:
# 0 1 2 3 4 5 6
--- --- --- --- --- --- --- ---
0 3
1 6 3
2 4 5 2
3 4 5 5 3
4 3 5 3 6 3
5 4 4 3 5 6 3
6 6 5 4 4 5 6 3
Half Dominoes for White Area for Side 1:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
1 0 3 1 2 1 0
Full Dominoes for White Area for Side 2:
# 0 1 2 3 4 5 6
--- --- --- --- --- --- --- ---
0 2
1 4 2
2 5 4 2
3 4 5 5 2
4 6 5 5 3 2
5 4 7 6 5 4 2
6 4 4 4 4 4 5 2
Half Dominoes for White Area for Side 2:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
0 1 1 3 1 3 1
...
Full Dominoes for All:
# 0 1 2 3 4 5 6
--- --- --- --- --- --- --- ---
0 10
1 25 10
2 27 25 14
3 27 25 25 10
4 24 25 19 25 12
5 23 25 22 20 24 11
6 25 23 25 27 27 26 10
Half Dominoes for All:
0 1 2 3 4 5 6
--- --- --- --- --- --- ---
15 12 14 14 17 16 14
Given counts of full and half dominoes, find the minimum number of domino sets required.
A standard domino set (with column and row headers) is:
# # 0 1 2 3 4 5 6
# --- --- --- --- --- --- --- ---
# 0 1
# 1 1 1
# 2 1 1 1
# 3 1 1 1 1
# 4 1 1 1 1 1
# 5 1 1 1 1 1 1
# 6 1 1 1 1 1 1 1
That is, it has one domino of each type. As a result, there are 8 half dominoes of each number (0 through 6).
def get_min_num_sets(full_dominoes_counts, half_dominoes_counts):
# The minimum number of sets must be at least as great as the highest full dominoes count. (That's because there's no other way to get a particular full domino than through a new set, since each set has only one of a given type.)
max_full_dominoes_count = 0
for i in range(0, len(full_dominoes_counts)):
# start at 1 since the first items are just row headers
for j in range(1, len(full_dominoes_counts[i])):
if full_dominoes_counts[i][j] > max_full_dominoes_count: max_full_dominoes_count = full_dominoes_counts[i][j]
min_num_sets = max_full_dominoes_count
# how many half dominoes are left over for use
# initilialize with zero values so they can later be overwritten (to avoid 'index out of range' error)
leftover_half_dominoes_counts = [[0, 0, 0, 0, 0, 0, 0]]
# Loop through again and set each leftover full dominoes count to be the difference between the (provisional) minimum number of sets and the value of the corresponding full dominoes count.
for k in range(0, len(full_dominoes_counts)):
# start at 1 since the first items are just row headers
for l in range(1, len(full_dominoes_counts[k])):
# k and l - 1 (minus 1 because there aren't row headers for the leftover half dominoes counts list) are the domino numbers, so add to those leftover half dominoes counts
leftover_half_dominoes_counts[0][k] += max_full_dominoes_count - full_dominoes_counts[k][l]
leftover_half_dominoes_counts[0][l - 1] += max_full_dominoes_count - full_dominoes_counts[k][l]
# Check if there are enough leftover half dominoes.
for m in range(0, len(half_dominoes_counts[0])):
while half_dominoes_counts[0][m] > leftover_half_dominoes_counts[0][m]:
# If there aren't enough leftover half dominoes with a particular number, we don't have enough sets. So, increment the minimum number of sets by 1 and the leftover half dominoes counts by 8 (since each set has 8 half dominoes of a particular number)
min_num_sets += 1
for n in range(0, len(leftover_half_dominoes_counts[0])): leftover_half_dominoes_counts[0][n] += 8
# Once we have enough leftover half dominoes for each number, we have the minimum number of sets.
return min_num_sets
Given (a list of a list of) coordinates (e.g. a list of dots), print the minimum number of sets to cover them.
def print_min_num_sets(coordinates):
sum_full_dominoes_counts, sum_half_dominoes_counts = get_sum_dominoes_counts(coordinates)
min_num_sets = get_min_num_sets(sum_full_dominoes_counts, sum_half_dominoes_counts)
print('Minimum Number of Domino Sets to Cover All:')
print(min_num_sets)
print_min_num_sets(dots)
print_min_num_sets(white_areas)
Example output:
Minimum Number of Domino Sets to Cover All:
5
Minimum Number of Domino Sets to Cover All:
27
Whew! After all that, we now know that it should take 5 black and 27 white domino sets to make the die of dominoes (at the scale of 16 by 16 squares, using Hilbert curves of 4 iterations). And that's exactly how many sets it took.