Skip to content

Commit 50b6682

Browse files
committed
Updated readme and added source
1 parent 43020d8 commit 50b6682

File tree

2 files changed

+306
-2
lines changed

2 files changed

+306
-2
lines changed

README.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,40 @@
1-
# opencubes
2-
A community improved version of the polycubes project!
1+
# Polycubes
2+
This code is associated with the Computerphile video on generating polycubes. The original repository may be found [here](https://github.com/mikepound/cubes). That version is unchanged from my original video, so that those watching for the first time can find and use the original code, and make improvements to it themselves. This repository is for those looking to contribute to a faster and better optimised version, driven by improvements from Computerphile viewers!
3+
4+
## Introduction
5+
A polycube is a set of cubes in any configuration in which all cubes are orthogonally connected - share a face. This code calculates all the variations of 3D polycubes for any size (time permitting!).
6+
7+
![5cubes](https://github.com/mikepound/cubes/assets/9349459/4fe60d01-c197-4cb3-b298-1dbae8517a74)
8+
9+
10+
## How the code works
11+
The code includes some doc strings to help you understand what it does, but in short it operates a bit like this (oversimplified!):
12+
13+
To generate all combinations of n cubes, we first calculate all possible n-1 shapes based on the same algorithm. We begin by taking all of the n-1 shape, and for each of these add new cubes in any possible free locations. For each of these potential new shapes, we test each rotation of this shape to see if it's been seen before. Entirely new shapes are added to a set of all shapes, to check future candidates.
14+
15+
In order to check slightly faster than simply comparing arrays, each shape is converted into a shortened run length encoding form, which allows hashes to be computed, so we can make use of the set datastructure.
16+
17+
## Running the code
18+
With python installed, you can run the code like this:
19+
20+
`python cubes.py --cache n`
21+
22+
Where n is the number of cubes you'd like to calculate. If you specify `--cache` then the program will attempt to load .npy files that hold all the pre-computed cubes for n-1 and then n. If you specify `--no-cache` then everything is calcuated from scratch, and no cache files are stored.
23+
24+
## Pre-computed cache files
25+
You can download the cache files for n=3 to n=11 from [here](https://drive.google.com/drive/folders/1Ls3gJCrNQ17yg1IhrIav70zLHl858Fl4?usp=drive_link). If you manage to calculate any more sets, please feel free to save them as an npy file and I'll upload them!
26+
27+
## Improving the code
28+
This was just a bit of fun, and as soon as it broadly worked, I stopped! This code could be made a lot better, and actually the whole point of the video was to get people thinking and have a mess around yourselves. Some things you might think about:
29+
- Another language like c or java would be substantially faster
30+
- Other languages would also have better support for multi-threading, which would be a transformative speedup
31+
- Calculating 24 rotations of a cube is slow, the only way to avoid this would be to come up with some rotationally invariant way of comparing cubes. I've not thought of one yet!
32+
33+
## Contributing!
34+
This version welcomes contributors!
35+
36+
## References
37+
- [Wikipedia article](https://en.wikipedia.org/wiki/Polycube)
38+
- [This repository](https://github.com/noelle-crawfish/Enumerating-Polycubes) was a source of inspiration, and a great description of some possible ways to solve this.
39+
- [There may be better ways](https://www.sciencedirect.com/science/article/pii/S0012365X0900082X) to count these, but I've not explored in much detail.
40+
- [Kevin Gong's](http://kevingong.com/Polyominoes/Enumeration.html) webpage on enumerating all shapes up to n=16.

cubes.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import os
2+
import sys
3+
import math
4+
import numpy as np
5+
import argparse
6+
from time import perf_counter
7+
8+
def all_rotations(polycube):
9+
"""
10+
Calculates all rotations of a polycube.
11+
12+
Adapted from https://stackoverflow.com/questions/33190042/how-to-calculate-all-24-rotations-of-3d-array.
13+
This function computes all 24 rotations around each of the axis x,y,z. It uses numpy operations to do this, to avoid unecessary copies.
14+
The function returns a generator, to avoid computing all rotations if they are not needed.
15+
16+
Parameters:
17+
polycube (np.array): 3D Numpy byte array where 1 values indicate polycube positions
18+
19+
Returns:
20+
generator(np.array): Yields new rotations of this cube about all axes
21+
22+
"""
23+
def single_axis_rotation(polycube, axes):
24+
"""Yield four rotations of the given 3d array in the plane spanned by the given axes.
25+
For example, a rotation in axes (0,1) is a rotation around axis 2"""
26+
for i in range(4):
27+
yield np.rot90(polycube, i, axes)
28+
29+
# 4 rotations about axis 0
30+
yield from single_axis_rotation(polycube, (1,2))
31+
32+
# rotate 180 about axis 1, 4 rotations about axis 0
33+
yield from single_axis_rotation(np.rot90(polycube, 2, axes=(0,2)), (1,2))
34+
35+
# rotate 90 or 270 about axis 1, 8 rotations about axis 2
36+
yield from single_axis_rotation(np.rot90(polycube, axes=(0,2)), (0,1))
37+
yield from single_axis_rotation(np.rot90(polycube, -1, axes=(0,2)), (0,1))
38+
39+
# rotate about axis 2, 8 rotations about axis 1
40+
yield from single_axis_rotation(np.rot90(polycube, axes=(0,1)), (0,2))
41+
yield from single_axis_rotation(np.rot90(polycube, -1, axes=(0,1)), (0,2))
42+
43+
def crop_cube(cube):
44+
"""
45+
Crops an np.array to have no all-zero padding around the edge.
46+
47+
Given in https://stackoverflow.com/questions/39465812/how-to-crop-zero-edges-of-a-numpy-array
48+
49+
Parameters:
50+
cube (np.array): 3D Numpy byte array where 1 values indicate polycube positions
51+
52+
Returns:
53+
np.array: Cropped 3D Numpy byte array equivalent to cube, but with no zero padding
54+
55+
"""
56+
for i in range(cube.ndim):
57+
cube = np.swapaxes(cube, 0, i) # send i-th axis to front
58+
while np.all( cube[0]==0 ):
59+
cube = cube[1:]
60+
while np.all( cube[-1]==0 ):
61+
cube = cube[:-1]
62+
cube = np.swapaxes(cube, 0, i) # send i-th axis to its original position
63+
return cube
64+
65+
def expand_cube(cube):
66+
"""
67+
Expands a polycube by adding single blocks at all valid locations.
68+
69+
Calculates all valid new positions of a polycube by shifting the existing cube +1 and -1 in each dimension.
70+
New valid cubes are returned via a generator function, in case they are not all needed.
71+
72+
Parameters:
73+
cube (np.array): 3D Numpy byte array where 1 values indicate polycube positions
74+
75+
Returns:
76+
generator(np.array): Yields new polycubes that are extensions of cube
77+
78+
"""
79+
cube = np.pad(cube, 1, 'constant', constant_values=0)
80+
output_cube = np.array(cube)
81+
82+
xs,ys,zs = cube.nonzero()
83+
output_cube[xs+1,ys,zs] = 1
84+
output_cube[xs-1,ys,zs] = 1
85+
output_cube[xs,ys+1,zs] = 1
86+
output_cube[xs,ys-1,zs] = 1
87+
output_cube[xs,ys,zs+1] = 1
88+
output_cube[xs,ys,zs-1] = 1
89+
90+
exp = (output_cube ^ cube).nonzero()
91+
92+
for (x,y,z) in zip(exp[0], exp[1], exp[2]):
93+
new_cube = np.array(cube)
94+
new_cube[x,y,z] = 1
95+
yield crop_cube(new_cube)
96+
97+
def generate_polycubes(n, use_cache=False):
98+
"""
99+
Generates all polycubes of size n
100+
101+
Generates a list of all possible configurations of n cubes, where all cubes are connected via at least one face.
102+
Builds each new polycube from the previous set of polycubes n-1.
103+
Uses an optional cache to save and load polycubes of size n-1 for efficiency.
104+
105+
Parameters:
106+
n (int): The size of the polycubes to generate, e.g. all combinations of n=4 cubes.
107+
108+
Returns:
109+
list(np.array): Returns a list of all polycubes of size n as numpy byte arrays
110+
111+
"""
112+
if n < 1:
113+
return []
114+
elif n == 1:
115+
return [np.ones((1,1,1), dtype=np.byte)]
116+
elif n == 2:
117+
return [np.ones((2,1,1), dtype=np.byte)]
118+
119+
# Check cache
120+
cache_path = f"cubes_{n}.npy"
121+
if use_cache and os.path.exists(cache_path):
122+
print(f"\rLoading polycubes n={n} from cache: ", end = "")
123+
polycubes = np.load(cache_path, allow_pickle=True)
124+
print(f"{len(polycubes)} shapes")
125+
return polycubes
126+
127+
# Empty list of new n-polycubes
128+
polycubes = []
129+
polycubes_rle = set()
130+
131+
base_cubes = generate_polycubes(n-1, use_cache)
132+
133+
for idx, base_cube in enumerate(base_cubes):
134+
# Iterate over possible expansion positions
135+
for new_cube in expand_cube(base_cube):
136+
if not cube_exists_rle(new_cube, polycubes_rle):
137+
polycubes.append(new_cube)
138+
polycubes_rle.add(rle(new_cube))
139+
140+
if (idx % 100 == 0):
141+
perc = round((idx / len(base_cubes)) * 100,2)
142+
print(f"\rGenerating polycubes n={n}: {perc}%", end="")
143+
144+
print(f"\rGenerating polycubes n={n}: 100% ")
145+
146+
if use_cache:
147+
cache_path = f"cubes_{n}.npy"
148+
np.save(cache_path, np.array(polycubes, dtype=object), allow_pickle=True)
149+
150+
return polycubes
151+
152+
def rle(polycube):
153+
"""
154+
Computes a simple run-length encoding of a given polycube. This function allows cubes to be more quickly compared via hashing.
155+
156+
Converts a {0,1} nd array into a tuple that encodes the same shape. The array is first flattened, and then the following algorithm is applied:
157+
158+
1) The first three values in tuple contain the x,y,z dimension sizes of the array
159+
2) Each string of zeros of length n is replaced with a single value -n
160+
3) Each string of ones of length m is replaced with a single value +m
161+
162+
Parameters:
163+
polycube (np.array): 3D Numpy byte array where 1 values indicate polycube positions
164+
165+
Returns:
166+
tuple(int): Run length encoded polycube in the form (X, Y, Z, a, b, c, ...)
167+
168+
"""
169+
r = []
170+
r.extend(polycube.shape)
171+
current = None
172+
val = 0
173+
for x in polycube.flat:
174+
if current is None:
175+
current = x
176+
val = 1
177+
pass
178+
elif current == x:
179+
val += 1
180+
elif current != x:
181+
r.append(val if current == 1 else -val)
182+
current = x
183+
val = 1
184+
185+
r.append(val if current == 1 else -val)
186+
187+
return tuple(r)
188+
189+
def cube_exists_rle(polycube, polycubes_rle):
190+
"""
191+
Determines if a polycube has already been seen.
192+
193+
Considers all possible rotations of a cube against the existing cubes stored in memory.
194+
Returns True if the cube exists, or False if it is new.
195+
196+
Parameters:
197+
polycube (np.array): 3D Numpy byte array where 1 values indicate polycube positions
198+
199+
Returns:
200+
boolean: True if polycube is already present in the set of all cubes so far.
201+
202+
"""
203+
for cube_rotation in all_rotations(polycube):
204+
if rle(cube_rotation) in polycubes_rle:
205+
return True
206+
207+
return False
208+
209+
if __name__ == "__main__":
210+
parser = argparse.ArgumentParser(
211+
prog='Polycube Generator',
212+
description='Generates all polycubes (combinations of cubes) of size n.')
213+
214+
parser.add_argument('n', metavar='N', type=int,
215+
help='The number of cubes within each polycube')
216+
217+
#Requires python >=3.9
218+
parser.add_argument('--cache', action=argparse.BooleanOptionalAction)
219+
220+
args = parser.parse_args()
221+
222+
n = args.n
223+
use_cache = args.cache if args.cache is not None else True
224+
225+
# Start the timer
226+
t1_start = perf_counter()
227+
228+
all_cubes = list(generate_polycubes(n, use_cache=use_cache))
229+
230+
# Stop the timer
231+
t1_stop = perf_counter()
232+
233+
print (f"Found {len(all_cubes)} unique polycubes")
234+
print (f"Elapsed time: {round(t1_stop - t1_start,3)}s")
235+
236+
237+
# Code for if you want to generate pictures of the sets of cubes. Will work up to about n=8, before there are simply too many!
238+
# Could be adapted for larger cube sizes by splitting the dataset up into separate images.
239+
# def render_shapes(shapes, path):
240+
# n = len(shapes)
241+
# dim = max(max(a.shape) for a in shapes)
242+
# i = math.isqrt(n) + 1
243+
# voxel_dim = dim * i
244+
# voxel_array = np.zeros((voxel_dim + i,voxel_dim + i,dim), dtype=np.byte)
245+
# pad = 1
246+
# for idx, shape in enumerate(shapes):
247+
# x = (idx % i) * dim + (idx % i)
248+
# y = (idx // i) * dim + (idx // i)
249+
# xpad = x * pad
250+
# ypad = y * pad
251+
# s = shape.shape
252+
# voxel_array[x:x + s[0], y:y + s[1] , 0 : s[2]] = shape
253+
254+
# voxel_array = crop_cube(voxel_array)
255+
# colors = np.empty(voxel_array.shape, dtype=object)
256+
# colors[:] = '#FFD65DC0'
257+
258+
# ax = plt.figure(figsize=(20,16), dpi=600).add_subplot(projection='3d')
259+
# ax.voxels(voxel_array, facecolors = colors, edgecolor='k', linewidth=0.1)
260+
261+
# ax.set_xlim([0, voxel_array.shape[0]])
262+
# ax.set_ylim([0, voxel_array.shape[1]])
263+
# ax.set_zlim([0, voxel_array.shape[2]])
264+
# plt.axis("off")
265+
# ax.set_box_aspect((1, 1, voxel_array.shape[2] / voxel_array.shape[0]))
266+
# plt.savefig(path + ".png", bbox_inches='tight', pad_inches = 0)

0 commit comments

Comments
 (0)