| 1 | #!/usr/bin/env python3 |
| 2 | """Derive the TUI launch-mark assets from the founder raster (PRD section 6). |
| 3 | |
| 4 | The launch mark in `crates/tui/src/tui/mark.rs` is generated here, never |
| 5 | hand-drawn. The canonical product mark is the founder-supplied raster |
| 6 | `brand/codewhalemarkfinal.png` (1254 x 1254 brand sheet: navy hero whale on |
| 7 | white, sizing row, the white-on-navy app icon, colour/mono/reversed rows). |
| 8 | Both TUI tiers are proportional/braille derivatives of that file — no |
| 9 | redraws, no traced SVG: |
| 10 | |
| 11 | - braille rows <- the hero whale (navy on white, top of the sheet), |
| 12 | navy darkness box-filtered to a dot grid, aspect preserved and centred |
| 13 | in the rung's box, all-blank edge columns trimmed, the eye carved as one |
| 14 | cleared dot; |
| 15 | - kitty/sixel PNGs <- the app-icon panel (white whale on the navy rounded |
| 16 | square), auto-located as the largest navy blob in the sheet's right |
| 17 | middle band, squared, sheet-white keyed to transparent, proportionally |
| 18 | resized. |
| 19 | |
| 20 | scripts/brand/braille-mark.py # print + Rust consts |
| 21 | scripts/brand/braille-mark.py --png crates/tui/assets/mark-96.png --px 96 |
| 22 | |
| 23 | Requires `pillow` (`pip install pillow`). No other dependencies. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import argparse |
| 29 | import collections |
| 30 | import pathlib |
| 31 | import sys |
| 32 | |
| 33 | try: |
| 34 | from PIL import Image |
| 35 | except ImportError: |
| 36 | raise SystemExit("braille-mark.py requires pillow (`pip install pillow`)") |
| 37 | |
| 38 | ROOT = pathlib.Path(__file__).resolve().parents[2] |
| 39 | RASTER = ROOT / "brand" / "codewhalemarkfinal.png" |
| 40 | |
| 41 | # Search bands as fractions of the sheet, so the boxes track the layout |
| 42 | # rather than absolute pixels. The app-icon caption ("APP ICON", navy text) |
| 43 | # sits above the icon band; the band starts below it. |
| 44 | HERO_BAND = (0.0, 1.0, 0.0, 0.52) # x0, x1, y0, y1 |
| 45 | ICON_BAND = (0.65, 1.0, 0.55, 0.78) |
| 46 | HERO_MARGIN = 12 |
| 47 | ICON_PAD = 10 |
| 48 | # Sheet background (and the icon's drop shadow, darkest ~211) keys out; |
| 49 | # founder navy (~15,33,65) never approaches this. |
| 50 | BG_CUTOFF = 200 |
| 51 | # Braille dot bit for (dot_row, dot_col) inside one cell — U+2800 layout: |
| 52 | # dots 1,2,3 are column 0 rows 0..2 (bits 0..2), dots 4,5,6 column 1 rows |
| 53 | # 0..2 (bits 3..5), dots 7,8 are row 3 (bits 6,7). |
| 54 | DOT_BITS = { |
| 55 | (0, 0): 0x01, |
| 56 | (1, 0): 0x02, |
| 57 | (2, 0): 0x04, |
| 58 | (0, 1): 0x08, |
| 59 | (1, 1): 0x10, |
| 60 | (2, 1): 0x20, |
| 61 | (3, 0): 0x40, |
| 62 | (3, 1): 0x80, |
| 63 | } |
| 64 | |
| 65 | |
| 66 | def is_navy(pixel: tuple[int, int, int]) -> bool: |
| 67 | r, g, b = pixel |
| 68 | return b > 60 and b > r + 25 and r < 110 and g < 150 |
| 69 | |
| 70 | |
| 71 | def load_sheet() -> Image.Image: |
| 72 | if not RASTER.exists(): |
| 73 | raise SystemExit(f"founder raster missing: {RASTER}") |
| 74 | image = Image.open(RASTER).convert("RGB") |
| 75 | width, height = image.size |
| 76 | if width != height or width < 800: |
| 77 | raise SystemExit(f"unexpected founder sheet geometry: {image.size}") |
| 78 | return image |
| 79 | |
| 80 | |
| 81 | def band_box(image: Image.Image, band: tuple[float, float, float, float]): |
| 82 | width, height = image.size |
| 83 | return ( |
| 84 | int(band[0] * width), |
| 85 | int(band[1] * width), |
| 86 | int(band[2] * height), |
| 87 | int(band[3] * height), |
| 88 | ) |
| 89 | |
| 90 | |
| 91 | def hero_coverage(image: Image.Image) -> tuple[int, int, list[list[float]]]: |
| 92 | """Navy-darkness coverage of the hero whale crop, each in 0..1.""" |
| 93 | width, height = image.size |
| 94 | x0, x1, y0, _ = band_box(image, HERO_BAND) |
| 95 | pixels = image.load() |
| 96 | xs, ys = [], [] |
| 97 | for y in range(y0, int(HERO_BAND[3] * height)): |
| 98 | for x in range(x0, x1): |
| 99 | if is_navy(pixels[x, y]): |
| 100 | xs.append(x) |
| 101 | ys.append(y) |
| 102 | if not xs: |
| 103 | raise SystemExit("no navy hero whale found in the founder sheet") |
| 104 | box = ( |
| 105 | max(0, min(xs) - HERO_MARGIN), |
| 106 | max(0, min(ys) - HERO_MARGIN), |
| 107 | min(width, max(xs) + HERO_MARGIN + 1), |
| 108 | min(height, max(ys) + HERO_MARGIN + 1), |
| 109 | ) |
| 110 | crop = image.crop(box) |
| 111 | cover = crop.load() |
| 112 | cw, ch = crop.size |
| 113 | coverage = [] |
| 114 | for y in range(ch): |
| 115 | row = [] |
| 116 | for x in range(cw): |
| 117 | r, g, b = cover[x, y] |
| 118 | row.append(max(0.0, min(1.0, (180.0 - (r + g + b) / 3.0) / 120.0))) |
| 119 | coverage.append(row) |
| 120 | print(f"// hero whale box {box[0]},{box[1]}-{box[2]},{box[3]}", file=sys.stderr) |
| 121 | return cw, ch, coverage |
| 122 | |
| 123 | |
| 124 | def icon_square(image: Image.Image) -> Image.Image: |
| 125 | """The app-icon panel squared: white whale on the navy rounded square |
| 126 | with the sheet background keyed to transparent. Located as the largest |
| 127 | navy blob in the icon band, so the caption text (separate small blobs) |
| 128 | can never be mistaken for the mark.""" |
| 129 | width, height = image.size |
| 130 | x0, x1, y0, y1 = band_box(image, ICON_BAND) |
| 131 | pixels = image.load() |
| 132 | seen = bytearray(width * height) |
| 133 | best: list[tuple[int, int]] = [] |
| 134 | for sy in range(y0, y1): |
| 135 | for sx in range(x0, x1): |
| 136 | if not is_navy(pixels[sx, sy]) or seen[sy * width + sx]: |
| 137 | continue |
| 138 | blob, stack = [], collections.deque([(sx, sy)]) |
| 139 | seen[sy * width + sx] = 1 |
| 140 | while stack: |
| 141 | x, y = stack.pop() |
| 142 | blob.append((x, y)) |
| 143 | for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): |
| 144 | if ( |
| 145 | x0 <= nx < x1 |
| 146 | and y0 <= ny < y1 |
| 147 | and not seen[ny * width + nx] |
| 148 | and is_navy(pixels[nx, ny]) |
| 149 | ): |
| 150 | seen[ny * width + nx] = 1 |
| 151 | stack.append((nx, ny)) |
| 152 | if len(blob) > len(best): |
| 153 | best = blob |
| 154 | if len(best) < 10_000: |
| 155 | raise SystemExit("app-icon blob not found in the founder sheet") |
| 156 | bx0 = min(x for x, _ in best) |
| 157 | bx1 = max(x for x, _ in best) |
| 158 | by0 = min(y for _, y in best) |
| 159 | by1 = max(y for _, y in best) |
| 160 | # The white whale cuts the blob's left side, but its full height shows: |
| 161 | # the icon is square, so the edge is the height. |
| 162 | edge = by1 - by0 + 1 |
| 163 | if not 150 <= edge <= 260: |
| 164 | raise SystemExit(f"app-icon blob has unexpected height: {edge}") |
| 165 | cx = (bx0 + bx1) // 2 |
| 166 | cy = (by0 + by1) // 2 |
| 167 | half = edge // 2 + ICON_PAD |
| 168 | box = (cx - half, cy - half, cx + half, cy + half) |
| 169 | print( |
| 170 | f"// app-icon navy blob x {bx0}-{bx1} y {by0}-{by1}, " |
| 171 | f"square crop {box[0]},{box[1]}-{box[2]},{box[3]}", |
| 172 | file=sys.stderr, |
| 173 | ) |
| 174 | square = image.crop(box).convert("RGBA") |
| 175 | sw, sh = square.size |
| 176 | ink = square.load() |
| 177 | flood = bytearray(sw * sh) |
| 178 | |
| 179 | def is_bg(x: int, y: int) -> bool: |
| 180 | r, g, b = ink[x, y][:3] |
| 181 | return min(r, g, b) > BG_CUTOFF |
| 182 | |
| 183 | queue = collections.deque() |
| 184 | for x in range(sw): |
| 185 | for y in (0, sh - 1): |
| 186 | if is_bg(x, y): |
| 187 | queue.append((x, y)) |
| 188 | flood[y * sw + x] = 1 |
| 189 | for y in range(sh): |
| 190 | for x in (0, sw - 1): |
| 191 | if is_bg(x, y) and not flood[y * sw + x]: |
| 192 | queue.append((x, y)) |
| 193 | flood[y * sw + x] = 1 |
| 194 | while queue: |
| 195 | x, y = queue.popleft() |
| 196 | r, g, b, _ = ink[x, y] |
| 197 | ink[x, y] = (r, g, b, 0) |
| 198 | for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): |
| 199 | if 0 <= nx < sw and 0 <= ny < sh and not flood[ny * sw + nx] and is_bg(nx, ny): |
| 200 | flood[ny * sw + nx] = 1 |
| 201 | queue.append((nx, ny)) |
| 202 | return square |
| 203 | |
| 204 | |
| 205 | def downsample( |
| 206 | coverage: list[list[float]], width: int, height: int, dots_w: int, dots_h: int |
| 207 | ) -> list[list[float]]: |
| 208 | """Box-filter coverage into a dots_w x dots_h grid, aspect preserved and |
| 209 | centred. Cells outside the glyph read 0.""" |
| 210 | scale = min(dots_w / width, dots_h / height) |
| 211 | glyph_w = max(1, round(width * scale)) |
| 212 | glyph_h = max(1, round(height * scale)) |
| 213 | off_x = (dots_w - glyph_w) // 2 |
| 214 | off_y = (dots_h - glyph_h) // 2 |
| 215 | grid = [[0.0] * dots_w for _ in range(dots_h)] |
| 216 | for gy in range(glyph_h): |
| 217 | y0 = int(gy * height / glyph_h) |
| 218 | y1 = max(y0 + 1, int((gy + 1) * height / glyph_h)) |
| 219 | for gx in range(glyph_w): |
| 220 | x0 = int(gx * width / glyph_w) |
| 221 | x1 = max(x0 + 1, int((gx + 1) * width / glyph_w)) |
| 222 | total = 0.0 |
| 223 | for y in range(y0, min(y1, height)): |
| 224 | row = coverage[y] |
| 225 | for x in range(x0, min(x1, width)): |
| 226 | total += row[x] |
| 227 | grid[off_y + gy][off_x + gx] = total / ((y1 - y0) * (x1 - x0)) |
| 228 | return grid |
| 229 | |
| 230 | |
| 231 | def eye_hole(coverage: list[list[float]], width: int, height: int) -> tuple[float, float] | None: |
| 232 | """Locate the eye: the smallest enclosed hole in the glyph above the |
| 233 | raster-speck noise floor (the belly white is the other, far larger, |
| 234 | hole). Returns its centroid as a fraction of the glyph's width and |
| 235 | height, or None when nothing is enclosed. Works on a coarse copy so |
| 236 | the flood fill stays cheap.""" |
| 237 | scale = max(1, width // 220) |
| 238 | cw, ch = width // scale, height // scale |
| 239 | solid = [ |
| 240 | [coverage[y * scale][x * scale] >= 0.5 for x in range(cw)] for y in range(ch) |
| 241 | ] |
| 242 | seen = [[False] * cw for _ in range(ch)] |
| 243 | holes = [] |
| 244 | for sy in range(ch): |
| 245 | for sx in range(cw): |
| 246 | if solid[sy][sx] or seen[sy][sx]: |
| 247 | continue |
| 248 | stack, cells, touches_edge = [(sx, sy)], [], False |
| 249 | seen[sy][sx] = True |
| 250 | while stack: |
| 251 | x, y = stack.pop() |
| 252 | cells.append((x, y)) |
| 253 | if x in (0, cw - 1) or y in (0, ch - 1): |
| 254 | touches_edge = True |
| 255 | for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): |
| 256 | if 0 <= nx < cw and 0 <= ny < ch and not solid[ny][nx] and not seen[ny][nx]: |
| 257 | seen[ny][nx] = True |
| 258 | stack.append((nx, ny)) |
| 259 | if not touches_edge and len(cells) >= 4: |
| 260 | holes.append(cells) |
| 261 | if not holes: |
| 262 | return None |
| 263 | eye = min(holes, key=len) |
| 264 | cx = sum(x for x, _ in eye) / len(eye) + 0.5 |
| 265 | cy = sum(y for _, y in eye) / len(eye) + 0.5 |
| 266 | return cx / cw, cy / ch |
| 267 | |
| 268 | |
| 269 | def carve_eye( |
| 270 | grid: list[list[float]], width: int, height: int, dots_w: int, dots_h: int, eye: tuple[float, float] |
| 271 | ) -> None: |
| 272 | """Clear the one dot under the eye's centroid so the eye survives rungs |
| 273 | where it is smaller than a dot. Same geometry as `downsample`.""" |
| 274 | scale = min(dots_w / width, dots_h / height) |
| 275 | glyph_w = max(1, round(width * scale)) |
| 276 | glyph_h = max(1, round(height * scale)) |
| 277 | off_x = (dots_w - glyph_w) // 2 |
| 278 | off_y = (dots_h - glyph_h) // 2 |
| 279 | x = min(dots_w - 1, off_x + int(eye[0] * glyph_w)) |
| 280 | y = min(dots_h - 1, off_y + int(eye[1] * glyph_h)) |
| 281 | grid[y][x] = 0.0 |
| 282 | |
| 283 | |
| 284 | def to_braille(grid: list[list[float]], cols: int, rows: int, threshold: float) -> list[str]: |
| 285 | lines = [] |
| 286 | for cy in range(rows): |
| 287 | line = [] |
| 288 | for cx in range(cols): |
| 289 | bits = 0 |
| 290 | for (dy, dx), bit in DOT_BITS.items(): |
| 291 | if grid[cy * 4 + dy][cx * 2 + dx] >= threshold: |
| 292 | bits |= bit |
| 293 | line.append(chr(0x2800 + bits) if bits else " ") |
| 294 | lines.append("".join(line)) |
| 295 | return lines |
| 296 | |
| 297 | |
| 298 | def trim_columns(lines: list[str]) -> list[str]: |
| 299 | width = max(len(line) for line in lines) |
| 300 | padded = [line.ljust(width) for line in lines] |
| 301 | blank = [all(line[x] == " " for line in padded) for x in range(width)] |
| 302 | first = next((x for x in range(width) if not blank[x]), 0) |
| 303 | last = next((x for x in range(width - 1, -1, -1) if not blank[x]), width - 1) |
| 304 | return [line[first : last + 1] for line in padded] |
| 305 | |
| 306 | |
| 307 | def rust_const(name: str, lines: list[str]) -> str: |
| 308 | body = "\n".join(f' "{line}",' for line in lines) |
| 309 | return f"const {name}: [&str; {len(lines)}] = [\n{body}\n];" |
| 310 | |
| 311 | |
| 312 | def write_png(out: pathlib.Path, px: int) -> None: |
| 313 | square = icon_square(load_sheet()) |
| 314 | square.resize((px, px), Image.LANCZOS).save(out) |
| 315 | print(f"wrote {out} ({px}x{px}, founder app-icon derivative)") |
| 316 | |
| 317 | |
| 318 | def main() -> int: |
| 319 | parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) |
| 320 | parser.add_argument( |
| 321 | "--rung", |
| 322 | action="append", |
| 323 | default=None, |
| 324 | metavar="NAME:COLSxROWS", |
| 325 | help="cell box to render, e.g. SMALL:11x3 (default: SMALL:11x3 TINY:8x2)", |
| 326 | ) |
| 327 | parser.add_argument("--threshold", type=float, default=0.3, help="dot coverage threshold") |
| 328 | parser.add_argument("--no-eye", action="store_true", help="do not carve the eye dot") |
| 329 | parser.add_argument("--png", type=pathlib.Path, help="write an app-icon PNG instead") |
| 330 | parser.add_argument("--px", type=int, default=96, help="PNG edge in pixels") |
| 331 | args = parser.parse_args() |
| 332 | |
| 333 | if args.png: |
| 334 | write_png(args.png, args.px) |
| 335 | return 0 |
| 336 | |
| 337 | rungs = args.rung or ["SMALL:11x3", "TINY:8x2"] |
| 338 | width, height, coverage = hero_coverage(load_sheet()) |
| 339 | eye = None if args.no_eye else eye_hole(coverage, width, height) |
| 340 | print("// generated by scripts/brand/braille-mark.py from brand/codewhalemarkfinal.png") |
| 341 | print( |
| 342 | f"// (founder hero whale {width}x{height}px, " |
| 343 | f"threshold {args.threshold}, aspect preserved, edge columns trimmed, " |
| 344 | f"eye {'carved' if eye else 'not found'})" |
| 345 | ) |
| 346 | for spec in rungs: |
| 347 | name, box = spec.split(":") |
| 348 | cols, rows = (int(v) for v in box.lower().split("x")) |
| 349 | grid = downsample(coverage, width, height, cols * 2, rows * 4) |
| 350 | if eye is not None: |
| 351 | carve_eye(grid, width, height, cols * 2, rows * 4, eye) |
| 352 | lines = trim_columns(to_braille(grid, cols, rows, args.threshold)) |
| 353 | print(f"\n// {name}: box {cols}x{rows} -> ink {len(lines[0])}x{len(lines)}") |
| 354 | print(rust_const(f"{name}_ROWS", lines)) |
| 355 | print("//", file=sys.stderr) |
| 356 | for line in lines: |
| 357 | print(f"// |{line}|", file=sys.stderr) |
| 358 | return 0 |
| 359 | |
| 360 | |
| 361 | if __name__ == "__main__": |
| 362 | raise SystemExit(main()) |
| 363 |