bloom-sheet CLI: markdown to a neon one-page sheet #46
4 changed files with 1066 additions and 0 deletions
|
|
@ -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
345
tools/bloom-sheet/bloom_sheet.py
Executable 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
612
tools/bloom-sheet/grow.py
Normal 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
108
tools/bloom-sheet/page.tex
Normal 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}
|
||||
Loading…
Reference in a new issue