bloom-sheet: visitors drawn with the plant's pen #47
5 changed files with 196 additions and 23 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
66
tools/bloom-sheet/test_visitors.py
Normal file
66
tools/bloom-sheet/test_visitors.py
Normal 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)
|
||||
Loading…
Reference in a new issue