bloom-sheet: visitors drawn with the plant's pen #47

Merged
eureka merged 1 commit from eureka-self/bloom-visitor-neon into main 2026-11-05 14:36:47 +00:00
5 changed files with 196 additions and 23 deletions

View file

@ -17,4 +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 |
| `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 and is drawn with the plant's pen, so it must be flat strokes or fills that touch. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH |

View file

@ -9,12 +9,19 @@ 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.
A visitor is drawn with the plant's own pen, not pasted in. Stroked shapes
become weighted neon lines, and a fill under a stroke becomes a faint wash, as
on a petal. Shapes with only a fill become solid neon. Colours snap to the
sheet's palette, near-black paints the ground (a cutout), and opacity is
ignored. All of it must touch, reading as one thing; a closed outline counts
as covering its inside. Text, images and gradients are refused.
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.
pdftoppm for PNG, the Ubuntu, IBM Plex Mono and Montserrat fonts, and for
visitors python3-svgelements and python3-shapely.
Everything from the document is escaped, and LaTeX runs with no shell escape.
"""
import argparse, json, os, random, re, shutil, subprocess, sys, tempfile, zlib
import argparse, colorsys, json, math, os, random, re, shutil, subprocess, sys, tempfile, zlib
from pathlib import Path
from markdown_it import MarkdownIt
@ -229,12 +236,100 @@ def macro(k):
return r"\content" + name
PALETTE_HUES = {"neonorange": 28, "neonsky": 205, "neonviolet": 244, "neonrose": 336}
def neon_colour(colour):
"""The sheet's colour nearest an SVG colour; near-black is the page's ground."""
h, s, v = colorsys.rgb_to_hsv(colour.red / 255, colour.green / 255, colour.blue / 255)
if v < 0.25: return "ground"
if s < 0.25: return "neonwhite"
return min(PALETTE_HUES, key=lambda c: abs((h * 360 - PALETTE_HUES[c] + 180) % 360 - 180))
def read_visitor(svg):
"""Read an SVG into marks the plant's pen can draw, in paint order, centred,
y up, and scaled so everything lies within radius 1. Exits with the reason
if the drawing can't be one. A mark is {"colour", "rings"} for a solid or
{"colour", "pts", "width", "closed", "wash"} for a line."""
from shapely.geometry import LineString, Polygon
from shapely.ops import unary_union
from svgelements import SVG, Close, Line, Move, Path as SvgPath, Shape, Text
from svgelements import Image as SvgImage
def refuse(why):
sys.exit(f"bloom-sheet: visitor {svg}: {why}")
def paint(element, attr):
"""The element's fill or stroke, or None. svgelements reads url(#...) as black."""
if "url(" in str(element.values.get(attr, "")):
refuse(f"its {attr} is a gradient or pattern; use one flat colour per shape")
c = getattr(element, attr)
return c if c is not None and c.value is not None else None
raw = [] # (fill, stroke, stroke width, [(points, closed)])
for e in SVG.parse(svg, reify=True).elements():
if isinstance(e, Text): refuse("it has text; draw it as paths")
if isinstance(e, SvgImage): refuse("it has an embedded image; draw it as paths")
if not isinstance(e, Shape): continue
fill, stroke = paint(e, "fill"), paint(e, "stroke")
if stroke is not None and not e.implicit_stroke_width: stroke = None
if fill is None and stroke is None: continue
subs = []
for seg in SvgPath(e):
if isinstance(seg, Move):
subs.append([[(seg.end.x, -seg.end.y)], False])
elif isinstance(seg, Close):
subs[-1][1] = True
else:
n = 1 if isinstance(seg, Line) else 24
subs[-1][0].extend((p.x, -p.y) for p in (seg.point(k / n) for k in range(1, n + 1)))
subs = [(pts, closed or math.dist(pts[0], pts[-1]) < 1e-6) for pts, closed in subs if len(pts) > 1]
if subs: raw.append((fill, stroke, float(e.implicit_stroke_width or 0), subs))
if not raw: refuse("nothing in it has a fill or a stroke")
every = [p for *_, subs in raw for pts, _ in subs for p in pts]
xs, ys = [p[0] for p in every], [p[1] for p in every]
cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
radius = max(math.dist(p, (cx, cy)) for p in every) or 1.0
unit = lambda pts: [(round((x - cx) / radius, 5), round((y - cy) / radius, 5)) for x, y in pts]
# all dark would be all cutout: a plain black icon is meant to be seen
colours = [c for f, s, *_ in raw for c in (f, s) if c is not None]
ink = (lambda c: "neonwhite") if all(neon_colour(c) == "ground" for c in colours) else neon_colour
marks, covers = [], []
touch = radius * 0.005
for fill, stroke, width, subs in raw:
if fill is not None:
area = None
for pts, _ in subs:
if len(pts) > 2:
poly = Polygon(pts).buffer(0)
area = poly if area is None else area.symmetric_difference(poly)
if area is not None: covers.append(area.buffer(touch))
if stroke is None:
marks.append({"colour": ink(fill), "rings": [unit(pts) for pts, _ in subs]})
continue
for pts, closed in subs:
marks.append({"colour": ink(stroke), "pts": unit(pts), "width": round(width / radius, 5), "closed": closed,
"wash": ink(fill) if fill is not None and closed else None})
covers.append(Polygon(pts).buffer(touch) if closed and len(pts) > 3
else LineString(pts).buffer(width / 2 + touch))
whole = unary_union(covers)
if whole.geom_type != "Polygon":
refuse(f"it comes apart into {len(whole.geoms)} separate pieces; "
"join them, or pass each as its own --visitor")
return marks
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")
visitors = [read_visitor(svg) for svg in args.visitor] # refuse before the two minutes
seed = args.seed if args.seed is not None else zlib.crc32(source.encode("utf8")) % 100000
rng = random.Random(seed)
@ -267,12 +362,8 @@ def build(args, source):
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")
{"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": visitors}), 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)
@ -328,7 +419,7 @@ def main():
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("--visitor", action="append", default=[], metavar="SVG", help="an SVG of strokes or one solid shape 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")

View file

@ -23,10 +23,11 @@ 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, ...]}
"visitors": [[mark, ...], ...]}
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.
grows, and the plant grows around it. bloom_sheet.py's read_visitor makes the
marks, in a unit disc; they are drawn with the plant's own pen.
"""
import json, math, random, sys
from pathlib import Path
@ -214,17 +215,37 @@ def place(c, r):
return col, 0.0, lambda: None
# ---------------------------------------------------------------- visitors
visitors = [] # (cx, cy, r, file)
for vf in spec.get("visitors", []):
visitors = [] # (cx, cy, r, marks)
for number, marks in enumerate(spec.get("visitors", []), 1):
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")
print(f"left out: visitor {number}: 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))
visitors.append((c[0], c[1], r, marks))
np.minimum(free, np.hypot(X - c[0], Y - c[1]) - r - 1.0, out=free)
def draw_visitor(cx, cy, r, marks):
a = math.radians(rng.uniform(-18, 18))
spot = lambda pts: [(cx + r * 0.95 * (x * math.cos(a) - y * math.sin(a)),
cy + r * 0.95 * (x * math.sin(a) + y * math.cos(a))) for x, y in pts]
for m in marks:
if "rings" in m:
rings = " ".join(path(spot(ring_)) for ring_ in m["rings"])
out.append(f"\\begin{{scope}}[even odd rule]\\neonfill{{{m['colour']}}}{{85}}{{{rings}}}\\end{{scope}}")
continue
pts = spot(m["pts"])
if m["wash"]: out.append(f"\\petalwash{{{m['wash']}}}{{{path(pts)}}}")
# the caller's weight, kept within the plant's; a closed line breathes
# like a ring, an open one swells between thin ends
w = max(0.14, min(0.7, m["width"] * r * 0.95))
if m["closed"]:
ph = rng.uniform(0, 2 * math.pi)
neon(m["colour"], ribbon(pts + pts[:1], lambda u: w * (0.75 + 0.25 * math.cos(2 * math.pi * u + ph))), 0.9)
else:
neon(m["colour"], ribbon(pts, lambda u: w * (0.4 + 0.6 * math.sin(math.pi * u) ** 0.7)), 0.9)
# ---------------------------------------------------------------- 1. motifs
nodes = [] # (cx, cy, drawn radius, colour)
draws = []
@ -602,9 +623,9 @@ for n, x, y, w, h, c, m, (tl, tr, br, bl) in panels:
lines.append("\\end{scope}")
for poly in swash_out:
lines.append(f"\\neonfill{{neonorange}}{{90}}{{{path(poly)}}}")
for vx, vy, vr, marks in visitors:
draw_visitor(vx, vy, vr, marks)
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])

View file

@ -6,7 +6,6 @@
\usepackage{fontspec}
\usepackage{xcolor}
\usepackage{tikz}
\usepackage{graphicx}
\usepackage{array}
\usepackage[most]{tcolorbox}
\tcbuselibrary{fitting}
@ -39,10 +38,6 @@
\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;

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""What a visitor must be. Run: python3 test_visitors.py
Needs python3-svgelements and python3-shapely; no LaTeX."""
import math, sys, tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bloom_sheet import read_visitor
fails = 0
tmp = Path(tempfile.mkdtemp())
def svg(body):
f = tmp / "v.svg"
f.write_text(f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100">{body}</svg>')
return str(f)
def check(name, ok):
global fails
print(("ok " if ok else "FAIL"), name)
fails += not ok
def refused(name, body, reason):
try:
read_visitor(svg(body))
check(f"{name}: refused", False)
except SystemExit as e:
check(f"{name}: refused for {reason}", reason in str(e))
DISC = '<circle cx="50" cy="50" r="40" fill="#f80"/>'
refused("apart", '<circle cx="20" cy="20" r="8" fill="#f80"/><circle cx="80" cy="80" r="8" fill="#f80"/>', "2 separate")
refused("apart lines", '<path d="M5 5 L20 5 M5 90 L20 90" fill="none" stroke="#f80"/>', "2 separate")
refused("gradient", '<circle cx="50" cy="50" r="40" fill="url(#g)"/>', "gradient")
refused("gradient stroke", '<circle cx="50" cy="50" r="40" fill="none" stroke="url(#g)"/>', "gradient")
refused("text", DISC + '<text x="5" y="5">hi</text>', "text")
refused("image", DISC + '<image width="9" height="9" xlink:href="data:image/png;base64,iVBORw0KGgo="/>', "image")
refused("nothing", '<circle cx="50" cy="50" r="40" fill="none"/>', "fill or a stroke")
m = read_visitor(svg(DISC))
check("a bare fill is a solid", len(m) == 1 and "rings" in m[0] and m[0]["colour"] == "neonorange")
m = read_visitor(svg('<path d="M10 10 L90 10 L50 90" fill="none" stroke="#59B7FF" stroke-width="2"/>'))
check("a stroke is an open line, no wash", len(m) == 1 and "pts" in m[0] and not m[0]["closed"] and m[0]["wash"] is None)
check("the line keeps its colour", m[0]["colour"] == "neonsky")
check("the stroke width scales with the drawing", 0 < m[0]["width"] < 0.1)
m = read_visitor(svg('<circle cx="50" cy="50" r="40" fill="#8F84FF" stroke="#FF8A2A" stroke-width="1"/>'))
check("a fill under a stroke is a wash", m[0]["closed"] and m[0]["wash"] == "neonviolet" and m[0]["colour"] == "neonorange")
m = read_visitor(svg('<path d="M50 5 L95 95 L5 95Z"/>'))
check("an all-black icon is seen", m[0]["colour"] == "neonwhite")
m = read_visitor(svg(DISC + '<circle cx="35" cy="35" r="5" fill="#07111A"/>'))
check("black on colour is a cutout", m[1]["colour"] == "ground")
check("paint order is kept", m[0]["colour"] == "neonorange")
m = read_visitor(svg('<circle cx="50" cy="50" r="40" fill="none" stroke="#f80"/><circle cx="35" cy="35" r="5" fill="#fff"/><circle cx="65" cy="35" r="5" fill="#fff"/>'))
check("eyes inside an outline count as touching", len(m) == 3)
m = read_visitor(svg('<path fill-rule="evenodd" fill="#f80" d="M10 10 H90 V90 H10Z M40 40 H60 V60 H40Z"/>'))
check("a hole stays a ring of the solid", len(m) == 1 and len(m[0]["rings"]) == 2)
m = read_visitor(svg('<g transform="translate(30 10) scale(0.2)"><rect width="100" height="300" fill="#f80"/></g>'))
pts = [p for ring in m[0]["rings"] for p in ring]
check("any drawing lands in the unit disc", abs(max(math.hypot(*p) for p in pts) - 1) < 1e-3)
top = max(pts, key=lambda p: p[1])
check("y points up", top[1] > 0 and m[0]["rings"][0][0][1] > 0)
sys.exit(1 if fails else 0)