TheBird

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()