mrlygram.py
6.0 kB · python · 207 lines
1import os2import sys34# MRLYPROD56HERE = os.path.dirname(os.path.abspath(__file__))7MRLYPROD = os.path.normpath(os.path.join(HERE, ".."))89if not os.path.isdir(os.path.join(MRLYPROD, "mrlypy", "six")):10 sys.exit(f"missing mrlypy: expected it at {MRLYPROD}")1112sys.path.insert(0, MRLYPROD)1314import math15from PIL import Image16from mrlypy.core.colors import Color, alpha17from mrlypy.six import FILL, GRID, VOID18from mrlypy.six.designs import carpet_cut, net_cut, tree_cut, void_cut19from mrlypy.six.renderer import draw20from mrlypy.two.designs import carpet_2d, net_2d, tree_2d, void_2d21from mrlypy.two.renderer import to_image2223# DESIGNS2425CUTS = {26 "carpet": carpet_cut,27 "net": net_cut,28 "tree": tree_cut,29 "void": void_cut,30}3132FLATS = {33 "carpet": carpet_2d,34 "net": net_2d,35 "tree": tree_2d,36 "void": void_2d,37}3839DESIGNS = list(CUTS)4041MIN = 142MAX = 5543NUMBERS = [i for i in range(MIN, MAX + 1, 2)]44LEVEL = 145CUBE_CAP = 4 * 1024 ** 34647# PAINT4849INK = Color(17, 17, 17)50PAPER = Color(255, 255, 255)51HEX_PALETTE = {VOID: [PAPER], FILL: [INK], GRID: [alpha]}52FLAT_PALETTE = {0: [PAPER], 1: [INK]}5354# CANVAS5556DATA = os.path.join(HERE, "data", "mrlygram")57H3 = math.sqrt(3) / 258WIDTH = 108059HEIGHT = round(WIDTH * H3)60SUPER = 361GIFW = 64062GREYS = 2463DELAY = 26064HOLD = 30006566# CUT6768def cost(number, level):69 return (4 * number ** level) ** 37071def guard(design, number, level):72 weight = cost(number, level)73 if weight > CUBE_CAP:74 sys.exit("%s %d-%d needs %.1f GB: mrlypy.six.cut blows the cube up by 4 before slicing it"75 % (design, number, level, weight / 1e9))7677# DRAW7879def flatten(image):80 canvas = Image.new("RGB", image.size, PAPER.to_rgb())81 canvas.paste(image, (0, 0), image)82 return canvas8384def cut_gram(design, number, level):85 """The diagonal slice, drawn as triangles, sized to the common box.8687 Every cut draws to a square, so forcing the same box both squashes the88 lattice to equilateral and lines all the numbers up on top of each other.89 """90 guard(design, number, level)91 cell = CUTS[design](number, level).paint(HEX_PALETTE)92 scale = max(1, math.ceil(SUPER * WIDTH / (cell.width + 1)))93 image = flatten(draw(cell, scale=scale, start=cell.start))94 return image.resize((WIDTH, HEIGHT), Image.BOX)9596def flat_gram(design, number, level):97 """The rule in 2D, drawn as squares, sized to the common box."""98 cell = FLATS[design](number, level).paint(FLAT_PALETTE)99 return flatten(to_image(cell)).resize((WIDTH, WIDTH), Image.BOX)100101VIEWS = {102 "cut": cut_gram,103 "flat": flat_gram,104}105106# OVERLAY107108def overlay(layers):109 """Stack the grams at equal weight: frame i is the mean of the first i + 1."""110 canvas = layers[0]111 frames = [canvas]112 for count, layer in enumerate(layers[1:], start=2):113 canvas = Image.blend(canvas, layer, 1 / count)114 frames.append(canvas)115 return frames116117# GIF118119def quantize(image):120 """Ink on paper is grey all the way through, so the gif rides on greys."""121 size = (GIFW, max(1, round(GIFW * image.height / image.width)))122 small = image.convert("L").resize(size, Image.LANCZOS)123 return small.quantize(colors=GREYS, method=Image.MEDIANCUT, dither=Image.Dither.NONE)124125def write_gif(path, frames):126 frames[0].save(127 path,128 save_all=True,129 append_images=frames[1:],130 duration=[DELAY] * (len(frames) - 1) + [HOLD],131 loop=0,132 optimize=True,133 )134135# RUN136137def run(view, design, level=LEVEL):138 folder = os.path.join(DATA, "%s-%s-%d" % (view, design, level))139 os.makedirs(folder, exist_ok=True)140 layers = [VIEWS[view](design, number, level) for number in NUMBERS]141 frames = overlay(layers)142 for index, (number, frame) in enumerate(zip(NUMBERS, frames)):143 path = os.path.join(folder, "%s-%s-%03d-%03d.png" % (view, design, index, number))144 frame.save(path, optimize=True)145 print("saved %d frames to %s" % (len(frames), os.path.relpath(folder, HERE)))146 gif = os.path.join(DATA, "%s-%s-%d.gif" % (view, design, level))147 write_gif(gif, [quantize(frame) for frame in frames])148 print("saved %s %.1f MB" % (os.path.relpath(gif, HERE), os.path.getsize(gif) / 1e6))149 print(" %d grams, %d to %d, overlaid at %dx%d" % (len(frames), MIN, NUMBERS[-1], *frames[0].size))150 return 0151152def cut_run(design, level=LEVEL):153 return run("cut", design, level)154155def flat_run(design, level=LEVEL):156 return run("flat", design, level)157158def sweep(level=LEVEL):159 for view in VIEWS:160 for design in DESIGNS:161 print("== %s %s ==" % (view, design))162 run(view, design, level)163 print()164 return 0165166def pick(design, level):167 if design not in CUTS:168 sys.exit("'%s' must be one of: %s" % (design, ", ".join(DESIGNS)))169 if not level.isdigit() or int(level) < 1:170 sys.exit("'%s' must be an integer >= 1" % level)171 return design, int(level)172173# TERMINAL174175COMMANDS = {176 "sweep": (sweep, "every view and design, frames and gif"),177 "cut": (cut_run, "the diagonal slice, as triangles: DESIGN [LEVEL]"),178 "flat": (flat_run, "the rule in 2D, as squares: DESIGN [LEVEL]"),179}180181def help():182 width = max(len(name) for name in COMMANDS)183 print("mrlygram.py <command> <design> <level> stack every number on top of every other")184 print()185 for name, (_, blurb) in COMMANDS.items():186 print(" %-*s %s" % (width, name, blurb))187 print()188 print(" designs: %s" % ", ".join(DESIGNS))189 print(" numbers: %d to %d odd (%d of them) at level %d" % (MIN, NUMBERS[-1], len(NUMBERS), LEVEL))190 print()191 print("'sweep' draws the lot; 'cut' and 'flat' take one design and any level.")192193def terminal():194 match sys.argv[1:]:195 case ["sweep"]:196 sys.exit(sweep() or 0)197 case ["sweep", level]:198 sys.exit(sweep(pick("carpet", level)[1]) or 0)199 case [("cut" | "flat") as view, design]:200 sys.exit(run(view, *pick(design, str(LEVEL))) or 0)201 case [("cut" | "flat") as view, design, level]:202 sys.exit(run(view, *pick(design, level)) or 0)203 case _:204 help()205206if __name__ == "__main__":207 terminal()