diff --git a/tools/INDEX.md b/tools/INDEX.md index f4a5a3b..b85a7b3 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -17,4 +17,4 @@ gets a Soul pass before merge. | `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only \|r[,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | | `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers | | `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as ` C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 ` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) | -| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | +| `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 | diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py index 38c55cb..d84eccd 100755 --- a/tools/bloom-sheet/bloom_sheet.py +++ b/tools/bloom-sheet/bloom_sheet.py @@ -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") diff --git a/tools/bloom-sheet/grow.py b/tools/bloom-sheet/grow.py index 44f645a..db9730a 100644 --- a/tools/bloom-sheet/grow.py +++ b/tools/bloom-sheet/grow.py @@ -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]) diff --git a/tools/bloom-sheet/page.tex b/tools/bloom-sheet/page.tex index e445ae9..d38c2e8 100644 --- a/tools/bloom-sheet/page.tex +++ b/tools/bloom-sheet/page.tex @@ -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; diff --git a/tools/bloom-sheet/test_visitors.py b/tools/bloom-sheet/test_visitors.py new file mode 100644 index 0000000..045102d --- /dev/null +++ b/tools/bloom-sheet/test_visitors.py @@ -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'{body}') + 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 = '' +refused("apart", '', "2 separate") +refused("apart lines", '', "2 separate") +refused("gradient", '', "gradient") +refused("gradient stroke", '', "gradient") +refused("text", DISC + 'hi', "text") +refused("image", DISC + '', "image") +refused("nothing", '', "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('')) +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('')) +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('')) +check("an all-black icon is seen", m[0]["colour"] == "neonwhite") +m = read_visitor(svg(DISC + '')) +check("black on colour is a cutout", m[1]["colour"] == "ground") +check("paint order is kept", m[0]["colour"] == "neonorange") +m = read_visitor(svg('')) +check("eyes inside an outline count as touching", len(m) == 3) +m = read_visitor(svg('')) +check("a hole stays a ring of the solid", len(m) == 1 and len(m[0]["rings"]) == 2) +m = read_visitor(svg('')) +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)