/
DeStijl.py
419 lines (319 loc) · 13.3 KB
/
DeStijl.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# DESCRIPTION
# Generates a Piet Mondrian (De Stijl, or neoplacticism) style painting.
# SOURCE
# Horked and adapted from: https://scipython.com/blog/computer-generated-mondrian-art-2/
# -- NOTE that a perhaps better version is therefrom roundabout found at: https://github.com/xnx/mondrian
# DEPENDENCIES
# - python 3
# USAGE
# python DeStijl.py
# LICENSE
# Open? :|
# TO DO
# - randomly name the output file
# - take CLI parameters which set:
# - width
# - height
# - nLines: number of lines in the painting
# - a color palette .hexplt source
# - how many paintings to generate (to do: generate N paintings).
# CODE
import random
EPS = 1.e-12 # Had to dig that out of inline commentary in the HTML page with the source
class Vector:
""" A lightweight vector class in two-dimensions. """
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, lam):
""" Multiply the vector by a scalar, lam. """
return Vector(lam * self.x, lam * self.y)
def __rmul__(self, lam):
return self.__mul__(lam)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __neq__(self, other):
return not self == other
def __hash__(self):
""" To keep Vector hashable when we define __eq__, define __hash__. """
return self.x, self.y
def __str__(self):
return '({}, {})'.format(self.x, self.y)
def dot(self, other):
""" Dot product, a.b = ax.bx + ay.by. """
return self.x * other.x + self.y * other.y
def cross(self, other):
""" z-component of vector cross product, a x b = ax.by - ay.bx. """
return self.x * other.y - self.y * other.x
class Line:
""" A simple class representing a line segment between two points in 2D. """
def __init__(self, p, r):
"""
p is the start point vector, r is the vector from this start point
to the end point.
"""
self.p, self.r = p, r
@classmethod
def from_endpoints(self, p, q):
""" Create and return the Line object between points p and q. """
return Line(p, q-p)
def __str__(self):
return '{} -> {}'.format(self.p, (self.p + self.r))
def intersection(self, other):
"""
Return the vector to the intersection point between two line segments
self and other, or None if the lines do not intersect.
"""
p, r = self.p, self.r
q, s = other.p, other.r
rxs = r.cross(s)
if rxs == 0:
# Line segments are parallel: no intersection
return None
u = (q - p).cross(r) / rxs
t = (q - p).cross(s) / rxs
if -EPS <= t <= 1+EPS and -EPS <= u <= 1+EPS:
# We have an intersection!
return p + t * r
# Line segments are not parallel but don't intersect
return None
def get_point_on_line(self, u):
""" Return the vector to the point on the line defined by p + ur. """
return self.p + u * self.r
def is_parallel(self, other):
""" Are the lines self and other parallel? """
return abs(self.r.cross(other.r)) < EPS
def is_colinear(self, other):
"""
Are the lines colinear (have the same start and end points, in either
order)? """
return (self.is_parallel(other) and
abs((self.p - other.p).cross(self.r)) < EPS)
class Polygon:
""" A small class to represent a polygon in two dimensions. """
def __init__(self, vertices):
"""
Define the polygon from an ordered sequence of vertices and get its
area and edges (as Line objects).
"""
self.vertices = vertices
self.n = len(self.vertices)
self.area = self.get_area()
self.edges = self.get_edges()
def get_area(self):
""" Calculate and return the area of the polygon."""
# We use the "shoelace" algorithm to calculate the area, since the
# polygon has no holes or self-intersections.
s1 = s2 = 0
for i in range(self.n):
j = (i+1) % self.n
s1 += self.vertices[i].x * self.vertices[j].y
s2 += self.vertices[i].y * self.vertices[j].x
return abs(s1 - s2) / 2
def get_edges(self):
"""
Determine an ordered sequence of edges for the polygon from its
vertices as a list of Line objects.
"""
edges = []
# Indexes of edge endpoints in self.vertices: (0,1), (1,2), ...,
# (n-2, n-1), (n-1, 0)
vertex_pair_indices = [(i,(i+1) % self.n) for i in range(self.n)]
for (i1, i2) in vertex_pair_indices:
v1, v2 = self.vertices[i1], self.vertices[i2]
edges.append(Line.from_endpoints(v1, v2))
return edges
def split(self, intersections):
""" Return the two polygons created by splitting this polygon.
Split the polygon into two polygons at the points given by
intersections = (i1, p1), (i2, p2)
where each tuple contains the index of an edge, i, intersected at the
point, p, by a new line.
Returns: a list of the new Polygon objects formed.
"""
(i1, p1), (i2, p2) = intersections
vertices1 = ([edge.p + edge.r for edge in self.edges[:i1]] +
[p1, p2] +
[edge.p + edge.r for edge in self.edges[i2:]])
polygon1 = Polygon(vertices1)
vertices2 = ([edge.p + edge.r for edge in self.edges[i1:i2]] +
[p2, p1])
polygon2 = Polygon(vertices2)
return [polygon1, polygon2]
def __str__(self):
return ', '.join([str(v) for v in self.vertices])
class Canvas:
# NOTE that you can use #hhhhhh hex colors! :
# Fill colours for polygons, and their cumulative probability distribution
colours = ['blue', 'red', 'yellow', 'white']
# colours = ['yellow', 'green', 'blue', 'white']
# colours = ['green', 'orange', 'purple', 'brown']
# colours = ['cyan', 'magenta', 'purple', 'gray']
colours_cdf = [0.15, 0.3, 0.45, 1.0]
# colours = ['red', 'orange', 'yellow', 'brown', 'black', 'gray', 'white']
# colours_cdf = [0.11, 0.15, 0.3, 0.45, 0.5, 0.9, 1.0]
# colours = ['#3a22eb', '#8becb2', '#fff392', 'cyan', '#af6eb9']
# colours_cdf = [0.15, 0.3, 0.45, 0.5, 1.0]
def get_colour(self):
"""
Pick a colour at random using the cumulative probability distribution
colours_cdf.
"""
cprob = random.random()
i = 0
while Canvas.colours_cdf[i] < cprob:
i += 1
return Canvas.colours[i]
def __init__(self, width, height):
""" Initialize the canvas with a border around the outside. """
self.width, self.height = width, height
self.lines = []
corners = Vector(0,0), Vector(0,1), Vector(1,1), Vector(1,0)
self.add_line(Line(corners[0], Vector(0,1)))
self.add_line(Line(corners[1], Vector(1,0)))
self.add_line(Line(corners[2], Vector(0,-1)))
self.add_line(Line(corners[3], Vector(-1,0)))
self.polygons = {Polygon(corners)}
def add_line(self, new_line):
""" Add new_line to the list of Line objects. """
self.lines.append(new_line)
def split_polygons(self, new_line):
"""
Split any Polygons which are intersected exactly twice by new_line.
Returns the set of "old" Polygons split and a list of the "new"
Polygons thus formed.
"""
new_polygons = []
old_polygons = set()
for polygon in self.polygons:
intersections = []
for i, edge in enumerate(polygon.edges):
p = new_line.intersection(edge)
if p:
intersections.append((i, p))
if len(intersections) == 2:
# this polygon is split into two by the new line
new_polygons.extend(polygon.split(intersections))
old_polygons.add(polygon)
return old_polygons, new_polygons
def update_polygons(self, old_polygons, new_polygons):
"""
Update the set of Polygon objects by removing old_polygons and adding
new_polygons to self.polygons.
"""
self.polygons -= old_polygons
self.polygons.update(new_polygons)
def get_new_line(self):
""" Return a random new line with endpoints on two existing lines. """
# Get a random point on each of any two different existing lines.
line1, line2 = random.sample(self.lines, 2)
start = line1.get_point_on_line(random.random())
end = line2.get_point_on_line(random.random())
# Create and return a new line between the points
return Line.from_endpoints(start, end)
def get_new_orthogonal_line(self):
"""
Return a new horizontal or vertical line between two existing lines.
"""
line1 = random.choice(self.lines)
def get_xy(line):
""" Return 'x' for horizontal line or 'y' for vertical line. """
return 'x' if abs(line.r.y) < EPS else 'y'
def get_other_xy(xy):
""" Passed 'x' or 'y', return 'y' or 'x'. """
return 'y' if xy == 'x' else 'x'
# Is this a line in parallel to the x-axis or the y-axis?
xy = get_xy(line1)
other_xy = get_other_xy(xy)
start = line1.get_point_on_line(random.random())
c = getattr(start, xy)
parallel_lines = []
for line in self.lines:
if line.is_colinear(line1):
continue
if get_xy(line) != xy:
# This line is perpendicular to our choice
continue
c1, c2 = sorted([getattr(line.p, xy), getattr(line.p+line.r, xy)])
if not c1 <= c <= c2:
continue
parallel_lines.append(line)
line2 = random.choice(parallel_lines)
end = Vector(None, None)
setattr(end, xy, getattr(start, xy))
setattr(end, other_xy, getattr(line2.p, other_xy))
return Line.from_endpoints(start, end)
def make_painting(self, nlines, minarea=None, orthogonal=False):
"""
Make the "painting" by adding nlines randomly, such that no polygon
is formed with an area less than minarea. If orthogonal is True,
only horizontal and vertical lines are used.
"""
for i in range(nlines):
while True:
# Create a new line and split any polygons it intersects
if orthogonal:
new_line = self.get_new_orthogonal_line()
else:
new_line = self.get_new_line()
old_polygons, new_polygons = self.split_polygons(new_line)
# If required, ensure that the smallest polygon is at least
# minarea in area, and go back around if not
if minarea:
smallest_polygon_area = min(polygon.area
for polygon in new_polygons)
if smallest_polygon_area >= minarea:
break
else:
break
self.update_polygons(old_polygons, new_polygons)
self.add_line(new_line)
def write_svg(self, filename):
""" Write the image as an SVG file to filename. """
with open(filename, 'w') as fo:
print('<?xml version="1.0" encoding="utf-8"?>', file=fo)
print('<svg xmlns="http://www.w3.org/2000/svg"\n'
' xmlns:xlink="http://www.w3.org/1999/xlink" width="{}"'
' height="{}" >'.format(self.width, self.height), file=fo)
print("""<defs>
<style type="text/css"><![CDATA[
line {
stroke: #000;
stroke-width: 8px;
fill: none;
}
]]></style>
</defs>""", file=fo)
for polygon in self.polygons:
path = []
for vertex in polygon.vertices:
path.append((vertex.x*self.width, vertex.y*self.height))
s = 'M{},{} '.format(*path[0])
s += ' '.join(['L{},{}'.format(*path[i])
for i in range(polygon.n)])
colour = self.get_colour()
print('<path d="{}" style="fill: {}"/>'.format(s, colour),
file=fo)
for line in self.lines[4:]:
x1, y1 = line.p.x * self.width, line.p.y * self.height
x2, y2 = ((line.p + line.r).x * self.width,
(line.p + line.r).y * self.height)
print('<line x1="{}" y1="{}" x2="{}" y2="{}"/>'
.format(x1,y1,x2,y2), file=fo)
print('</svg>', file=fo)
# Generate N paintings via all of the above, and save to random file names
for i in range(0, 51):
nlines = 9
# 11" x 8.5" for standard landscape printer paper:
width, height = 792, 612
import string
minarea = (width * height) * 0.000000055
canvas = Canvas(width, height)
canvas.make_painting(nlines, minarea, orthogonal=True)
# To generate random 14-character string for random file base name:
import random
file_base_name = ''.join(random.choices(string.ascii_uppercase + string.digits, k=14))
canvas.write_svg(file_base_name + '.svg')