bloom-sheet CLI: markdown to a neon one-page sheet #46

Merged
eureka merged 1 commit from eureka-self/bloom-sheet-cli into main 2026-11-05 12:55:12 +00:00
4 changed files with 1066 additions and 0 deletions

View file

@ -17,3 +17,4 @@ gets a Soul pass before merge.
| `tools/context-pack.py` | `py -3 tools/context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>\|r<k>[,...]] [--spec-json <file>] [--max-lines <n>]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r<k>`; 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 <sha>` 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 `<absolute python.exe> 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 <missing script>` 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 |

345
tools/bloom-sheet/bloom_sheet.py Executable file
View file

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

612
tools/bloom-sheet/grow.py Normal file
View file

@ -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}")

108
tools/bloom-sheet/page.tex Normal file
View file

@ -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}