diff --git a/tools/INDEX.md b/tools/INDEX.md index 5562e83..f4a5a3b 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -17,3 +17,4 @@ gets a Soul pass before merge. | `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only \|r[,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | | `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers | | `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as ` C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 ` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) | +| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py new file mode 100755 index 0000000..38c55cb --- /dev/null +++ b/tools/bloom-sheet/bloom_sheet.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""Turn a markdown document into a one-page neon bloom sheet. + + bloom_sheet.py notes.md -o sheet.png + +The first top-level heading is the page title and the paragraph right after it +the subtitle; every other heading becomes a card, sized by how much it holds. +The plant grows through whatever the cards leave, and --visitor perches your +SVGs in the garden. Output is PDF or PNG, by the extension of -o. Takes about +two minutes: the growth, then LuaLaTeX twice. + +Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox, +pdftoppm for PNG, rsvg-convert for visitors, and the Ubuntu, IBM Plex Mono and +Montserrat fonts. +Everything from the document is escaped, and LaTeX runs with no shell escape. +""" +import argparse, json, os, random, re, shutil, subprocess, sys, tempfile, zlib +from pathlib import Path + +from markdown_it import MarkdownIt + +HERE = Path(__file__).resolve().parent +W, H = 210.0, 297.0 +MARGIN, GUTTER = 5.0, 7.0 +TITLE_H = 21.2 +COLOURS = ["neonorange", "neonsky", "neonviolet"] +# Files first, then family names: a font's packaging differs between machines. +FONTS = r""" +\IfFontExistsTF{Ubuntu-L.ttf}{% + \setmainfont{Ubuntu-L.ttf}[BoldFont=Ubuntu-B.ttf] + \newfontfamily\speakfont{Ubuntu-R.ttf}[BoldFont=Ubuntu-B.ttf] +}{% + \setmainfont{Ubuntu}[UprightFont=* Light, BoldFont=* Bold] + \newfontfamily\speakfont{Ubuntu}[BoldFont=* Bold] +} +\IfFontExistsTF{IBMPlexMono-Regular.otf}{% + \newfontfamily\numfont{IBMPlexMono-Regular.otf} + \newfontfamily\labelfont{IBMPlexMono-Medium.otf}[LetterSpace=14] +}{% + \newfontfamily\numfont{IBM Plex Mono} + \newfontfamily\labelfont{IBM Plex Mono}[UprightFont=* Medium, LetterSpace=14] +} +\IfFontExistsTF{Montserrat-Thin.ttf}{\newfontfamily\thinfont{Montserrat-Thin.ttf}}{\newfontfamily\thinfont{Montserrat}[UprightFont=* Thin]} +""" + +TEX_SPECIAL = {"\\": r"\textbackslash{}", "{": r"\{", "}": r"\}", "$": r"\$", "&": r"\&", "#": r"\#", + "%": r"\%", "_": r"\_", "^": r"\textasciicircum{}", "~": r"\textasciitilde{}"} + + +def esc(text): + return "".join(TEX_SPECIAL.get(c, c) for c in text) + + +def esc_code(text): + """Escaped, with break points: code and URLs have no spaces to wrap at.""" + return re.sub(r"(/|\.)", r"\1\\allowbreak{}", esc(text)) + + +def inline(token): + """Render one inline token's children as LaTeX; returns (tex, plain text).""" + tex, plain, stack = [], [], [] + link = None + for t in token.children or []: + if t.type == "text": + tex.append(esc(t.content)); plain.append(t.content) + elif t.type == "code_inline": + tex.append(r"\code{" + esc_code(t.content) + "}"); plain.append(t.content) + elif t.type in ("softbreak", "hardbreak"): + tex.append(" " if t.type == "softbreak" else r"\newline{}"); plain.append(" ") + elif t.type in ("strong_open", "em_open"): + tex.append(r"\strong{" if t.type == "strong_open" else r"\soft{") + elif t.type in ("strong_close", "em_close"): + tex.append("}") + elif t.type == "link_open": + link = (t.attrGet("href") or "", len(plain)) + elif t.type == "link_close" and link: + href, start = link + if href and href != "".join(plain[start:]): + tex.append(r" \faint{" + esc_code(href) + "}") + link = None + elif t.type == "image": + alt = t.content or "image" + tex.append(r"\faint{[" + esc(alt) + "]}"); plain.append(alt) + return "".join(tex), "".join(plain) + + +class Card: + def __init__(self, head=None, level=7): + self.head, self.level, self.parts, self.chars = head, level, [], len(head or "") + + def add(self, tex, chars): + self.parts.append(tex) + self.chars += chars + 30 # each block costs a line's start and some space + + def tex(self): + return (r"\head{" + esc(self.head) + "}\n" if self.head else "") + "\n".join(self.parts) + + +def table(tokens, i): + """A pipe table as a tabular of equal ragged columns. Returns (tex, chars, next i).""" + rows, row, is_head = [], [], False + while tokens[i].type != "table_close": + t = tokens[i] + if t.type == "tr_open": row = [] + elif t.type == "th_open": is_head = True + elif t.type == "td_open": is_head = False + elif t.type == "inline": + tex, plain = inline(t) + row.append((r"\strong{" + tex + "}" if is_head else tex, plain)) + elif t.type == "tr_close": rows.append(row) + i += 1 + n = max(len(r) for r in rows) + col = r">{\cell}p{\dimexpr(\linewidth - %d\tabcolsep)/%d\relax}" % (2 * (n - 1), n) + body = " \\\\\n".join(" & ".join(c[0] for c in r + [("", "")] * (n - len(r))) for r in rows) + chars = sum(max(len(c[1]) for c in r) * n + 10 for r in rows) + return r"\par\begin{tabular}{@{}" + "".join([col] * n) + "@{}}\n" + body + " \\\\\n\\end{tabular}\\par", chars, i + + +def parse(text, card_level): + """Returns (title, subtitle, cards).""" + tokens = MarkdownIt("commonmark").enable("table").enable("strikethrough").parse(text) + title, subtitle, cards = None, None, [] + card = None + depth, ordered, quote = 0, [], 0 + i = 0 + while i < len(tokens): + t = tokens[i] + if t.type == "heading_open": + level = int(t.tag[1]) + _, plain = inline(tokens[i + 1]) + if level == 1 and title is None and not cards and card is None: + title = plain + nxt = tokens[i + 3] if i + 3 < len(tokens) else None + if nxt is not None and nxt.type == "paragraph_open": + subtitle = inline(tokens[i + 4])[1] + i += 3 + elif level <= card_level: + # a heading with nothing of its own is its first child's section name + if card is not None and card.head and not card.parts and level > card.level: + plain = f"{card.head} · {plain}" + cards.pop() + card = Card(plain, level); cards.append(card) + else: + if card is None: + card = Card(); cards.append(card) + card.add(r"\sub{" + esc(plain) + "}", len(plain)) + i += 3 + continue + if card is None and t.type not in ("hr",): + card = Card(); cards.append(card) + if t.type == "inline" and tokens[i - 1].type == "paragraph_open": + tex, plain = inline(t) + if depth: + marker = esc(f"{ordered[-1]}.") if ordered[-1] else "•" + if tokens[i - 2].type == "list_item_open": + card.add(rf"\li{{{depth}}}{{{marker}}}{{{tex}}}", len(plain)) + if ordered[-1]: ordered[-1] += 1 + else: + card.add(rf"\li{{{depth}}}{{}}{{{tex}}}", len(plain)) + elif quote: + card.add(rf"\quoted{{{tex}}}", len(plain)) + else: + card.add(tex + r"\par", len(plain)) + elif t.type == "bullet_list_open": + depth += 1; ordered.append(0) + elif t.type == "ordered_list_open": + depth += 1; ordered.append(int(t.attrGet("start") or 1)) + elif t.type in ("bullet_list_close", "ordered_list_close"): + depth -= 1; ordered.pop() + elif t.type == "blockquote_open": + quote += 1 + elif t.type == "blockquote_close": + quote -= 1 + elif t.type in ("fence", "code_block"): + for line in t.content.rstrip("\n").split("\n"): + # a kept space is a real glue item, so indents survive in the fit box + card.add(r"\codeline{" + esc_code(line).replace(" ", r"\ ") + r"\strut}", len(line) + 30) + elif t.type == "hr" and card is not None: + card.add(r"\hairline", 10) + elif t.type == "table_open": + tex, chars, i = table(tokens, i) + card.add(tex, chars) + i += 1 + return title, subtitle, [c for c in cards if c.head or c.parts] + + +def layout(cards, box, rng, visitors): + """Cut box into one rectangle per card, in reading order, each about the + size its text needs at 9.5 pt. Whatever that leaves is dealt in as holes: + the flowers and visitors need somewhere to bloom, and a short document gets + more garden. Returns [(card index, x, y, w, h)].""" + weights = [(k, 900 + 8 * c.chars) for k, c in enumerate(cards)] + need = sum(w for _, w in weights) + room = box[2] * box[3] - GUTTER * (box[2] + box[3]) * len(cards) ** 0.5 + spare = max(room - need * 1.15, 0.05 * need) + holes = max(visitors, 2 + int(4 * spare / room)) + for _ in range(holes): + # not first: the reader's eye starts at the top left + weights.insert(rng.randint(1, len(weights)), (None, max(spare / holes, 0.03 * need))) + + out = [] + def cut(items, x, y, w, h): + if len(items) == 1: + if items[0][0] is not None: + out.append((items[0][0], round(x, 1), round(y, 1), round(w, 1), round(h, 1))) + return + whole = sum(v for _, v in items) + # the split nearest even, so neither side ends up a sliver + best = min(range(1, len(items)), key=lambda n: abs(sum(v for _, v in items[:n]) - whole / 2)) + s = sum(v for _, v in items[:best]) / whole + if w > h * 0.85: # side by side, first on the left + a = (w - GUTTER) * s + cut(items[:best], x, y, a, h) + cut(items[best:], x + a + GUTTER, y, w - a - GUTTER, h) + else: # stacked, first on top + a = (h - GUTTER) * s + cut(items[:best], x, y + h - a, w, a) + cut(items[best:], x, y, w, h - a - GUTTER) + cut(weights, *box) + return sorted(out) + + +def macro(k): + name = "" + k += 1 + while k: + k, r = divmod(k - 1, 26) + name = chr(65 + r) + name + return r"\content" + name + + +def build(args, source): + title, subtitle, cards = parse(source, args.level) + if args.title is not None: title = args.title + if args.subtitle is not None: subtitle = args.subtitle + if not cards: + sys.exit("bloom-sheet: nothing to put on the page: the document has no headings or text") + seed = args.seed if args.seed is not None else zlib.crc32(source.encode("utf8")) % 100000 + rng = random.Random(seed) + + top = H - MARGIN + title_tex, title_box, swash = "", None, None + if title: + # Montserrat Thin runs about 0.56 em a letter; 31 pt is the sheet's title size + size = min(31.0, 31.0 * 192 / (0.56 * 10.9 * max(len(title), 1))) + width = min(196.0, 0.56 * size * 0.3528 * len(title)) + title_tex = (r"\node[anchor=south west, inner sep=0pt, text=heading] at (7,278)" "\n" + rf" {{\thinfont\fontsize{{{size:.1f}}}{{{size:.1f}}}\selectfont {esc(title)}}};" "\n") + if subtitle: + title_tex += (r"\node[anchor=north west, inner sep=0pt, text=muted, text width=196mm] at (7.4,273.2)" "\n" + rf" {{\labelfont\fontsize{{5.6}}{{6}}\selectfont {esc(subtitle.upper())}}};" "\n") + title_box = [5, H - MARGIN - TITLE_H, 200, TITLE_H] + swash = [7, 276.4, 7 + max(30.0, width), 277.2] + top = title_box[1] - GUTTER + + rects = layout(cards, (MARGIN, MARGIN, W - 2 * MARGIN, top - MARGIN), rng, len(args.visitor)) + panels, contents, last = [], [], None + for k, x, y, w, h in rects: + colour = rng.choice([c for c in COLOURS if c != last]); last = colour + panels.append([macro(k)[8:], x, y, w, h, colour, macro(k)]) + contents.append(rf"\newcommand{{{macro(k)}}}{{%" + "\n" + cards[k].tex() + "}") + # 5 pt type is about 1.7 mm of line and 0.9 mm of letter + room = (w - 6.2) * (h - 5.6) / (1.8 * 0.95) + if cards[k].chars > room: + print(f"bloom-sheet: card '{cards[k].head or 'intro'}' will come out under 5 pt: " + "shorten it or split the sheet", file=sys.stderr) + + with tempfile.TemporaryDirectory(prefix="bloom-sheet-") as tmp: + d = Path(tmp) + names = [] + for n, svg in enumerate(args.visitor): + names.append(f"visitor{n}.pdf") + run(["rsvg-convert", "-f", "pdf", "-o", str(d / names[-1]), str(Path(svg).resolve())], d, args.verbose) + (d / "spec.json").write_text(json.dumps( + {"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": names}), encoding="utf8") + page = (HERE / "page.tex").read_text(encoding="utf8") + for mark, value in (("@@FONTS@@", FONTS), ("@@CONTENTS@@", "\n".join(contents)), ("@@TITLE@@", title_tex)): + page = page.replace(mark, value) + (d / "sheet.tex").write_text(page, encoding="utf8") + grown = run([sys.executable, str(HERE / "grow.py"), "spec.json", "layer.tex"], d, args.verbose) + for line in grown.splitlines(): + if line.startswith("left out: visitor"): + print(f"bloom-sheet: {line}", file=sys.stderr) + env = dict(os.environ) + # the font database takes a minute to build: keep it between sheets + cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "bloom-sheet" + try: + cache.mkdir(parents=True, exist_ok=True) + except OSError: # a sandbox with no home: slower, not broken + cache = d / "cache" + env.setdefault("TEXMFVAR", str(cache)) + latex = ["lualatex", "--no-shell-escape", "--interaction=nonstopmode", "--halt-on-error", "sheet.tex"] + for _ in range(2): # remember picture lands right only on the second pass + run(latex, d, args.verbose, env, log="sheet.log") + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + if out.suffix.lower() == ".png": + run(["pdftoppm", "-r", str(args.dpi), "-png", "-singlefile", "sheet.pdf", "sheet"], d, args.verbose) + shutil.copyfile(d / "sheet.png", out) + else: + shutil.copyfile(d / "sheet.pdf", out) + print(f"{out} ({len(cards)} cards, seed {seed})") + + +def run(cmd, cwd, verbose, env=None, log=None): + try: + p = subprocess.run(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors="replace", timeout=600) + except FileNotFoundError: + sys.exit(f"bloom-sheet: {cmd[0]} is not installed") + except subprocess.TimeoutExpired: + sys.exit(f"bloom-sheet: {cmd[0]} ran past 10 minutes") + if verbose: print(p.stdout) + if p.returncode: + tail = p.stdout + if log and (Path(cwd) / log).exists(): + text = (Path(cwd) / log).read_text(encoding="utf8", errors="replace") + errors = [i for i, line in enumerate(text.splitlines()) if line.startswith("!")] + tail = "\n".join(text.splitlines()[errors[0]:errors[0] + 12]) if errors else text[-1500:] + sys.exit(f"bloom-sheet: {cmd[0]} failed:\n{tail[-1500:]}") + return p.stdout + + +def main(): + ap = argparse.ArgumentParser(prog="bloom-sheet", description="Turn a markdown document into a one-page neon bloom sheet.") + ap.add_argument("input", help="markdown file, or - for stdin") + ap.add_argument("-o", "--output", help="output .pdf or .png (default: the input's name, .pdf)") + ap.add_argument("--title", help="page title, instead of the document's first top-level heading; empty for none") + ap.add_argument("--subtitle", help="small line under the title") + ap.add_argument("--level", type=int, default=6, help="headings deeper than this are labels inside a card, not cards (default: every heading is a card)") + ap.add_argument("--visitor", action="append", default=[], metavar="SVG", help="an SVG to perch in the garden; repeat for more (about three suit a page)") + ap.add_argument("--seed", type=int, help="plant seed (default: from the document, so a sheet rebuilds the same)") + ap.add_argument("--dpi", type=int, default=200, help="PNG resolution (default 200)") + ap.add_argument("-v", "--verbose", action="store_true") + args = ap.parse_args() + source = sys.stdin.read() if args.input == "-" else Path(args.input).read_text(encoding="utf8") + if not args.output: + args.output = "sheet.pdf" if args.input == "-" else str(Path(args.input).with_suffix(".pdf")) + if Path(args.output).suffix.lower() not in (".pdf", ".png"): + sys.exit("bloom-sheet: -o must end in .pdf or .png") + build(args, source) + + +if __name__ == "__main__": + main() diff --git a/tools/bloom-sheet/grow.py b/tools/bloom-sheet/grow.py new file mode 100644 index 0000000..44f645a --- /dev/null +++ b/tools/bloom-sheet/grow.py @@ -0,0 +1,612 @@ +"""Grow the ornament layer of a bloom sheet (see ../SKILL.md). + +The page owns its text; this script owns its geometry, in four stages: +1. motifs: a greedy loop borrowed from florilegium + (github.com/kevinraymond/florilegium): find the largest free disc, put a + motif in it, mark it taken, repeat. Big discs get flowers; small ones are + invisible junctions. +2. the tree: a minimum spanning tree over the motifs, so the page is one plant. +3. the vine: the tree cut into a few long paths, each one Catmull-Rom spline + carrying a sine wave sized to the room around it; branches leave their + parent tangentially. +4. scrolls: a rinceau. At every crest a scroll leaves the vine and winds into + a logarithmic spiral (exponentially rising curvature); scrolls shed smaller + counter-winding scrolls, three generations deep, each kept only if it fits. +5. budding: the largest free gap left pulls a branch out of the nearest stroke + that reaches it smoothly; the branch winds into a spiral inside the gap and + can itself be budded from. Repeats down to sub-millimetre gaps. + +Every line is a weighted stroke: a closed outline offset from a centreline by +half a width that varies along it, filled rather than stroked. That is +florilegium's `ribbon` (ink.lua, MIT, Kevin Raymond), ported to Python. + +Run: python grow.py spec.json layer.tex +spec.json: {"seed": 11, "panels": [[name, x, y, w, h, colour, content macro], ...], + "title_box": [x, y, w, h] or null, "swash": [x0, y0, x1, y1] or null, + "visitors": [file, ...]} +All lengths are mm from the page's bottom left. A visitor is florilegium's idea +with the caller's drawing: it takes the largest disc free before the plant +grows, and the plant grows around it. +""" +import json, math, random, sys +from pathlib import Path +import numpy as np + +spec = json.loads(Path(sys.argv[1]).read_text(encoding="utf8")) +SEED = spec["seed"] +rng = random.Random(SEED) +W, H = 210.0, 297.0 +GAP = 0.8 # clearance between anything drawn and a panel edge +MARGIN = 1.8 # ornaments keep this far from the paper edge; wide enough that + # the vine can pass a full-width panel through the side margins + +# ---------------------------------------------------------------- panels +# name, x, y, w, h (mm from bottom left), colour, content macro +PANELS = [tuple(p) for p in spec["panels"]] +# Gutters are 7 mm: narrower, and a scroll has no room to wind. Leave a hole or two +# open on purpose: the flowers need somewhere to bloom. +TITLE_BOX = spec["title_box"] # the title is text, not a panel, but it is not free space + +# Corner radii (tl, tr, br, bl). Text starts top left and may reach bottom +# left, so those stay tight; the right-hand corners open into pockets. +def radii(w, h): + cap = min(w, h) / 3 + return tuple(min(cap, rng.uniform(lo, hi)) for lo, hi in ((2, 4), (3, 9), (3, 10.5), (2, 7))) + +panels = [(n, x, y, w, h, c, m, radii(w, h)) for n, x, y, w, h, c, m in PANELS] + +# ---------------------------------------------------------------- distance field +STEP = 0.4 +gx = np.arange(0, W + STEP / 2, STEP) +gy = np.arange(0, H + STEP / 2, STEP) +X, Y = np.meshgrid(gx, gy) + +def sd_roundbox(px, py, x, y, w, h, r): + """Signed distance to a box with one radius per corner (tl, tr, br, bl). + After Inigo Quilez's sdRoundBox, with y pointing up.""" + tl, tr, br, bl = r + cx, cy = x + w / 2, y + h / 2 + qx, qy = px - cx, py - cy + rr = np.where(qx > 0, np.where(qy > 0, tr, br), np.where(qy > 0, tl, bl)) + ax = np.abs(qx) - w / 2 + rr + ay = np.abs(qy) - h / 2 + rr + return np.minimum(np.maximum(ax, ay), 0) + np.hypot(np.maximum(ax, 0), np.maximum(ay, 0)) - rr + +wall = np.minimum.reduce([X - MARGIN, W - MARGIN - X, Y - MARGIN, H - MARGIN - Y]) +solid = np.full_like(X, np.inf) +for n, x, y, w, h, c, m, r in panels: + solid = np.minimum(solid, sd_roundbox(X, Y, x, y, w, h, r)) +if TITLE_BOX: + tx, ty, tw, th = TITLE_BOX + solid = np.minimum(solid, sd_roundbox(X, Y, tx, ty, tw, th, (0, 0, 0, 0))) +clear = np.minimum(solid - GAP, wall) # room to draw, ignoring ornaments +free = clear.copy() # room to draw, ornaments included + +def clear_at(px, py): + i = int(round(py / STEP)); j = int(round(px / STEP)) + if not (0 <= i < clear.shape[0] and 0 <= j < clear.shape[1]): return -1 + return clear[i, j] + +# ---------------------------------------------------------------- weighted strokes +def normals(pts): + out = [] + for i in range(len(pts)): + a = pts[max(i - 1, 0)]; b = pts[min(i + 1, len(pts) - 1)] + dx, dy = b[0] - a[0], b[1] - a[1] + L = math.hypot(dx, dy) or 1 + out.append((-dy / L, dx / L)) + return out + +def ribbon(pts, width): + """Closed outline of a stroke along pts whose full width at fraction u of + its length is width(u).""" + nrm = normals(pts) + s = [0.0] + for i in range(1, len(pts)): + s.append(s[-1] + math.dist(pts[i], pts[i - 1])) + L = s[-1] or 1 + left, right = [], [] + for p, n, si in zip(pts, nrm, s): + hw = width(si / L) / 2 + left.append((p[0] + n[0] * hw, p[1] + n[1] * hw)) + right.append((p[0] - n[0] * hw, p[1] - n[1] * hw)) + return left + right[::-1] + +def arc_pts(p, q, bend, n=24): + """Circular arc from p to q; bend is the sagitta as a fraction of the chord.""" + (x0, y0), (x1, y1) = p, q + mx, my = (x0 + x1) / 2, (y0 + y1) / 2 + dx, dy = x1 - x0, y1 - y0 + L = math.hypot(dx, dy) + if abs(bend) < 1e-3 or L < 1e-6: + return [(x0 + dx * t / n, y0 + dy * t / n) for t in range(n + 1)] + s = bend * L + R = (L * L / 4 + s * s) / (2 * abs(s)) + nx, ny = -dy / L, dx / L + sign = 1 if s > 0 else -1 + cx, cy = mx - nx * sign * (R - abs(s)), my - ny * sign * (R - abs(s)) + a0 = math.atan2(y0 - cy, x0 - cx); a1 = math.atan2(y1 - cy, x1 - cx) + da = (a1 - a0 + math.pi) % (2 * math.pi) - math.pi + # take the arc on the bulge side + midang = a0 + da / 2 + if (math.cos(midang) * nx + math.sin(midang) * ny) * sign < 0: + da = da - 2 * math.pi if da > 0 else da + 2 * math.pi + return [(cx + R * math.cos(a0 + da * t / n), cy + R * math.sin(a0 + da * t / n)) for t in range(n + 1)] + +def vesica(c, ang, length, fat, n=9): + """A pointed petal or leaf: two circular arcs from c to the tip.""" + tip = (c[0] + length * math.cos(ang), c[1] + length * math.sin(ang)) + a = arc_pts(c, tip, fat, n) + b = arc_pts(tip, c, fat, n) + return a + b[1:] + +def path(poly): + return " -- ".join(f"({x:.2f},{y:.2f})" for x, y in poly) + " -- cycle" + +out = [] +def neon(colour, poly, core=0.85): + out.append(f"\\neonfill{{{colour}}}{{{int(core * 100)}}}{{{path(poly)}}}") +def neonline(colour, pts, w0, w1, core=0.9): + neon(colour, ribbon(pts, lambda u: w0 + (w1 - w0) * u), core) +def ring(colour, c, r, w): + pts = [(c[0] + r * math.cos(2 * math.pi * k / 24), c[1] + r * math.sin(2 * math.pi * k / 24)) for k in range(25)] + # a ring breathes: heavier on one side, like a pen circle + ph = rng.uniform(0, 2 * math.pi) + neon(colour, ribbon(pts, lambda u: w * (0.55 + 0.45 * math.cos(2 * math.pi * u + ph))), 0.8) +def dot(colour, c, r): + out.append(f"\\neondot{{{colour}}}{{{c[0]:.2f}}}{{{c[1]:.2f}}}{{{r:.2f}}}") + +# ---------------------------------------------------------------- motifs +PALETTE = [("neonorange", 5), ("neonsky", 3), ("neonviolet", 3), ("neonrose", 2)] +def pick(): + t = rng.uniform(0, sum(w for _, w in PALETTE)) + for c, w in PALETTE: + t -= w + if t <= 0: return c + return PALETTE[0][0] + +def rosette(c, r, col): + n = rng.choice((5, 6, 6, 8)) + ph = rng.uniform(0, math.pi) + for k in range(n): + a = ph + 2 * math.pi * k / n + petal = vesica(c, a, r, 0.24 if n < 8 else 0.19) + out.append(f"\\petalwash{{{col}}}{{{path(petal)}}}") + # the petal's outline as one weighted stroke: fat at the belly, thin at the tip + neonline_closed(col, petal, 0.34 * min(1, r / 5) + 0.12) + ring(pick(), c, r * 0.3, 0.32) + dot("neonwhite", c, max(0.35, r * 0.09)) + +def neonline_closed(col, poly, w): + neon(col, ribbon(poly, lambda u: w * (0.25 + 0.75 * math.sin(math.pi * ((2 * u) % 1)) ** 2)), 0.9) + +def curl(c, r, col): + turns = rng.uniform(1.2, 1.9) + a0 = rng.uniform(0, 2 * math.pi) + sgn = rng.choice((-1, 1)) + k = math.log(9) / (turns * 2 * math.pi) # r falls to a ninth over the turns + n = int(22 * turns) + pts = [] + for i in range(n + 1): + t = i / n * turns * 2 * math.pi + rad = r * math.exp(-k * t) + pts.append((c[0] + rad * math.cos(a0 + sgn * t), c[1] + rad * math.sin(a0 + sgn * t))) + neonline(col, pts, 0.55 * min(1, r / 3) + 0.15, 0.08) + dot(col, pts[-1], max(0.28, r * 0.09)) + +def sparkle(c, r, col): + ph = rng.uniform(0, math.pi / 4) + for k in range(4): + a = ph + k * math.pi / 2 + L = r if k % 2 == 0 else r * 0.6 + tip = (c[0] + L * math.cos(a), c[1] + L * math.sin(a)) + neon(col, ribbon([c, ((c[0] + tip[0]) / 2, (c[1] + tip[1]) / 2), tip], lambda u: 0.5 * min(1, r / 2.5) * (1 - u) ** 1.4 + 0.02)) + dot("neonwhite", c, max(0.22, r * 0.1)) + +def place(c, r): + """Choose a motif for a disc of radius r; return (colour, drawn radius, + draw). Drawing waits until the vine is known, so a motif the vine could + not reach is never drawn. Discs too small for a flower are junctions of + the vine and draw nothing: the scrolls carry the small-scale detail.""" + col = pick() + if r >= 3.0: + return col, r, lambda: rosette(c, r, col) + return col, 0.0, lambda: None + +# ---------------------------------------------------------------- visitors +visitors = [] # (cx, cy, r, file) +for vf in spec.get("visitors", []): + i, j = np.unravel_index(np.argmax(free), free.shape) + r = min(float(free[i, j]), 15.0) + if r < 4.0: + print(f"left out: visitor {vf}: no free disc wider than {2 * r:.0f} mm") + continue + c = (float(gx[j]), float(gy[i])) + visitors.append((c[0], c[1], r, vf)) + np.minimum(free, np.hypot(X - c[0], Y - c[1]) - r - 1.0, out=free) + +# ---------------------------------------------------------------- 1. motifs +nodes = [] # (cx, cy, drawn radius, colour) +draws = [] +RMAX = 8.5 +while True: + i, j = np.unravel_index(np.argmax(free), free.shape) + r = float(free[i, j]) + if r < 1.0: break + r = min(r, RMAX) + c = (float(gx[j]), float(gy[i])) + col, rd, draw = place(c, r * 0.92) # drawn a hair smaller than its disc + draws.append(draw) + # flowers keep a stem's length apart, so the vine between them shows + pad = 1.8 if rd >= 3.0 else 0.5 + np.minimum(free, np.hypot(X - c[0], Y - c[1]) - r - pad, out=free) + nodes.append((c[0], c[1], rd, col)) + +# ---------------------------------------------------------------- 2. the tree +# Kruskal's minimum spanning tree over every pair of motifs a clear arc can +# join, shortest gap first. A tree, so the page is one plant and no gutter +# holds two parallel stems. It fixes topology only; the vine is drawn in 3. +parent = list(range(len(nodes))) +def root(a): + while parent[a] != a: + parent[a] = parent[parent[a]]; a = parent[a] + return a + +cands = [] +for a in range(len(nodes)): + ax, ay, ar, _ = nodes[a] + for b in range(a + 1, len(nodes)): + bx, by, br, _ = nodes[b] + gap = math.hypot(bx - ax, by - ay) - ar - br + if 0.4 <= gap <= 24: cands.append((gap, a, b)) +cands.sort() +edges = [] +for gap, a, b in cands: + if root(a) == root(b): continue + ax, ay, ar, _ = nodes[a]; bx, by, br, _ = nodes[b] + ang = math.atan2(by - ay, bx - ax) + p0 = (ax + ar * math.cos(ang), ay + ar * math.sin(ang)) + p1 = (bx - br * math.cos(ang), by - br * math.sin(ang)) + if any(all(clear_at(*q) > 0.4 for q in arc_pts(p0, p1, bend, 12)) for bend in (0.0, 0.1, -0.1)): + edges.append((a, b)); parent[root(a)] = root(b) + +# One plant: whatever the tree could not reach is left out, not drawn alone. +from collections import Counter +sizes = Counter(root(k) for k in range(len(nodes))) +main = sizes.most_common(1)[0][0] +for g, n in sizes.most_common()[1:]: + print("left out:", n, "motif(s) near", [(round(nodes[k][0]), round(nodes[k][1])) for k in range(len(nodes)) if root(k) == g][:3]) +keep = [root(k) == main for k in range(len(nodes))] +for k in range(len(nodes)): + if keep[k]: draws[k]() +adj = {k: [] for k in range(len(nodes)) if keep[k]} +for a, b in edges: + if keep[a]: adj[a].append(b); adj[b].append(a) + +# ---------------------------------------------------------------- 3. the vine +# The tree is cut into as few long paths as it allows: each walk goes on to +# the neighbour that turns least, and stops only at a leaf or a hairpin. +# Every path is one Catmull-Rom spline in one colour, so the vine is +# tangent-continuous; a branch path borrows its parent's previous node as a +# phantom control point, so it leaves the parent tangentially, like a shoot. +def turn(p, q, r): + a1 = math.atan2(q[1] - p[1], q[0] - p[0]); a2 = math.atan2(r[1] - q[1], r[0] - q[0]) + return abs((a2 - a1 + math.pi) % (2 * math.pi) - math.pi) + +P = lambda k: nodes[k][:2] +used = set() +paths = [] # (phantom node or None, [node ids]) +def walk(phantom, start, first): + seq = [start, first]; used.add(frozenset((start, first))) + while True: + cur, prev = seq[-1], seq[-2] + nxt = [n for n in adj[cur] if frozenset((cur, n)) not in used] + if not nxt: break + n = min(nxt, key=lambda n: turn(P(prev), P(cur), P(n))) + if turn(P(prev), P(cur), P(n)) > math.radians(100): break + seq.append(n); used.add(frozenset((cur, n))) + paths.append((phantom, seq)) + +leaves = [k for k in adj if len(adj[k]) == 1] +start = max(leaves, key=lambda k: nodes[k][0] - nodes[k][1]) if leaves else next(iter(adj)) +walk(None, start, adj[start][0]) +pending = True +while pending: + pending = False + for phantom_, seq in list(paths): + for idx, k in enumerate(seq): + for n in adj[k]: + if frozenset((k, n)) in used: continue + # phantom: the path neighbour of k the branch continues most smoothly from + nbrs = [seq[i] for i in (idx - 1, idx + 1) if 0 <= i < len(seq)] + ph = min(nbrs, key=lambda m: turn(P(m), P(k), P(n))) if nbrs else None + walk(ph, k, n); pending = True + +def catmull(ctrl, per): + out = [] + for i in range(1, len(ctrl) - 2): + p0, p1, p2, p3 = ctrl[i - 1], ctrl[i], ctrl[i + 1], ctrl[i + 2] + for k in range(per): + t = k / per; t2 = t * t; t3 = t2 * t + out.append(tuple(0.5 * (2 * p1[j] + (-p0[j] + p2[j]) * t + (2 * p0[j] - 5 * p1[j] + 4 * p2[j] - p3[j]) * t2 + + (-p0[j] + 3 * p1[j] - 3 * p2[j] + p3[j]) * t3) for j in (0, 1))) + out.append(ctrl[-2]) + return out + +def mirror(a, b): # a phantom beyond b, continuing a -> b straight on + return (2 * b[0] - a[0], 2 * b[1] - a[1]) + +def resample(curve, ds): + out = [curve[0]]; carry = 0.0 + for p, q in zip(curve, curve[1:]): + seg = math.dist(p, q); t = ds - carry + while t <= seg: + out.append((p[0] + (q[0] - p[0]) * t / seg, p[1] + (q[1] - p[1]) * t / seg)); t += ds + carry = seg - (t - ds) + if math.dist(out[-1], curve[-1]) > 1e-6: out.append(curve[-1]) + return out + +def heading(curve, i): + a = curve[max(i - 1, 0)]; b = curve[min(i + 1, len(curve) - 1)] + return math.atan2(b[1] - a[1], b[0] - a[0]) + +def wavy(centre, ph0): + """Lay a sine wave along a smooth centreline. Wavelength and amplitude + follow the room at each point (phase is integrated, so a changing + wavelength never kinks), and the wave fades to nothing at both ends so + paths meet their junctions exactly. Returns the curve and its crests as + (index, side), side +1 for a crest on the left of travel.""" + pts = resample(centre, 0.2) + L = 0.2 * (len(pts) - 1) + out, crests = [], [] + ph = ph0; last = math.cos(ph) + for i, p in enumerate(pts): + c = clear_at(*p) + s = 0.2 * i + fade = max(0.0, min(1.0, s / 1.6, (L - s) / 1.6)) + amp = max(0.0, min(1.4, 0.55 * (c - 0.45))) * fade + th = heading(pts, i) + off = amp * math.sin(ph) + out.append((p[0] - math.sin(th) * off, p[1] + math.cos(th) * off)) + ph += 2 * math.pi * 0.2 / max(5.0, min(11.0, 4.2 * c)) + now = math.cos(ph) + if fade > 0.6 and amp > 0.2 and (last > 0) != (now > 0): + crests.append((i, 1 if last > 0 else -1)) + last = now + return out, crests +pos = {k: P(k) for k in adj} + +room = clear + 0.5 # scrolls are hairlines: they may come closer to a panel than the vine +def mark(pts, rad): + """Take a band of radius rad around a polyline out of the free room.""" + dense = [] + for p, q in zip(pts, pts[1:]): + n = max(1, int(math.dist(p, q) / 0.3)) + dense += [(p[0] + (q[0] - p[0]) * t / n, p[1] + (q[1] - p[1]) * t / n) for t in range(n)] + dense.append(pts[-1]) + k = int(math.ceil((rad + 0.5) / STEP)) + for x, y in dense: + i, j = int(round(y / STEP)), int(round(x / STEP)) + win = (slice(max(i - k, 0), i + k + 1), slice(max(j - k, 0), j + k + 1)) + np.minimum(room[win], np.hypot(X[win] - x, Y[win] - y) - rad, out=room[win]) +def room_at(x, y): + i = int(round(y / STEP)); j = int(round(x / STEP)) + if not (0 <= i < room.shape[0] and 0 <= j < room.shape[1]): return -1 + return room[i, j] + +for vx, vy, vr, _ in visitors: + mark([(vx, vy)], vr) + +vines = [] # (pts, colour, w0, w1) +for n_path, (phantom, seq) in enumerate(paths): + pts_n = [pos[k] for k in seq] + head = pos[phantom] if phantom is not None else mirror(pts_n[1], pts_n[0]) + ctrl = [head] + pts_n + [mirror(pts_n[-2], pts_n[-1])] + curve, crests = wavy(catmull(ctrl, 8), rng.uniform(0, 2 * math.pi)) + col = nodes[seq[0]][3] if phantom is None else pick() + w0 = 0.5 if phantom is None else 0.38 + vines.append((curve, col, w0, 0.2, crests)) + +for k in adj: + if nodes[k][2] > 0: mark([P(k)], nodes[k][2] + 0.3) +for curve, col, w0, w1, crests in vines: + mark(curve, w0 / 2 + 0.25) + +# ---------------------------------------------------------------- 4. scrolls +# The fractal part, after the rinceau: a scroll leaves its parent almost +# tangentially and winds into a logarithmic spiral, because its curvature +# grows exponentially along it. Partway along, it sheds smaller scrolls that +# wind the other way, and those shed smaller ones again: nested S-curves, all +# tangent-continuous with what they grow from. Each is kept only where it fits. +scrolls = [] # (pts, colour, w0) +leaves_out = [] + +def scroll_pts(base, theta, turn_dir, L, ds=0.18): + R0 = 0.5 * L + g = math.log(10) + pts = [base]; th = theta; x, y = base + s = 0.0; heads = [theta] + while s < L: + k = math.exp(g * s / L) / R0 + th += turn_dir * k * ds + x += ds * math.cos(th); y += ds * math.sin(th) + pts.append((x, y)); heads.append(th); s += ds + return pts, heads + +def scroll(base, theta, turn_dir, L, w0, depth, col, skip=0.55): + fit = None + for scale in (1.0, 0.75, 0.55, 0.4): + Ls = L * scale + if Ls < 0.9: return False + for td in (turn_dir, -turn_dir) if depth == 3 else (turn_dir,): + pts, heads = scroll_pts(base, theta, td, Ls) + wmax = w0 / 2 + 0.08 + if all(room_at(*q) > wmax for q in pts if math.dist(q, base) > skip): + fit = (pts, heads, Ls, td); break + if fit: break + if not fit: return False + pts, heads, Ls, td = fit + width = lambda u: w0 * (1 - 0.85 * u) + 0.035 + scrolls.append((pts[::2] + [pts[-1]], col, w0)) + mark(pts[1:], w0 / 2 + 0.2) + if rng.random() < 0.35: + dot(rng.choice(("neonwhite", col)), pts[-1], 0.2) + if depth > 1: + n = len(pts) + for frac in (0.2, 0.42): + i = int(frac * n) + # peel off on the outside of the bend and wind the other way + scroll(pts[i], heads[i] - td * math.radians(18), -td, Ls * 0.5, width(i / n) * 0.85, depth - 1, col) + i = int(0.62 * n) + if rng.random() < 0.5: + la = heads[i] + td * math.radians(rng.uniform(35, 55)) + ll = min(1.6, Ls * 0.3) + lt = (pts[i][0] + ll * math.cos(la), pts[i][1] + ll * math.sin(la)) + if room_at(*lt) > 0.15 and room_at((pts[i][0] + lt[0]) / 2, (pts[i][1] + lt[1]) / 2) > 0.3: + leaves_out.append((col, vesica(pts[i], la, ll, 0.32))); mark([pts[i], lt], 0.35) + return True + +order = list(range(len(vines))) +rng.shuffle(order) +for n_path in order: + curve, col, w0, w1, crests = vines[n_path] + # Rinceau: at each crest a scroll carries straight on and winds back + # toward the stem, into the bay the wave curves around. + for i, side in crests: + th = heading(curve, i) + # a scroll the size of the bay it winds into + amp = max(0.0, min(1.4, 0.55 * (clear_at(*curve[i]) - 0.45))) + L = max(1.8, min(7.0, 1.2 + 3.4 * amp)) + # it leaves at an angle: a scroll tangent to its parent runs alongside + # it for a millimetre and reads as a thickening, not a branch + scroll(curve[i], th + side * math.radians(22), -side, L, 0.3, 3, col, skip=0.9) + # and the vine's end does not stop: it winds up into a scroll of its own + th = heading(curve, len(curve) - 1) + L = max(2.0, min(6.0, 2.0 * clear_at(*curve[-1]))) + scroll(curve[-1], th, rng.choice((-1, 1)), L, w1 + 0.05, 3, col) + +# ---------------------------------------------------------------- 5. budding +# Growing outward from the vine always leaves holes it happened to miss. +# So the gaps call the growth instead, florilegium's grown loop: take the +# largest free disc left, and bud a branch into it from the existing stroke +# that reaches it most smoothly; the branch winds up into a spiral inside the +# disc. Every branch becomes somewhere later branches can bud from, so big +# holes fill first and small ones last, as deep as the space allows. +anchors = [] # (x, y, heading, width, colour) +def add_anchors(pts, col, wfun): + n = len(pts) + for i in range(0, n, 2): + anchors.append((pts[i][0], pts[i][1], heading(pts, i), wfun(i / max(n - 1, 1)), col)) +for curve, col, w0, w1, crests in vines: + add_anchors(curve, col, lambda u, w0=w0, w1=w1: w0 + (w1 - w0) * u) +for pts, col, w0 in scrolls: + add_anchors(pts, col, lambda u, w0=w0: w0 * (1 - 0.85 * u) + 0.035) + +def bezier(p0, p1, p2, p3, n): + out = [] + for k in range(n + 1): + t = k / n; a = (1 - t) ** 3; b = 3 * (1 - t) ** 2 * t; c = 3 * (1 - t) * t * t; d = t ** 3 + out.append((a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1])) + return out + +def bud_into(cx, cy, r): + """Try to grow a branch from a nearby stroke into the disc (c, r).""" + A = np.array([(a[0], a[1]) for a in anchors]) + d = np.hypot(A[:, 0] - cx, A[:, 1] - cy) + near = np.argsort(d)[:24] + tries = [] + for k in near: + ax, ay, th, aw, col = anchors[k] + to_c = math.atan2(cy - ay, cx - ax) + for flip in (0, math.pi): # a stroke can be budded from either way along it + dev = (to_c - (th + flip) + math.pi) % (2 * math.pi) - math.pi + if abs(dev) > math.radians(115): continue + tries.append((d[k] * (1 + 0.9 * abs(dev) / math.pi), k, th + flip, dev)) + tries.sort() + for score, k, th, dev in tries[:16]: + ax, ay, _, aw, col = anchors[k] + # leave the parent at no more than ~50 degrees, toward the disc + t0 = th + max(-1, min(1, dev / math.radians(50))) * math.radians(min(50, abs(math.degrees(dev)))) + w = max(0.06, min(aw * 0.8, 0.1 + 0.11 * r)) + rho0 = 0.72 * r + u = math.atan2(cy - ay, cx - ax) + for sense in (1 if dev > 0 else -1, -1 if dev > 0 else 1): + phi0 = u - sense * math.pi / 2 + pe = (cx + rho0 * math.cos(phi0), cy + rho0 * math.sin(phi0)) + L = math.dist((ax, ay), pe) + # a bud reaches its gap from close by; a long stalk reads as a stray wire + if L < 0.3 or L > 1.0 + 2.5 * r: continue + kk = L / 3 + stalk = bezier((ax, ay), (ax + kk * math.cos(t0), ay + kk * math.sin(t0)), + (pe[0] - kk * math.cos(u), pe[1] - kk * math.sin(u)), pe, max(4, int(L / 0.25))) + spiral = [] + for i in range(1, 25): + t = i / 24 + ph = phi0 + sense * 2.3 * math.pi * t + rho = rho0 * math.exp(-math.log(7) * t) + spiral.append((cx + rho * math.cos(ph), cy + rho * math.sin(ph))) + pts = stalk + spiral + if all(room_at(*q) > w / 2 + 0.04 for q in pts if math.dist(q, (ax, ay)) > max(0.45, aw)): + return pts, col, w + return None + +buds = 0 +while True: + i, j = np.unravel_index(np.argmax(room), room.shape) + r = float(room[i, j]) + if r < 0.42: break + cx, cy = float(gx[j]), float(gy[i]) + got = bud_into(cx, cy, min(r, 6.0)) + if got is None: + # nothing can reach this hole: close its middle so the loop moves on + mark([(cx, cy)], min(r, 6.0) * 0.35) + continue + pts, col, w = got + scrolls.append((pts, col, w)) + mark(pts[1:], w / 2 + 0.16) + add_anchors(pts, col, lambda u, w=w: w * (1 - 0.85 * u) + 0.035) + if r > 0.9 and rng.random() < 0.4: + dot(rng.choice(("neonwhite", col)), pts[-1], min(0.25, 0.12 + 0.05 * r)) + buds += 1 + +# ---------------------------------------------------------------- strokes out +stem_out = [] +for curve, col, w0, w1, crests in vines: + stem_out.append((col, ribbon(curve[::2] + [curve[-1]], lambda u, w0=w0, w1=w1: w0 + (w1 - w0) * u))) +for pts, col, w0 in scrolls: + stem_out.append((col, ribbon(pts, lambda u, w0=w0: w0 * (1 - 0.85 * u) + 0.035))) +stem_out += leaves_out +nodes = [n for n, kk in zip(nodes, keep) if kk] + +# ---------------------------------------------------------------- title swash +# One long weighted stroke under the title, ending in a curl. +swash_out = [] +if spec.get("swash"): + sx0, sy0, sx1, sy1 = spec["swash"] + swash = arc_pts((sx0, sy0), (sx1, sy1), -0.012, 60) + swash_out = [ribbon(swash, lambda u: 0.15 + 0.85 * math.sin(math.pi * u ** 0.8) ** 1.5)] + +# ---------------------------------------------------------------- write +lines = [f"% generated by {Path(__file__).name}, seed {SEED}. Do not edit; run the script."] +lines.append("\\begin{scope}% stems and leaves") +for col, poly in stem_out: + lines.append(f"\\neonfill{{{col}}}{{75}}{{{path(poly)}}}") +lines.append("\\end{scope}") +lines.append("\\begin{scope}% panel frames") +for n, x, y, w, h, c, m, (tl, tr, br, bl) in panels: + p = (f"({x + bl:.2f},{y:.2f}) -- ({x + w - br:.2f},{y:.2f}) arc[start angle=-90, end angle=0, radius={br:.2f}] " + f"-- ({x + w:.2f},{y + h - tr:.2f}) arc[start angle=0, end angle=90, radius={tr:.2f}] " + f"-- ({x + tl:.2f},{y + h:.2f}) arc[start angle=90, end angle=180, radius={tl:.2f}] " + f"-- ({x:.2f},{y + bl:.2f}) arc[start angle=180, end angle=270, radius={bl:.2f}] -- cycle") + lines.append(f"\\neonframe{{{c}}}{{{p}}}") +lines.append("\\end{scope}") +for poly in swash_out: + lines.append(f"\\neonfill{{neonorange}}{{90}}{{{path(poly)}}}") +lines.extend(out) +for vx, vy, vr, vf in visitors: + lines.append(f"\\visitor{{{vx:.2f}}}{{{vy:.2f}}}{{{2 * vr * 0.95:.2f}}}{{{rng.uniform(-18, 18):.1f}}}{{{vf}}}") +for n, x, y, w, h, c, m, r in panels: + lines.append(f"\\panel{{{x}}}{{{y}}}{{{w}}}{{{h}}}{{{c}}}{{{m}}}") +dst = Path(sys.argv[2]) +dst.write_text("\n".join(lines) + "\n", encoding="utf8") +print(f"seed {SEED}: {len(nodes)} motifs, {len(paths)} vine paths, {len(scrolls)} scrolls ({buds} budded) -> {dst.name}") diff --git a/tools/bloom-sheet/page.tex b/tools/bloom-sheet/page.tex new file mode 100644 index 0000000..e445ae9 --- /dev/null +++ b/tools/bloom-sheet/page.tex @@ -0,0 +1,108 @@ +% A bloom sheet: one A4 page, GameCult navy with a neon garden. +% bloom_sheet.py fills the three @@ markers; grow.py writes layer.tex. +% Every panel is a fitting tcolorbox, so its type shrinks to whatever room it gets. +\documentclass{article} +\usepackage[paperwidth=210mm,paperheight=297mm,margin=0mm]{geometry} +\usepackage{fontspec} +\usepackage{xcolor} +\usepackage{tikz} +\usepackage{graphicx} +\usepackage{array} +\usepackage[most]{tcolorbox} +\tcbuselibrary{fitting} + +% ---------- GameCult palette (gamecult-site quartz.config.ts), plus neon ---------- +\definecolor{ground}{HTML}{07111A} +\definecolor{groundtop}{HTML}{03070D} +\definecolor{groundfoot}{HTML}{09141F} +\definecolor{panelbg}{HTML}{0A1722} +\definecolor{heading}{HTML}{EEF5FF} +\definecolor{body}{HTML}{B7C7D9} +\definecolor{muted}{HTML}{7488A0} +\definecolor{neonorange}{HTML}{FF8A2A} +\definecolor{neonsky}{HTML}{59B7FF} +\definecolor{neonviolet}{HTML}{8F84FF} +\definecolor{neonrose}{HTML}{FF6FAE} % not a brand colour: the "cozy" note +\definecolor{neonwhite}{HTML}{EEF5FF} +\colorlet{accent}{neonorange} + +@@FONTS@@ + +% ---------- Neon primitives, called by the generated layer ---------- +% A filled weighted stroke with two halos; #2 is the percent of colour the core keeps. +\newcommand{\neonfill}[3]{\path[ + preaction={draw=#1, line width=1.7pt, draw opacity=0.09, line join=round}, + preaction={draw=#1, line width=0.8pt, draw opacity=0.18, line join=round}, + fill=#1!#2!white] #3;} +\newcommand{\neondot}[4]{% + \fill[#1, opacity=0.10] (#2,#3) circle ({2.4*#4}); + \fill[#1, opacity=0.22] (#2,#3) circle ({1.5*#4}); + \fill[#1!55!white] (#2,#3) circle (#4);} +\newcommand{\petalwash}[2]{\fill[#1, opacity=0.14] #2;} +% A visitor: the caller's SVG, already a PDF, in a disc the plant grew around. +% \visitor{x}{y}{size}{angle}{file} +\newcommand{\visitor}[5]{\node[inner sep=0pt, rotate=#4] at (#1,#2) + {\includegraphics[width=#3mm, height=#3mm, keepaspectratio]{#5}};} +\newcommand{\neonframe}[2]{% + \fill[panelbg, opacity=0.88] #2; + \draw[#1, line width=3pt, opacity=0.06] #2; + \draw[#1, line width=1.4pt, opacity=0.14] #2; + \draw[#1!70!white, line width=0.45pt] #2;} + +% ---------- Text atoms ---------- +% All of these live in the preamble: a macro with parameters defined inside a +% panel's argument breaks tcolorbox fit ("Illegal parameter number"). Nothing +% here sets an absolute size for body text: fit scales the base size and size +% commands escape it. \sub is the one relative label. +\newcommand{\head}[1]{{\labelfont\color{accent}\MakeUppercase{#1}}\par\vspace{0.25em}} +\newcommand{\sub}[1]{\par\vspace{0.25em}{\labelfont\color{muted}\MakeUppercase{#1}}\par} +% \leavevmode: a \color at the start of a table cell otherwise drops the cell a line. +\DeclareRobustCommand{\code}[1]{\leavevmode{\numfont\color{neonsky}#1}} +\DeclareRobustCommand{\strong}[1]{\leavevmode{\bfseries\color{heading}#1}} +\DeclareRobustCommand{\soft}[1]{\leavevmode{\speakfont\color{heading}#1}} +\DeclareRobustCommand{\faint}[1]{\leavevmode{\color{muted}#1}} +% One list item: depth, its marker, its text. +\newcommand{\li}[3]{\par{\leftskip=\dimexpr #1em - 1em\relax\hangindent=1em\hangafter=1\noindent\makebox[1em][l]{\color{accent}#2}#3\par}} +\newcommand{\quoted}[1]{\par{\leftskip=0.8em\color{heading}#1\par}} +\newcommand{\codeline}[1]{\par{\numfont\color{neonsky}\hangindent=1em\hangafter=1\noindent#1\par}} +\newcommand{\hairline}{\par\vspace{0.2em}{\color{muted}\rule{\linewidth}{0.2pt}}\par\vspace{0.2em}} +\newcommand{\cell}[1]{\raggedright\arraybackslash#1} + +% ---------- One panel ---------- +% \panel{x}{y}{w}{h}{colour}{content}, mm from the page's bottom left. +% The frame is drawn by the generated layer; this only sets the text. +% Padding 3.1mm keeps text clear of corner radii up to ~10mm. +\newcommand{\panel}[6]{% + \node[anchor=south west, inner sep=0pt] at (#1mm,#2mm) {% + \begin{tcolorbox}[blank, fit, width=#3mm, height=#4mm, + left=3.1mm, right=3.1mm, top=3mm, bottom=2.6mm, + fontupper=\color{body}\linespread{1.02}\selectfont, + before upper={\colorlet{accent}{#5}\parindent0pt\parskip0.14em\raggedright}] + #6 + \end{tcolorbox}};} + +@@CONTENTS@@ + +\pagecolor{ground} +\pagestyle{empty} +\begin{document} +\begin{tikzpicture}[remember picture, overlay, x=1mm, y=1mm] +\begin{scope}[shift={(current page.south west)}] + +% ---- ground: the site's vertical gradient and its three coloured radials ---- +% Radials are stacks of faint circles; one opaque-ish circle shows a hard edge. +\shade[top color=groundtop, bottom color=groundfoot, middle color=ground] (0,0) rectangle (210,297); +\foreach \i in {1,...,32} { + \fill[neonviolet, opacity=0.0060] (38,267) circle ({87*\i/32}); + \fill[neonsky, opacity=0.0050] (109,297) circle ({100*\i/32}); + \fill[neonorange, opacity=0.0065] (164,255) circle ({65*\i/32}); +} +\foreach \i in {1,...,24} {\fill[neonrose, opacity=0.0028] (40,30) circle ({60*\i/24});} + +\input{layer.tex} + +@@TITLE@@ + +\end{scope} +\end{tikzpicture} +\end{document}