#!/usr/bin/env python3 """HatchLing-TuneBox: pictures, Ling programs, SVG files and tunes in; outline-and-crosshatch sheets, maps, globes and music boxes out.""" import os import tempfile from pathlib import Path os.environ.setdefault('LOKY_MAX_CPU_COUNT', str(os.cpu_count() or 1)) # joblib shells out to wmic to count cores, which Windows 11 no longer has import gradio as gr import numpy as np import ling_hatch as lh import ling_tiles as lt import ling_map as lm import ling_globe as lg import ling_musicbox as mb import themes import ling_dice as ld import ling_gltf as gl import ling_lamp as ll import sizes from gpu import gpu_task GROUNDS = ['white', 'black'] def _options(layout, crop, anchor, orientation, paper, key_side, margin, hues, steps, coding, analysis_px, simplify, smooth, min_region, contrast, gamma, tone_weight, vivid_auto, vivid, keep_transparent, seed, dpi, line_mm, spacing_mm, quality, black_ground, title, subtitle, rev, key_phrase): return lh.Options( layout='instruments' if layout.startswith('Picture with') else 'picture', crop='fill' if crop.startswith('Crop') else 'fit', anchor=anchor, orientation=orientation, paper=paper, key_side=key_side, margin_mm=margin, hues=int(hues), steps=int(steps), coding=coding, analysis_px=int(analysis_px), simplify=simplify, smooth=smooth, min_region=int(min_region), contrast=contrast, gamma=gamma, tone_weight=tone_weight, vivid=-1.0 if vivid_auto else vivid, keep_transparent=keep_transparent, seed=int(seed), dpi=int(dpi), line_mm=line_mm, spacing_mm=spacing_mm, quality=quality, black_ground=black_ground, title=title, subtitle=subtitle, rev=rev, key_phrase=key_phrase or '') def _paths(files): return [f if isinstance(f, str) else f.name for f in (files or [])] @gpu_task() def quick_preview(files, grounds, *args): paths = _paths(files) if not paths: raise gr.Error('Add at least one image.') o = _options(*args); out = [] for p in paths: try: for g, img in lh.preview_posterize(p, o, grounds or GROUNDS).items(): out.append((img, '%s on %s' % (Path(p).name, g))) except Exception as e: gr.Warning('%s: %s' % (Path(p).name, e)) return out @gpu_task() def generate(files, grounds, style, circle_bias, mix, tile_min, circle_max, ring_text, lettering, png2x, animate, look, as_ling, *args, progress=gr.Progress()): paths = _paths(files) if not paths: raise gr.Error('Add at least one image.') grounds = grounds or GROUNDS o = _options(*args) out_dir = Path(tempfile.mkdtemp(prefix='ling_hatch_')) stages = ['signing', 'clustering'] + ['%s on %s' % (a, g) for g in grounds for a in ('posterizing', 'hatching')] def report(i, n, msg): stage = msg.rsplit(': ', 1)[-1] frac = (stages.index(stage) / len(stages)) if stage in stages else 0 progress((i + frac) / n, desc=msg) if style.startswith('Tiles') or look != 'none': ling = lettering.startswith('Chesilin') t = lt.TileOptions(circle_bias=circle_bias, mix=mix, tile_min_mm=tile_min, circle_max_mm=circle_max, ring_text=ring_text or '', ring_face='chesilin' if ling else 'lingring', tile_face='daemon' if ling else 'lingring', animate=bool(animate)) if look != 'none': o, t = lt.preset(look, o, t) recs = lt.run_batch(paths, o, t, out_dir, grounds, previews=True, progress=report, png_scale=2.0 if png2x else 0.0) else: recs = lh.run_batch(paths, o, out_dir, grounds, previews=True, progress=report) gallery, files_out, lines = [], [], [] for r in recs: name = Path(r['source']).name if r['error']: lines.append('%s: failed (%s)' % (name, r['error'])); continue for g in grounds: gallery.append((r['preview'][g], '%s on %s' % (name, g))); files_out.append(r['svg'][g]) if r.get('png', {}).get(g): files_out.append(r['png'][g]) if as_ling: # the sheet as a Ling program that draws it stem = Path(r['svg'][g]).stem progs, _ = lm.svg_to_ling(Path(r['svg'][g]).read_text(encoding='utf-8'), stem, 'draw') files_out += lm.write_outputs(progs, out_dir, stem) lines.append('%s: %s' % (name, ', '.join(Path(f).name for f in r['svg'].values()))) if (out_dir / 'all.zip').exists(): files_out.append(str(out_dir / 'all.zip')) return gallery, files_out, '\n'.join(lines) def make_map(files, grounds, paper, orientation, rings, edges, title, key_phrase, png2x, progress=gr.Progress()): """Ling program (.zip or .ling files) -> functional map SVGs.""" paths = _paths(files) if not paths: raise gr.Error('Add a .zip or one or more .ling files.') grounds = grounds or GROUNDS try: sources = lm.load_sources(paths) project = Path(paths[0]).stem if len(paths) == 1 else 'project' o = lm.MapOptions(paper=paper, orientation=orientation, rings=int(rings), edges=bool(edges), title=title or '', key_phrase=key_phrase or '') steps = ['parsing', 'signing', 'laying out'] + ['drawing on ' + g for g in grounds] svgs = lm.program_to_svg(sources, o, grounds, project, lambda m: progress(steps.index(m) / len(steps) if m in steps else 0, desc=m)) except Exception as e: raise gr.Error(str(e)) out_dir = Path(tempfile.mkdtemp(prefix='ling_map_')); gallery, files_out = [], [] for g, svg in svgs.items(): f = out_dir / ('%s_map_%s.svg' % (project, g)); f.write_text(svg, encoding='utf-8'); files_out.append(str(f)) png = out_dir / ('%s_map_%s.png' % (project, g)); lt.write_png(svg, png, g, 2.0) gallery.append((str(png), '%s on %s' % (project, g))) if png2x: files_out.append(str(png)) info = lm.embedded_program(next(iter(svgs.values())))[1] sheet = lm.sheet_info(next(iter(svgs.values()))); c = sheet['counts'] lines = ['%d files, %d functions, %d globals, %d types, %d calls between functions, %d tokens.' % ( c['files'], c['functions'], c['globals'], c['types'], c['calls'], c['tokens']), 'Sheet %g x %g mm. Label cap height %.1f mm (%s the 3.5 mm print floor%s).' % ( sheet['size_mm'][0], sheet['size_mm'][1], sheet['label_cap_height_mm'], 'meets' if sheet['labels_meet_print_floor'] else 'below', '' if sheet['labels_meet_print_floor'] else '; choose a larger paper or "auto" for print'), 'The program is stored in each SVG and signed (signature checks: %s).' % ('yes' if info['signature_valid'] else 'no')] if c['parse_errors']: lines.append('%d places could not be read as Ling and were skipped in the map (the stored program is still complete).' % c['parse_errors']) return gallery, files_out, '\n'.join(lines) def svg_to_ling(files, mode): """SVG -> Ling: the program a map carries, or a program that redraws the SVG.""" paths = _paths(files) if not paths: raise gr.Error('Add at least one SVG.') out_dir = Path(tempfile.mkdtemp(prefix='ling_from_svg_')); files_out, lines, shown = [], [], '' key = {'Automatic': 'auto', 'Program stored in the map': 'source', 'Program that redraws the SVG': 'draw'}[mode] for p in paths: name = Path(p).stem try: progs, info = lm.svg_to_ling(Path(p).read_text(encoding='utf-8'), name, key) except Exception as e: lines.append('%s: %s' % (Path(p).name, e)); continue made = lm.write_outputs(progs, out_dir, name) files_out += [m for m in made if m.endswith('.zip')] or made if info['kind'] == 'stored program': lines.append('%s: the stored program, %d file(s). Signature %s.' % ( Path(p).name, len(progs), 'valid' if info['signature_valid'] else 'NOT valid: the program or signature was changed')) else: extra = ', '.join('%d %s skipped' % (n, t) for t, n in info['skipped_elements'].items()) lines.append('%s: a drawing program with %d strokes in %d functions. Run it with "ling run %s.ling"; it writes %s_redrawn.svg.%s' % ( Path(p).name, info['strokes'], info['functions'], name, name, (' Not drawn: ' + extra + '.') if extra else '')) if not shown: first = next(iter(progs.values())).decode('utf-8', 'replace'); shown = first[:6000] + ('\n// ...' if len(first) > 6000 else '') return files_out, '\n'.join(lines), shown @gpu_task() def make_globe(files, midi, wav, as_ling, at, detail, dial_max, node_min, coding, key_phrase, progress=gr.Progress()): """A picture, an SVG or a Ling program -> a globe you can spin, as one HTML page (and, if asked, a Ling program).""" import html paths = _paths(files) if not paths: raise gr.Error('Add a picture, an SVG, a .zip or .ling files.') midi_path = (midi if isinstance(midi, str) else midi.name) if midi else '' wav_path = (wav if isinstance(wav, str) else wav.name) if wav else '' o = lh.Options(coding=coding, key_phrase=key_phrase or '') g = lg.GlobeOptions(samples={'quick': 500000, 'standard': 1000000, 'fine': 1600000}[detail], circle_max_deg=float(dial_max), node_min_deg=float(node_min), midi=midi_path, wav=wav_path) progs = [p for p in paths if Path(p).suffix.lower() in ('.ling', '.zip')] jobs = ([progs] if progs else []) + [[p] for p in paths if p not in progs] out_dir = Path(tempfile.mkdtemp(prefix='ling_globe_')); files_out, lines, first = [], [], '' for n, job in enumerate(jobs): stem = Path(job[0]).stem if len(job) == 1 else 'project' try: page, counts, sc = lg.make_globe(job, o, g, lambda m, n=n: progress(n / len(jobs), desc='%s: %s' % (stem, m))) except Exception as e: lines.append('%s: failed (%s: %s)' % (stem, type(e).__name__, e)); continue f = out_dir / (stem + '_globe.html'); f.write_text(page, encoding='utf-8'); files_out.append(str(f)); first = first or page if as_ling: lf = out_dir / (stem + '_globe.ling'); lf.write_text(lg.ling_program(sc, sc['notes'], float(at)), encoding='utf-8'); files_out.append(str(lf)) lines.append('%s: %d dials (%d of them signal dials), %d tiles, %d wires; %d%% of the shell covered.' % ( stem, counts['dials'], counts['nodes'], counts['tiles'], counts['wires'], round(100 * counts['covered']))) if as_ling: lines.append('Run a .ling file with "ling run name_globe.ling": it draws the globe %g s into the tune as name_globe_frame.svg.' % float(at)) view = '' % html.escape(first, quote=True) if first else '' return view, files_out, '\n'.join(lines) @gpu_task() def make_box(files, midi, wav, paper, orientation, key_side, roll_side, choreo, grounds, layout, key, roll, seconds, start, fit, turn, tilt, detail, dial_max, as_ling, key_phrase, progress=gr.Progress()): """A picture, an SVG or a Ling program, with a MIDI file or a recording -> an animated SVG sheet that plays the tune through the circuit.""" import html paths = _paths(files) if not paths: raise gr.Error('Add a picture, an SVG, a .zip or .ling files.') midi_path = (midi if isinstance(midi, str) else midi.name) if midi else '' wav_path = (wav if isinstance(wav, str) else wav.name) if wav else '' flat = layout.startswith('Flat') o = lh.Options(coding='perceptual', line_mm=0.2, crop='fit' if fit else 'fill', key_phrase=key_phrase or '') g = lg.GlobeOptions(samples={'quick': 500000, 'standard': 1000000, 'fine': 1600000}[detail], circle_max_deg=float(dial_max), tile_max_deg=0.8 * float(dial_max), midi=midi_path, wav=wav_path) b = mb.BoxOptions(paper=paper, orientation=orientation, key_side=key_side, roll_side=roll_side, choreography=bool(choreo), layout='flat' if flat else 'globe', key=bool(key), roll=bool(roll), seconds=float(seconds), start=float(start), turn=float(turn), tilt=float(tilt)) progs = [p for p in paths if Path(p).suffix.lower() in ('.ling', '.zip')] job = progs or paths[:1]; stem = Path(job[0]).stem if len(job) == 1 else 'project' try: res = mb.make_box(job, o, g, b, grounds or ['black'], lambda m: progress(0, desc=m)) except Exception as e: raise gr.Error('%s: %s' % (type(e).__name__, e)) out_dir = Path(tempfile.mkdtemp(prefix='ling_musicbox_')); files_out = [] for ground, svg in res['svg'].items(): f = out_dir / ('%s_musicbox_%s_%s_%s.svg' % (stem, paper, b.layout, ground)); f.write_text(svg, encoding='utf-8'); files_out.append(str(f)) f = out_dir / (stem + '_musicbox.html'); f.write_text(res['html'], encoding='utf-8'); files_out.append(str(f)) if as_ling and not flat: lf = out_dir / (stem + '_globe.ling'); lf.write_text(lg.ling_program(res['scene'], res['scene']['notes'], float(start) + 6.0), encoding='utf-8'); files_out.append(str(lf)) i = res['info'] log = ('%s: %d notes over %g s, played through %d signal dials among %d pieces.\n' 'The SVG plays on its own in a browser, without sound. The .html file is the same sheet with the tune as sound: press play there ' '(or here, below the sheet) and the two keep in step. There you can also zoom (wheel, pinch, + and -) and drag the sheet about.' % (i['tune'], i['notes'], i['seconds'], i['signal_dials'], i['pieces'])) view = '' % html.escape(res['html'], quote=True) return view, files_out, log SHAPES = list(ld.SOLIDS) + ['globe (sphere)', 'board (a flat table, like a Reactable)'] @gpu_task() def make_dice(files, midi, wav, shape, style, board, diameter, dial_max, coding, key_phrase, wires, progress=gr.Progress()): """A picture -> its dials and tiles over a die, a globe or a flat board: an interactive page that plays a tune, and every piece as a 3D object (.glb).""" import html paths = _paths(files) if not paths: raise gr.Error('Add a picture.') midi_path = (midi if isinstance(midi, str) else midi.name) if midi else '' wav_path = (wav if isinstance(wav, str) else wav.name) if wav else '' o = lh.Options(coding=coding, key_phrase=key_phrase or '', line_mm=0.2, analysis_px=900) g = lg.GlobeOptions(circle_max_deg=float(dial_max), tile_max_deg=0.8 * float(dial_max), midi=midi_path, wav=wav_path, samples=700000) say = lambda m: progress(0, desc=m) out_dir = Path(tempfile.mkdtemp(prefix='ling_dice_')); files_out = []; stem = Path(paths[0]).stem; page = '' try: if shape in ld.SOLIDS: page, counts, sc = ld.make_die(paths[:1], o, g, ld.SOLIDS[shape], say); scene_name = shape.split(' ')[0] elif shape.startswith('globe'): page, counts, sc = lg.make_globe(paths[:1], o, g, say); scene_name = 'globe' else: b = mb.BoxOptions(layout='flat', max_dials=240) sc = mb.flat_scene(paths[:1], o, g, b, 300.0, 300.0, say); sc['flat'] = True; scene_name = 'board' counts = {'dials': sum(e['kind'] == 0 for e in sc['els']), 'tiles': sum(e['kind'] == 1 and e['tess'] != 2 for e in sc['els']), 'nodes': len(sc['nodes']), 'wires': sum(w['kind'] < 3 for w in sc['wires'])} say('making the 3D objects') glb = gl.export_glb(sc, style=style, board=board, diameter_m=float(diameter) if diameter else None, wires=bool(wires)) except Exception as e: raise gr.Error('%s: %s' % (type(e).__name__, e)) gf = out_dir / ('%s_%s.glb' % (stem, scene_name)); gf.write_bytes(glb); files_out.append(str(gf)) if page: pf = out_dir / ('%s_%s.html' % (stem, scene_name)); pf.write_text(page, encoding='utf-8'); files_out.append(str(pf)) view = '' % html.escape(page, quote=True) if page else \ '
The board is shown as 3D objects below. Open the .glb in Blender, or drag it into any glTF viewer.
' log = ('%s on a %s: %d dials (%d of them signal dials), %d tiles, %d wires. The .glb has every piece as its own object, %.1f MB; in Blender use ' 'File > Import > glTF 2.0. Each object carries its key, tone and signal number as custom properties.' % ( stem, scene_name, counts['dials'], counts['nodes'], counts['tiles'], counts['wires'], len(glb) / 1e6)) return view, str(gf), files_out, log def make_lamp(files, midi, shape, width, height, leds, sides, filament, pitch, bend, wall, cell_h, seconds, coding, key_phrase, music_box=False, speaker=40.0, depth=28.0, progress=gr.Progress()): """A picture -> a printable lamp: mirrored cells, filament conduits, LED housings, and the pattern for the LEDs.""" import html paths = _paths(files) if not paths: raise gr.Error('Add a picture.') midi_path = (midi if isinstance(midi, str) else midi.name) if midi else '' o = ll.LampOptions(shape=shape, width=float(width), height=float(height), leds=int(leds), sides=2 if str(sides).startswith('Both') else 1, filament=float(filament), pitch=float(pitch), bend=float(bend), wall=float(wall), cell_h=float(cell_h), seconds=float(seconds), coding=coding, key_phrase=key_phrase or '', music_box=bool(music_box), speaker=float(speaker or 40.0), depth=float(depth or 28.0)) try: L = ll.build(paths[:1], o, midi=midi_path if midi_path.lower().endswith(('.mid', '.midi')) else '', progress=lambda m: progress(0, desc=m)) files_out = ll.package(L); glb = ll.preview_glb(L, L.get('box')) except Exception as e: raise gr.Error('%s: %s' % (type(e).__name__, e)) out_dir = Path(tempfile.mkdtemp(prefix='ling_lamp_')); stem = Path(paths[0]).stem; paths_out = [] zf = out_dir / ('%s_lamp.zip' % stem); zf.write_bytes(ll.zip_files(files_out)); paths_out.append(str(zf)) for k, v in files_out.items(): if k.endswith(('.stl', '.csv', '.kicad_pcb', '.svg')): f = out_dir / k; f.write_bytes(v); paths_out.append(str(f)) gf = out_dir / ('%s_lamp_preview.glb' % stem); gf.write_bytes(glb) d = L['dims']; per = {} for c in L['leds']: per[c['side']] = per.get(c['side'], 0) + 1 log = ('%s: %d cells, %d lit by an LED (%s), %d without a lane. The block is %g x %g x %g mm: check it fits your bed (or print the panel smaller). ' 'LED pitch %g mm; filament %g mm (cut lengths are in led_map.csv); the animation is %d frames at %d fps for %d LEDs. BUILD_NOTES.txt in the zip has the rest.' % ( stem, len(L['cells']), len(L['leds']), ', '.join('%d on the %s' % (n, 'right' if k > 0 else 'left') for k, n in sorted(per.items())), len(L['dark']), d['block_mm'][0], d['block_mm'][1], d['block_mm'][2], o.pitch, o.filament, L['meta']['frames'], o.fps, len(L['leds']))) if o.music_box: log += '\nMusic box: enclosure body and bezel, one colour plug file for each key, and KiCad files for the controller and the LED strips (see MUSIC_BOX_NOTES.txt).' if L.get('box_problems'): log += ' Board file problems: ' + '; '.join(L['box_problems']) view = '' % html.escape(files_out['lightbox_preview.html'].decode('utf-8'), quote=True) return view, str(gf), paths_out, log def apply_theme(look): return themes.banner(look), themes.css(look) with gr.Blocks(title=themes.NAME) as demo: theme_style = gr.HTML(themes.css('none')) banner = gr.Markdown(themes.banner('none'), elem_classes='hl-banner') with gr.Tab('Pictures'): with gr.Row(): with gr.Column(scale=1): files = gr.Files(label='Images', file_count='multiple', file_types=['image'], type='filepath') grounds = gr.CheckboxGroup(GROUNDS, value=GROUNDS, label='Grounds') as_ling = gr.Checkbox(value=False, label='Also write each sheet as a Ling program', info='A .ling file that redraws the sheet with Ling\'s svg builtins when run (ling run name.ling).') look = gr.Dropdown(themes.LOOKS, value='none', label='Preset', info='patriotic: tiles and rings in the colours and mottoes of America, Korea or Ling Country, with stars, diamonds or the character ling. ' 'Overrides the settings it needs, and dresses the whole app in that country\'s colours, background and slogans.') style = gr.Radio(['Hatch', 'Tiles and text rings'], value='Hatch', label='Style', info='Hatch: outlined regions filled with hatch. Tiles and text rings: regions packed with square tiles and circles of Ling Ring text.') layout = gr.Radio(['Picture with legend, dial, digest and title plate', 'Picture only'], value='Picture with legend, dial, digest and title plate', label='Sheet') crop = gr.Radio(['Crop to fill the picture area', 'Fit the whole image'], value='Crop to fill the picture area', label='Cropping', info='The image is never stretched.') with gr.Row(): anchor = gr.Dropdown(['center', 'top', 'bottom', 'left', 'right'], value='center', label='Keep when cropping') orientation = gr.Dropdown(['auto', 'portrait', 'landscape'], value='auto', label='Orientation', info='auto: "Picture only" follows the picture; the sheet with a key keeps the paper\'s own shape.') with gr.Row(): paper = gr.Dropdown(sizes.CHOICES, value='a4', label='Sheet size', info='Paper, or a screen, phone, tablet or projector: a screen sheet keeps its shape and its SVG is its real pixel size.') key_side = gr.Dropdown(['bottom', 'top', 'left', 'right'], value='bottom', label='Key position', info='Where the legend, dial, digest and title plate go.') with gr.Accordion('Posterize', open=True): with gr.Row(): hues = gr.Slider(3, 7, value=7, step=1, label='Colour regions', info='How many of the seven hues are used.') steps = gr.Slider(2, 4, value=4, step=1, label='Hatch densities per hue') coding = gr.Dropdown(['perceptual', 'cryptographic', 'tonal', 'appearance', 'spectrum', 'spectrum-reversed'], value='perceptual', label='Colour coding', info='perceptual: each colour keeps its own hue. cryptographic: hues in the order the signature gives. ' 'appearance: seven regions, one hue each. spectrum: red to violet from light to dark.') with gr.Row(): analysis_px = gr.Slider(512, 2048, value=1024, step=128, label='Detail (analysis size, px)') smooth = gr.Slider(0.5, 4, value=1.0, step=0.1, label='Edge smoothing') with gr.Row(): simplify = gr.Slider(0, 3, value=1.0, step=0.1, label='Flatten before clustering') min_region = gr.Slider(0, 400, value=30, step=5, label='Smallest region (px)') with gr.Row(): contrast = gr.Slider(0, 5, value=2.0, step=0.1, label='Local contrast') gamma = gr.Slider(0.5, 2.5, value=1.25, step=0.05, label='Gamma', info='Above 1 darkens mid-tones.') with gr.Row(): tone_weight = gr.Slider(0.2, 2, value=0.8, step=0.05, label='Tone vs colour', info='Higher: regions follow light and dark. Lower: they follow colour.') seed = gr.Number(value=87, precision=0, label='Clustering seed') with gr.Row(): vivid_auto = gr.Checkbox(value=True, label='Colour strength: auto', info='Photos follow tone; flat, saturated artwork gets dense colour.') vivid = gr.Slider(0, 1.5, value=0.0, step=0.05, label='Colour strength (when auto is off)', info='0: density follows light and dark only. 1: saturated colours get dense hatch.') keep_transparent = gr.Checkbox(value=True, label='Keep transparent areas empty', info='Transparent parts of a PNG get no hatch and no outline.') with gr.Accordion('Tiles and text rings', open=False): with gr.Row(): circle_bias = gr.Slider(0.5, 3, value=1.35, step=0.05, label='Circles vs tiles', info='Higher: more circles.') mix = gr.Slider(0, 2, value=1.0, step=0.1, label='Patchiness', info='Higher: stronger patches of tiles and of circles.') with gr.Row(): tile_min = gr.Slider(0.4, 3, value=0.4, step=0.1, label='Smallest tile (mm)', info='Smaller: finer detail, more pieces.') circle_max = gr.Slider(4, 30, value=15, step=1, label='Largest circle radius (mm)') ring_text = gr.Textbox(value='', label='Ring text', max_lines=1, info='Empty: each ring spells its colour key (asm, lit, bind, ctl, call, fn, mod).') lettering = gr.Radio(['Chesilin rings, Daemon tiles', 'Ling Ring'], value='Chesilin rings, Daemon tiles', label='Lettering') png2x = gr.Checkbox(value=False, label='Also write PNGs at 2x (1588 x 2246 for A4)') animate = gr.Checkbox(value=False, label='Animated SVG for screens', info='Rings turn, glyphs flash, letters flip, tile frames light in a wave, the crosshatch opens and closes. ' 'For print leave this off. Lighter settings run smoother: smallest tile 0.7 mm or more.') with gr.Accordion('Hatch and print', open=False): with gr.Row(): dpi = gr.Dropdown([720, 1440, 2880], value=1440, label='DPI grid') quality = gr.Dropdown(['draft', 'standard', 'fine'], value='standard', label='Edge precision') with gr.Row(): line_mm = gr.Slider(0.08, 0.3, value=0.123, step=0.001, label='Line weight (mm)') spacing_mm = gr.Slider(0.22, 0.8, value=0.229, step=0.001, label='Densest spacing (mm)') with gr.Row(): margin = gr.Slider(5, 25, value=10, step=0.5, label='Sheet margin (mm)') black_ground = gr.Checkbox(value=True, label='Paint the black ground', info='Turn off to print the black version on black stock.') with gr.Accordion('Title plate and key', open=False): with gr.Row(): title = gr.Textbox(value='LING', label='Title', max_lines=1) subtitle = gr.Textbox(value='fips 204', label='Subtitle (10 characters)', max_lines=1) rev = gr.Textbox(value='rev 2026.1', label='Revision (10 characters)', max_lines=1) key_phrase = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password', info='Empty: a fresh ML-DSA-87 key per image, discarded after signing. ' 'Set: the key comes from the phrase and the same image gives the same hatch.') with gr.Row(): btn_prev = gr.Button('Preview posterization') btn_run = gr.Button('Generate A4 sheets', variant='primary') with gr.Column(scale=1): gallery = gr.Gallery(label='Previews', columns=2, height=720, object_fit='contain') downloads = gr.Files(label='SVG files (and all.zip)') log = gr.Textbox(label='Result', lines=4) opts = [layout, crop, anchor, orientation, paper, key_side, margin, hues, steps, coding, analysis_px, simplify, smooth, min_region, contrast, gamma, tone_weight, vivid_auto, vivid, keep_transparent, seed, dpi, line_mm, spacing_mm, quality, black_ground, title, subtitle, rev, key_phrase] btn_prev.click(quick_preview, [files, grounds] + opts, gallery) look.change(apply_theme, look, [banner, theme_style], show_progress='hidden') btn_run.click(generate, [files, grounds, style, circle_bias, mix, tile_min, circle_max, ring_text, lettering, png2x, animate, look, as_ling] + opts, [gallery, downloads, log]) with gr.Tab('Ling programs'): gr.Markdown('A `.zip` of a Ling project, or one or more `.ling` files, becomes a functional map. Each file is a framed panel. ' 'Each function is a dial: its rings read outward through its statements and what they contain, coloured by what the code does ' 'and hatched more densely the more code a cell holds, with its name round the outside. Globals and types are tiles; calls and ' 'imports are lines. The program itself is stored in the SVG and signed with ML-DSA-87, so the map can be turned back into the program.') with gr.Row(): with gr.Column(scale=1): m_files = gr.Files(label='Ling program (.zip or .ling files)', file_count='multiple', file_types=['.zip', '.ling'], type='filepath') m_grounds = gr.CheckboxGroup(GROUNDS, value=GROUNDS, label='Grounds') with gr.Row(): m_paper = gr.Dropdown([('auto', 'auto')] + sizes.CHOICES, value='a4', label='Paper or screen', info='auto: as large as the program needs for print-size lettering.') m_orient = gr.Dropdown(['auto', 'portrait', 'landscape'], value='auto', label='Orientation') with gr.Row(): m_rings = gr.Slider(1, 4, value=3, step=1, label='Rings per dial', info='How many levels of nesting each function shows.') m_edges = gr.Checkbox(value=True, label='Draw calls and imports') m_title = gr.Textbox(value='', label='Title (empty: the project name)', max_lines=1) m_key = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password', info='Empty: a fresh ML-DSA-87 key each time. Set: the same program always gives the same sheet.') m_png = gr.Checkbox(value=False, label='Also give the PNGs') m_run = gr.Button('Make the map', variant='primary') with gr.Column(scale=1): m_gallery = gr.Gallery(label='Maps', columns=2, height=720, object_fit='contain') m_downloads = gr.Files(label='SVG files') m_log = gr.Textbox(label='Result', lines=4) m_run.click(make_map, [m_files, m_grounds, m_paper, m_orient, m_rings, m_edges, m_title, m_key, m_png], [m_gallery, m_downloads, m_log]) with gr.Tab('Globe'): gr.Markdown('A picture, an SVG or a Ling program becomes a sphere you can spin, packed edge to edge with dials and tiles that stand on its ' 'shell. The dials are signal nodes: a pulse lights and turns them and runs on along their wires, and what else it does follows ' 'each dial\'s key. Lettered tiles wired to a dial are its meters. Pulses come from a MIDI file (or, without one, from a tune ' 'the signature writes), from clicking a dial, and from turning the globe. The result is one HTML page; open it in a browser.') with gr.Row(): with gr.Column(scale=1): g_files = gr.Files(label='A picture, an SVG, a .zip or .ling files', file_count='multiple', type='filepath') g_midi = gr.File(label='MIDI (optional): the exact notes', file_types=['.mid', '.midi', '.wav', '.mp3', '.ogg', '.oga', '.flac', '.m4a'], type='filepath') g_wav = gr.File(label='Recording (optional): WAV, MP3, OGG or FLAC', file_types=['.wav', '.mp3', '.ogg', '.oga', '.flac', '.m4a'], type='filepath') gr.Markdown('With both, the MIDI gives every note and the recording\'s FFT lines the MIDI up with the sound, sets how hard each note is, adds drum hits the MIDI lacks, and is what plays.') with gr.Row(): g_ling = gr.Checkbox(value=True, label='Also write a Ling program', info='A .ling file that draws the globe, with its circuit, at one moment of the tune.') g_at = gr.Number(value=6.0, label='That moment (seconds)') with gr.Row(): g_detail = gr.Dropdown(['quick', 'standard', 'fine'], value='standard', label='Packing detail', info='How finely the shell is measured. Standard takes about a minute.') g_coding = gr.Dropdown(['perceptual', 'cryptographic', 'appearance', 'spectrum', 'spectrum-reversed'], value='perceptual', label='Colour coding (pictures)') with gr.Row(): g_dial = gr.Slider(4, 12, value=7, step=0.5, label='Largest dial (degrees)', info='Smaller keeps more of a picture\'s detail.') g_node = gr.Slider(1.5, 5, value=2.7, step=0.1, label='Smallest signal dial (degrees)', info='Lower: more dials carry signals.') g_key = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password', info='Empty: a fresh ML-DSA-87 key each time. Set: the same source always gives the same globe.') g_run = gr.Button('Make the globe', variant='primary') with gr.Column(scale=1): g_view = gr.HTML(label='Globe') g_downloads = gr.Files(label='Files') g_log = gr.Textbox(label='Result', lines=3) g_run.click(make_globe, [g_files, g_midi, g_wav, g_ling, g_at, g_detail, g_dial, g_node, g_coding, g_key], [g_view, g_downloads, g_log]) with gr.Tab('Music box'): gr.Markdown('A picture, an SVG or a Ling program, with a tune, becomes one big animated SVG sheet of dials and tiles wired into a circuit. ' 'The tune is a MIDI file or a recording (wav, mp3, ogg, flac); a recording is analysed with the FFT and its onsets become the ' 'impulses. Every note is drawn on a track round the frame and a playhead travels the frame once per loop. As it passes a note, ' 'the pulse runs through the circuit: dials light and turn, meters light, swap and split, wires flash. ' 'The SVG plays by itself in a browser. Without a tune the signature writes one.') with gr.Row(): with gr.Column(scale=1): b_files = gr.Files(label='A picture, an SVG, a .zip or .ling files', file_count='multiple', type='filepath') b_midi = gr.File(label='MIDI: the exact notes (or a recording alone)', file_types=['.mid', '.midi', '.wav', '.mp3', '.ogg', '.oga', '.flac', '.m4a'], type='filepath') b_wav = gr.File(label='Recording (optional): WAV, MP3, OGG or FLAC', file_types=['.wav', '.mp3', '.ogg', '.oga', '.flac', '.m4a'], type='filepath') gr.Markdown('With both, the MIDI gives every note and the recording\'s FFT lines the MIDI up with the sound, sets how hard each note is, adds drum hits the MIDI lacks, and is what plays.') b_layout = gr.Radio(['Globe: the face of the sphere', 'Flat: pieces laid across the whole sheet'], value='Globe: the face of the sphere', label='Layout') b_choreo = gr.Checkbox(value=False, label='Enhance choreography', info='The tune drives the whole circuit as a show: ripples out from the centre, waterfalls down the face, sweeping arms, ' 'marching ranks on the beat, spirals and a six-fold kaleidoscope. It changes move every two bars, and turns the colours ' 'about: some sections light a note\'s own hue, others everything but it.') with gr.Row(): b_key = gr.Checkbox(value=True, label='Key', info='Legend, colour shares, digest and title plate. Off: the picture takes the whole sheet.') b_roll = gr.Checkbox(value=False, label='Piano roll', info='A window where the notes, or the impulses found in a recording, go by.') with gr.Row(): b_paper = gr.Dropdown(sizes.CHOICES, value='a3', label='Sheet size', info='Paper, or a screen, phone, tablet or projector size (its SVG is then its real pixel size).') b_orient = gr.Radio(['auto', 'portrait', 'landscape'], value='auto', label='Orientation') with gr.Row(): b_keyside = gr.Dropdown(['bottom', 'top', 'left', 'right'], value='bottom', label='Key position') b_rollside = gr.Dropdown(['bottom', 'top', 'left', 'right'], value='bottom', label='Piano roll position') b_grounds = gr.CheckboxGroup(GROUNDS, value=['black'], label='Grounds') with gr.Row(): b_seconds = gr.Number(value=60, label='Loop (seconds)', info='How much of the tune the sheet plays. 0: all of it.') b_start = gr.Number(value=0, label='Starting at (seconds)') b_fit = gr.Checkbox(value=True, label='Keep the whole picture', info='Off: the picture is cropped to fill.') with gr.Accordion('Globe', open=False): with gr.Row(): b_turn = gr.Slider(-180, 180, value=0, step=5, label='Turn (degrees)', info='Which side of the globe faces you.') b_tilt = gr.Slider(-90, 90, value=0, step=5, label='Tilt (degrees)') with gr.Row(): b_detail = gr.Dropdown(['quick', 'standard', 'fine'], value='standard', label='Packing detail') b_dial = gr.Slider(4, 12, value=5.5, step=0.5, label='Largest dial (degrees)') b_ling = gr.Checkbox(value=False, label='Also write the globe as a Ling program') b_key_phrase = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password', info='Empty: a fresh ML-DSA-87 key each time. Set: the same inputs always give the same sheet.') b_run = gr.Button('Make the music box', variant='primary') with gr.Column(scale=1): b_view = gr.HTML(label='Music box') b_downloads = gr.Files(label='Files') b_log = gr.Textbox(label='Result', lines=4) b_run.click(make_box, [b_files, b_midi, b_wav, b_paper, b_orient, b_keyside, b_rollside, b_choreo, b_grounds, b_layout, b_key, b_roll, b_seconds, b_start, b_fit, b_turn, b_tilt, b_detail, b_dial, b_ling, b_key_phrase], [b_view, b_downloads, b_log]) with gr.Tab('Dice and board (3D)'): gr.Markdown('The globe\'s system on other solids, and as real objects. A picture is projected straight on to a **die** (a tetrahedron first, then a cube, ' 'octahedron, dodecahedron or icosahedron), a **globe**, or a **flat board** like a Reactable table, and packed with dials and tiles that are wired ' 'into a circuit that plays a tune. You get the interactive page, and a **.glb** with every dial and tile as its own 3D object (cylinders, cones, domes, ' 'spheres, hexagonal prisms, rings, cubes, pyramids, or a different one for each key) standing on the board, with the wires as tubes: open it in Blender, ' 'Unity, Godot or any glTF viewer.') with gr.Row(): with gr.Column(scale=1): d_files = gr.Files(label='A picture', file_count='multiple', type='filepath', file_types=['image']) d_shape = gr.Dropdown(SHAPES, value=SHAPES[0], label='Shape', info='Dice are packed face by face; the board is packed flat.') with gr.Row(): d_midi = gr.File(label='MIDI (optional)', file_types=['.mid', '.midi', '.wav', '.mp3', '.ogg', '.oga', '.flac', '.m4a'], type='filepath') d_wav = gr.File(label='Recording (optional)', file_types=['.wav', '.mp3', '.ogg', '.oga', '.flac', '.m4a'], type='filepath') d_style = gr.Dropdown(gl.STYLES, value=gl.STYLES[0], label='Object shapes (3D)', info='What each dial becomes in the .glb. Tiles are always slabs.') with gr.Row(): d_board = gr.Dropdown(gl.BOARDS, value='auto', label='Base', info='auto: the die itself, a ball for the globe, a round board for the table.') d_diam = gr.Number(value=None, label='Size (metres across)', info='Empty: 0.9 for the board, 0.3 for the rest.') with gr.Row(): d_dial = gr.Slider(2, 14, value=7, step=0.5, label='Largest dial (degrees)', info='Smaller: more, finer pieces.') d_coding = gr.Dropdown(['perceptual', 'cryptographic', 'tonal', 'appearance', 'spectrum', 'spectrum-reversed'], value='perceptual', label='Colour coding') d_wires = gr.Checkbox(value=True, label='Wires in the .glb', info='Thin tubes in each key\'s colour.') d_key = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password', info='Empty: a fresh key each time. Set: the same inputs give the same die.') d_run = gr.Button('Make it', variant='primary') with gr.Column(scale=1): d_view = gr.HTML(label='Circuit') d_model = gr.Model3D(label='The 3D objects (.glb)', clear_color=(0.08, 0.08, 0.1, 1.0)) d_files_out = gr.Files(label='Files') d_log = gr.Textbox(label='Result', lines=4) d_run.click(make_dice, [d_files, d_midi, d_wav, d_shape, d_style, d_board, d_diam, d_dial, d_coding, d_key, d_wires], [d_view, d_model, d_files_out, d_log]) with gr.Tab('Lamp (3D print)'): gr.Markdown('A **printable light sculpture** of the picture. Each piece becomes a mirrored cell (the wireframe extruded; line the walls with mirror film), ' 'and each cell is fed by its own **filament** (clear 1.75 mm filament or optical fibre) running through a printed **conduit** from an LED in a housing at the side. ' 'So about a hundred cells need only one row of small RGB LEDs on a thin PCB, and the pattern the display plays (the choreography) comes as frames for them. ' 'You get STL files for the block, the LED bays and their lids and a diffuser sheet, the LED map with filament lengths, the animation, and build notes, plus the picture\'s SVG line work ' 'laid over the lit cells (switch it on and off in the view) and layered **UV-print SVGs** (white, colour, linework, cut) at the panel\'s true size. ' 'A panel can be a rectangle or circle, or one face of a die (triangle, square, pentagon): print one panel for each face.') with gr.Row(): with gr.Column(scale=1): l_files = gr.Files(label='A picture', file_count='multiple', type='filepath', file_types=['image']) l_midi = gr.File(label='MIDI for the animation (optional)', file_types=['.mid', '.midi'], type='filepath') l_shape = gr.Dropdown(ll.SHAPES, value=ll.SHAPES[0], label='Panel shape') with gr.Row(): l_w = gr.Number(value=220, label='Width (mm)'); l_h = gr.Number(value=220, label='Height (mm)') with gr.Row(): l_leds = gr.Slider(8, 160, value=80, step=1, label='Cells and LEDs (about)', info='More cells: smaller cells and closer lanes.') l_sides = gr.Radio(['Both sides', 'Right side only'], value='Both sides', label='LED bays') with gr.Row(): l_fil = gr.Number(value=1.75, label='Filament diameter (mm)', info='1.75 clear filament, or 1 to 3 mm optical fibre.') l_pitch = gr.Number(value=3.0, label='LED pitch (mm)', info='LED package width + 0.8: 3.0 for 2020, 4.5 for 3535.') with gr.Row(): l_bend = gr.Number(value=12, label='Bend radius (mm)', info='The turn up into each cell. Stiff filament wants 12 or more.') l_wall = gr.Number(value=1.2, label='Cell wall (mm)'); l_ch = gr.Number(value=14, label='Cell depth (mm)') with gr.Row(): l_sec = gr.Number(value=30, label='Animation length (s)') l_coding = gr.Dropdown(['perceptual', 'cryptographic', 'tonal', 'appearance', 'spectrum', 'spectrum-reversed'], value='perceptual', label='Colour coding') l_key = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password') l_box = gr.Checkbox(value=False, label='Also make the music box', info='A printable enclosure (a tray for the electronics and a speaker, and a bezel), a solid colour plug for every cell, and KiCad files for the controller board ' '(a Raspberry Pi Pico with an I2S amp, microSD and MIDI in) and for the LED strips.') with gr.Row(): l_spk = gr.Number(value=40, label='Speaker diameter (mm)'); l_depth = gr.Number(value=28, label='Electronics depth (mm)') l_run = gr.Button('Make the lamp', variant='primary') with gr.Column(scale=1): l_view = gr.HTML(label='The lightbox from the front, with the SVG over it') l_model = gr.Model3D(label='Preview (cells lit in their key colours)', clear_color=(0.08, 0.08, 0.1, 1.0)) l_files_out = gr.Files(label='Files: the zip has everything') l_log = gr.Textbox(label='Result', lines=6) l_run.click(make_lamp, [l_files, l_midi, l_shape, l_w, l_h, l_leds, l_sides, l_fil, l_pitch, l_bend, l_wall, l_ch, l_sec, l_coding, l_key, l_box, l_spk, l_depth], [l_view, l_model, l_files_out, l_log]) with gr.Tab('SVG to Ling'): gr.Markdown('A map made in the "Ling programs" tab gives its program back exactly, after its signature is checked. Any other SVG becomes a ' 'Ling program that redraws it with `svg_begin`, `svg_polyline`, `svg_line`, `svg_rect`, `svg_circle` and `svg_end`: one function ' 'per colour and line weight, called from `bind start`. Filled shapes are outlined and crosshatched; text and images are not drawn.') with gr.Row(): with gr.Column(scale=1): s_files = gr.Files(label='SVG files', file_count='multiple', file_types=['.svg'], type='filepath') s_mode = gr.Radio(['Automatic', 'Program stored in the map', 'Program that redraws the SVG'], value='Automatic', label='What to make') s_run = gr.Button('Make the Ling program', variant='primary') with gr.Column(scale=1): s_downloads = gr.Files(label='Ling files') s_log = gr.Textbox(label='Result', lines=4) s_code = gr.Code(label='First file', language=None, lines=24) s_run.click(svg_to_ling, [s_files, s_mode], [s_downloads, s_log, s_code]) if __name__ == '__main__': demo.queue().launch()