#!/usr/bin/env python3 """ Temo Creates a colorful maze inspired by a famous one line C64 BASIC program. Copyright © 2020 Christian Rosentreter This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . $Id: temo.py 164 2020-07-09 12:18:58Z tokai $ """ import random import argparse import math import sys import colorsys import logging from enum import Enum import xml.etree.ElementTree as xtree __author__ = 'Christian Rosentreter' __version__ = '1.3' __all__ = [] class Direction(Enum): """""" NORTH = 1 SOUTH = 2 EAST = 3 WEST = 4 class Slope(Enum): """""" UP = 0 # "/" DOWN = 1 # "\" class DLine(): """A diagonal line segment inside a square.""" def __init__(self, slope, hue, x1, y1, x2, y2, angle=0): self.slope = slope self.hue = hue if angle: xc = (x1 + x2) / 2.0 yc = (y1 + y2) / 2.0 r = math.sqrt(math.pow(x2 - x1, 2.0) + math.pow(y2 - y1, 2.0)) / 2.0 a = -math.radians((-45.0 if slope == Slope.DOWN else 45) + angle) self.x1 = math.sin(a) * r + xc self.y1 = math.cos(a) * r + yc self.x2 = math.sin(a + math.pi) * r + xc self.y2 = math.cos(a + math.pi) * r + yc else: self.x1 = x1 if (slope == Slope.DOWN) else x2 self.x2 = x2 if (slope == Slope.DOWN) else x1 self.y1 = y1 self.y2 = y2 def __repr__(self): return '\\' if (self.slope == Slope.DOWN) else '/' def hue_blend(a, b): """Blends two angular hue values with linear interpolation.""" if a > b: a, b = b, a d = b - a if d > 180: a += 360 return (a + ((b - a) / 2.0)) % 360 return a + (d / 2.0) def lookup_hue(slope, x, y, rows, hue_shift_line): """Looks up a hue value or a pair of hue values from the already generated grid elements.""" hues = [] if y: if slope == Slope.DOWN: if x and (rows[y-1][x-1].slope == Slope.DOWN): hues.append(rows[y-1][x-1].hue) if rows[y-1][x].slope == Slope.UP: hues.append(rows[y-1][x].hue) else: # slope == Slope.UP if rows[y-1][x].slope == Slope.DOWN: hues.append(rows[y-1][x].hue) if (x < len(rows[y-1]) - 1) and (rows[y-1][x+1].slope == Slope.UP): hues.append(rows[y-1][x+1].hue) if hues: if len(hues) == 2: return hue_blend(hues[0], hues[1]) return (hues[0] + hue_shift_line) % 360 return None def hls_to_hex(hue, lightness, saturation): """Converts a HLS color triplet into a SVG hex string.""" return '#{:02x}{:02x}{:02x}'.format(*(int(c*255) for c in list(colorsys.hls_to_rgb(hue / 360, lightness, saturation)))) def main(): """It's not just a single line of code, but what can we do? :)""" ap = argparse.ArgumentParser( description=('Creates a colorful maze inspired by a famous one line C64 BASIC program ' '(`10 PRINT CHR$(205.5+RND(1)); : GOTO 10\'). Output is generated as a set of vector ' 'shapes in Scalable Vector Graphics (SVG) format and printed on the standard output ' 'stream.'), epilog='Report bugs, request features, or provide suggestions via https://github.com/the-real-tokai/macuahuitl/issues', add_help=False, ) g = ap.add_argument_group('Startup') g.add_argument('-V', '--version', action='version', help="show version number and exit", version='%(prog)s {}'.format(__version__), ) g.add_argument('-h', '--help', action='help', help='show this help message and exit') g = ap.add_argument_group('Algorithm') g.add_argument('--columns', metavar='INT', type=int, help='number of grid columns  [:11]', default=40) g.add_argument('--rows', metavar='INT', type=int, help='number of grid rows  [:11]', default=30) g.add_argument('--scale', metavar='FLOAT', type=float, help='base scale factor of the grid elements [:10.0]', default=10.0) g.add_argument('--random-seed', metavar='INT', type=int, help='fixed initialization of the random number generator for predictable results') g = ap.add_argument_group('Miscellaneous') g.add_argument('--frame', metavar='FLOAT', type=float, help='increase or decrease spacing around the maze  [:20.0]', default=20.0) g.add_argument('--stroke-width', metavar='FLOAT', type=float, help='width of the generated strokes  [:2.0]', default=2.0) g.add_argument('--background-color', metavar='COLOR', type=str, help='SVG compliant color specification or identifier; adds a background to the SVG output') g.add_argument('--hue-shift', metavar='FLOAT', type=float, help='amount to rotate an imaginary color wheel before looking up new colors (in degrees)  [:15.0]', default=15.0) g.add_argument('--hue-shift-line', metavar='FLOAT', type=float, help='separate hue shift for continuous lines; if not passed `--hue-shift\' applies too') g.add_argument('--best-path-width', metavar='FLOAT', type=float, help='show the best (aka the longest) path through the maze and set width of its marker line') g = ap.add_argument_group('Schotter') g.add_argument('--schotter-falloff', choices=('infinite', 'horizontal', 'vertical', 'radial', 'box', 'random'), help='enable `George Nees\'-style randomizing rotations and offsets of the maze\'s line segments') g.add_argument('--schotter-inverse', action='store_true', help='flip the schotter mapping of the selected mode') g.add_argument('--schotter-rotation', metavar='FLOAT', type=float, help='rotational variance for schottering  [:0.5]', default=0.5) g.add_argument('--schotter-offset', metavar='FLOAT', type=float, help='positional variance for schottering  [:0.25]', default=0.25) g = ap.add_argument_group('Output') g.add_argument('-o', '--output', metavar='FILENAME', type=str, help='optionally rasterize the generated vector paths and write the result into a PNG file (requires the `svgcairo\' Python module)') g.add_argument('--output-size', metavar='INT', type=int, help='force pixel width of the raster image, height is automatically calculated; if omitted the generated SVG viewbox dimensions are used') user_input = ap.parse_args() # Generate data… # chaos = random.Random(user_input.random_seed) scale = user_input.scale frame = user_input.frame rows = [] master_hue = chaos.uniform(0,360) huesl = user_input.hue_shift if user_input.hue_shift_line is None else user_input.hue_shift_line for y in range(0, user_input.rows): # master_hue = (360 / user_input.rows * y) % 360 rows.append([]) for x in range(0, user_input.columns): xoffset = 0 yoffset = 0 angle = 0 slope = chaos.choice([Slope.UP, Slope.DOWN]) hue = lookup_hue(slope, x, y, rows, huesl) if hue is None: hue = master_hue master_hue = (master_hue + user_input.hue_shift) % 360 if user_input.schotter_falloff == 'infinite': schotter_factor = 1.0 elif user_input.schotter_falloff == 'random': schotter_factor = chaos.choice([0, 1.0]) elif user_input.schotter_falloff == 'vertical': schotter_factor = 1.0 / (user_input.rows - 1) * y elif user_input.schotter_falloff == 'horizontal': schotter_factor = 1.0 / (user_input.columns - 1) * x elif user_input.schotter_falloff == 'radial': xc = (user_input.columns - 1) / 2.0 yc = (user_input.rows - 1) / 2.0 d = math.sqrt(math.pow(xc - x, 2.0) + math.pow(yc - y, 2.0)) / 2.0 schotter_factor = 1.0 / max(xc, yc) * d elif user_input.schotter_falloff == 'box': md = min(x, (user_input.columns - 1) - x, y, (user_input.rows - 1) - y) * 2.0 schotter_factor = 1.0 / max(user_input.columns - 1, user_input.rows - 1) * md else: schotter_factor = 0 #if schotter_factor > 1.0: # print('WARNING: schotter_factor too big: {}'.format(schotter_factor), file=sys.stderr) if user_input.schotter_inverse: schotter_factor = 1.0 - schotter_factor #schotter_factor = -(math.cos(math.pi * schotter_factor) - 1.0) / 2.0 # ease-in-out-sine schotter_factor = schotter_factor * schotter_factor # ease-in-quad if schotter_factor: xoffset = chaos.uniform(-scale, scale) * schotter_factor * user_input.schotter_offset yoffset = chaos.uniform(-scale, scale) * schotter_factor * user_input.schotter_offset angle = chaos.uniform( -90, 90) * schotter_factor * user_input.schotter_rotation rows[y].append(DLine(slope, hue, (x * scale + frame) + xoffset, (y * scale + frame) + yoffset, (x * scale + scale + frame) + xoffset, (y * scale + scale + frame) + yoffset, angle )) # Primitive path walking… # bestwalker = None circle_pos = None if user_input.best_path_width: coords = [] for x in range(0, user_input.columns): coords.append((x, -1, Direction.SOUTH)) coords.append((x, user_input.rows, Direction.NORTH)) for y in range(0, user_input.rows): coords.append((-1, y, Direction.EAST)) coords.append((user_input.columns, y, Direction.WEST)) chaos.shuffle(coords) offset = scale / 2.0 for pos in coords: wx, wy, wd = pos cx = (wx * scale) + frame + offset cy = (wy * scale) + frame + offset tempwalker = ['M{} {}{}{}'.format(cx, cy, 'v' if wd in (Direction.SOUTH, Direction.NORTH) else 'h', offset if wd in (Direction.SOUTH, Direction.EAST) else -offset )] tx, ty = 1, 1 while True: if wd == Direction.SOUTH: wy += 1 if wy >= user_input.rows: break wd, tx, ty = (Direction.EAST, 1, 1) if (rows[wy][wx].slope == Slope.DOWN) else (Direction.WEST, -1, 1) elif wd == Direction.WEST: wx -= 1 if wx < 0: break wd, tx, ty = (Direction.NORTH, -1, -1) if (rows[wy][wx].slope == Slope.DOWN) else (Direction.SOUTH, -1, 1) elif wd == Direction.EAST: wx += 1 if wx >= user_input.columns: break wd, tx, ty = (Direction.SOUTH, 1, 1) if (rows[wy][wx].slope == Slope.DOWN) else (Direction.NORTH, 1, -1) else: # wd == Direction.NORTH wy -= 1 if wy < 0: break wd, tx, ty = (Direction.WEST, -1, -1) if (rows[wy][wx].slope == Slope.DOWN) else (Direction.EAST, 1, -1) tempwalker.append('l{} {}'.format((offset * tx), (offset * ty))) logging.debug('New position <%u×%u>, direction <%s>', wx, wy, wd) logging.debug(tempwalker) if bestwalker is None or len(tempwalker) > len(bestwalker): bestwalker = tempwalker.copy() circle_pos = (cx, cy) # Generate SVG… # vbw = int((scale * user_input.columns) + (frame * 2.0)) vbh = int((scale * user_input.rows ) + (frame * 2.0)) svg = xtree.Element('svg', {'width':'100%', 'height':'100%', 'xmlns':'http://www.w3.org/2000/svg', 'viewBox':'0 0 {} {}'.format(vbw, vbh)}) title = xtree.SubElement(svg, 'title') title.text = 'A Temo Artwork' if user_input.background_color: xtree.SubElement(svg, 'rect', {'id':'background', 'x':'0', 'y':'0', 'width':str(vbw), 'height':str(vbh), 'fill':user_input.background_color}) svg_g = xtree.SubElement(svg, 'g', {'id':'goto10', 'stroke-width':str(user_input.stroke_width), 'stroke-linecap':'round'}) for row_id, row in enumerate(rows): for col_id, element in enumerate(row): xtree.SubElement(svg_g, 'line', { 'id': 'line-{}x{}'.format(col_id + 1, row_id + 1), 'x1': str(element.x1), 'y1': str(element.y1), 'x2': str(element.x2), 'y2': str(element.y2), 'stroke': hls_to_hex(element.hue, 0.6, 0.5), }) if bestwalker: svg_g = xtree.SubElement(svg, 'g', {'id':'best_walker'}) wcolor = hls_to_hex(chaos.uniform(0, 360), 0.5, 0.8) xtree.SubElement(svg_g, 'path', { 'd': ''.join(bestwalker), 'stroke-width': str(user_input.best_path_width), 'stroke': wcolor, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'fill': 'none', }) xtree.SubElement(svg_g, 'circle', { 'id': 'start_point', 'cx': str(circle_pos[0]), 'cy': str(circle_pos[1]), 'r': str(user_input.best_path_width), 'fill': wcolor, }) rawxml = xtree.tostring(svg, encoding='unicode') # Output… # if not user_input.output: print(rawxml) else: try: import os from cairosvg import svg2png svg2png( bytestring = rawxml, write_to = os.path.realpath(os.path.expanduser(user_input.output)), output_width = user_input.output_size, output_height = int(user_input.output_size * vbh / vbw) if user_input.output_size is not None else None ) except ImportError as e: print('Couldn\'t rasterize nor write a PNG file. Required Python module \'cairosvg\' is not available: {}'.format(str(e)), file=sys.stderr) if __name__ == '__main__': main()