| 1 | #!/usr/bin/env python3 |
| 2 | """Derive the pet body (`crates/tui/src/tui/ambient_life/whale-points.tsv`). |
| 3 | |
| 4 | python3 scripts/brand/whale-points.py # rewrite the point cloud |
| 5 | python3 scripts/brand/whale-points.py --check # exit 1 if it drifted |
| 6 | python3 scripts/brand/whale-points.py --preview # print the cloud as ASCII |
| 7 | |
| 8 | The pet is a 980-particle body. Before this script the body was hand-authored |
| 9 | and did not follow the product mark: measured against `brand/mark.svg`'s |
| 10 | silhouette only ~19-23% of its points landed inside the mark once the cloud was |
| 11 | scaled to fill it, so the pet read as static rather than a whale. |
| 12 | |
| 13 | Source of truth is the same one `trace-brand.py` uses: the hero whale of |
| 14 | `brand/codewhalemarkfinal.png`. `brand/mark.svg` is the kept trace of that hero, |
| 15 | and the founder's app-icon render is the same silhouette (0.93 IoU), so there is |
| 16 | exactly one mark and this script derives from it rather than redrawing it. |
| 17 | |
| 18 | Sampling is deliberately contour-only. 980 discs cannot fill a solid |
| 19 | silhouette legibly at pet sizes, so the cloud spends its whole budget on the |
| 20 | mark's outline (outer edge plus internal boundaries), where each dot buys the |
| 21 | most shape. Spacing is even (farthest-point sampling) because clumped sampling |
| 22 | reads as noise even where the underlying silhouette is correct. |
| 23 | |
| 24 | Measured against the shipped dot radius, the body this replaced covered ~6% of |
| 25 | the mark's outline and buried the rest under a diffuse interior; this one |
| 26 | covers ~80% of it continuously. |
| 27 | |
| 28 | Requires `pillow` and `numpy`. No network. No ImageMagick. |
| 29 | """ |
| 30 | |
| 31 | from __future__ import annotations |
| 32 | |
| 33 | import argparse |
| 34 | import pathlib |
| 35 | import sys |
| 36 | |
| 37 | try: |
| 38 | from PIL import Image |
| 39 | import numpy as np |
| 40 | except ImportError: |
| 41 | raise SystemExit("whale-points.py requires pillow and numpy") |
| 42 | |
| 43 | ROOT = pathlib.Path(__file__).resolve().parents[2] |
| 44 | SHEET = ROOT / "brand" / "codewhalemarkfinal.png" |
| 45 | OUT = ROOT / "crates" / "tui" / "src" / "tui" / "ambient_life" / "whale-points.tsv" |
| 46 | |
| 47 | # `pet-native.js` rejects any body that is not exactly 980 x 2 finite points in |
| 48 | # [-1, 1]; the sim, the served TSV and the desktop client must agree on a count. |
| 49 | COUNT = 980 |
| 50 | # Normalized half-extent of the longer side. The renderers apply one uniform |
| 51 | # scale to x and y, so the cloud must be aspect-true to the mark and this is |
| 52 | # what sets the pet's on-screen size. |
| 53 | HALF_EXTENT = 0.44 |
| 54 | |
| 55 | |
| 56 | def hero_mask(path: pathlib.Path) -> np.ndarray: |
| 57 | """The hero whale of the brand sheet, as a boolean ink mask. |
| 58 | |
| 59 | The sheet is a multi-panel page (hero mark, size ramp, icon row, wordmark), |
| 60 | so the hero is found rather than assumed: threshold, then keep the largest |
| 61 | dark component in the top half, which is the hero mark. |
| 62 | """ |
| 63 | grey = np.array(Image.open(path).convert("L"), dtype=np.float64) |
| 64 | h, w = grey.shape |
| 65 | ink = grey < 128 |
| 66 | ink[int(0.52 * h) :, :] = False # below the hero band is the size ramp |
| 67 | |
| 68 | # The hero is the topmost ink on the sheet; flood its component with a |
| 69 | # stack so a caption or a stray rule cannot be mistaken for the mark. |
| 70 | ys, xs = np.nonzero(ink) |
| 71 | if len(ys) == 0: |
| 72 | raise SystemExit(f"no ink found in {path}") |
| 73 | start = (int(ys[0]), int(xs[np.argmin(ys)])) |
| 74 | comp = np.zeros_like(ink) |
| 75 | comp[start] = True |
| 76 | stack = [start] |
| 77 | while stack: |
| 78 | y, x = stack.pop() |
| 79 | for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)): |
| 80 | if 0 <= ny < h and 0 <= nx < w and ink[ny, nx] and not comp[ny, nx]: |
| 81 | comp[ny, nx] = True |
| 82 | stack.append((ny, nx)) |
| 83 | return comp |
| 84 | |
| 85 | |
| 86 | def crop(mask: np.ndarray) -> np.ndarray: |
| 87 | ys, xs = np.nonzero(mask) |
| 88 | return mask[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] |
| 89 | |
| 90 | |
| 91 | def erode(mask: np.ndarray) -> np.ndarray: |
| 92 | out = mask.copy() |
| 93 | out[1:, :] &= mask[:-1, :] |
| 94 | out[:-1, :] &= mask[1:, :] |
| 95 | out[:, 1:] &= mask[:, :-1] |
| 96 | out[:, :-1] &= mask[:, 1:] |
| 97 | return out |
| 98 | |
| 99 | |
| 100 | def smooth(mask: np.ndarray, radius: int = 2) -> np.ndarray: |
| 101 | """Box-blur the edge before thresholding so the contour is not stair-stepped.""" |
| 102 | a = mask.astype(np.float64) |
| 103 | for _ in range(radius): |
| 104 | b = a.copy() |
| 105 | b[1:, :] += a[:-1, :] |
| 106 | b[:-1, :] += a[1:, :] |
| 107 | b[:, 1:] += a[:, :-1] |
| 108 | b[:, :-1] += a[:, 1:] |
| 109 | a = b / b.max() |
| 110 | return a > 0.5 |
| 111 | |
| 112 | |
| 113 | def farthest_point(candidates: np.ndarray, seeds: np.ndarray, want: int) -> np.ndarray: |
| 114 | """Even spacing: repeatedly take the candidate furthest from everything chosen. |
| 115 | |
| 116 | This is what stops the cloud reading as noise: uniform-random sampling |
| 117 | clumps, and clumps read as speckle at pet sizes no matter how correct the |
| 118 | underlying silhouette is. |
| 119 | """ |
| 120 | chosen = list(map(tuple, seeds)) |
| 121 | if not chosen: |
| 122 | chosen.append(tuple(candidates[0])) |
| 123 | pts = candidates.astype(np.float64) |
| 124 | if len(chosen) < want: |
| 125 | base = np.array(chosen, dtype=np.float64) |
| 126 | best = np.full(len(pts), np.inf) |
| 127 | for p in base: |
| 128 | best = np.minimum(best, ((pts - p) ** 2).sum(1)) |
| 129 | for _ in range(want - len(chosen)): |
| 130 | i = int(np.argmax(best)) |
| 131 | p = pts[i] |
| 132 | chosen.append(tuple(candidates[i])) |
| 133 | best = np.minimum(best, ((pts - p) ** 2).sum(1)) |
| 134 | best[i] = -1.0 |
| 135 | return np.array(chosen, dtype=np.float64) |
| 136 | |
| 137 | |
| 138 | def cloud(mask: np.ndarray) -> np.ndarray: |
| 139 | """Spend the whole budget on the contour, evenly spaced. |
| 140 | |
| 141 | Measured at the shipped dot radius (1.55px where the pet is rendered), |
| 142 | spreading points through the interior instead leaves most of the mark's |
| 143 | outline undrawn and scatters loose specks inside it - which is what made |
| 144 | the pet read as static. A contour-only cloud draws a continuous outline. |
| 145 | """ |
| 146 | rim = mask & ~erode(mask) |
| 147 | rys, rxs = np.nonzero(rim) |
| 148 | rimp = np.stack([rxs, rys], axis=1).astype(np.float64) |
| 149 | if len(rimp) == 0: |
| 150 | raise SystemExit("no contour found") |
| 151 | # Seeds must be spread across the whole contour. Taking a prefix instead |
| 152 | # leaves everything past it undrawn, and farthest-point sampling cannot |
| 153 | # recover a region it has no seed near. |
| 154 | seed = rimp[np.linspace(0, len(rimp) - 1, min(64, len(rimp))).astype(int)] |
| 155 | return farthest_point(rimp, seed, COUNT) |
| 156 | |
| 157 | |
| 158 | def normalize(pts: np.ndarray, mask: np.ndarray) -> np.ndarray: |
| 159 | ys, xs = np.nonzero(mask) |
| 160 | cx = (xs.min() + xs.max()) / 2.0 |
| 161 | cy = (ys.min() + ys.max()) / 2.0 |
| 162 | span = max(xs.max() - xs.min(), ys.max() - ys.min()) |
| 163 | scale = (HALF_EXTENT * 2.0) / (span + 1.0) |
| 164 | out = np.empty_like(pts) |
| 165 | out[:, 0] = (pts[:, 0] - cx) * scale |
| 166 | # Screen space is y-down and the mask is y-down, so this keeps the whale |
| 167 | # the right way up in both renderers. |
| 168 | out[:, 1] = (pts[:, 1] - cy) * scale |
| 169 | return out |
| 170 | |
| 171 | |
| 172 | def render() -> str: |
| 173 | mask = smooth(crop(hero_mask(SHEET))) |
| 174 | pts = normalize(cloud(mask), mask) |
| 175 | return "".join(f"{x:.6f}\t{y:.6f}\n" for x, y in pts) |
| 176 | |
| 177 | |
| 178 | def rasterize(pts: np.ndarray, w: int, h: int, dot_scale: float = 1.0) -> np.ndarray: |
| 179 | """Emulate the shipped paint path so legibility is judged on real output. |
| 180 | |
| 181 | Mirrors `src/workspace/pet.rs` / `pet_watch/graphics.rs`: one uniform scale |
| 182 | for both axes, a disc per point, the same radius rule and clamp. |
| 183 | """ |
| 184 | scale = min(w * 0.52, h * 0.85) |
| 185 | radius = max(0.68, min(1.55, min(w, h) * 0.00285)) * dot_scale |
| 186 | ox, oy = w * 0.5, h * 0.47 |
| 187 | canvas = np.zeros((h, w), dtype=np.float64) |
| 188 | reach = int(radius) + 2 |
| 189 | for px, py in pts: |
| 190 | cx = ox + px * scale |
| 191 | cy = oy + py * scale |
| 192 | x0, x1 = int(cx) - reach, int(cx) + reach + 1 |
| 193 | y0, y1 = int(cy) - reach, int(cy) + reach + 1 |
| 194 | if x1 < 0 or y1 < 0 or x0 >= w or y0 >= h: |
| 195 | continue |
| 196 | ys, xs = np.mgrid[max(0, y0) : min(h, y1), max(0, x0) : min(w, x1)] |
| 197 | d = np.hypot(xs - cx, ys - cy) |
| 198 | np.maximum( |
| 199 | canvas[max(0, y0) : min(h, y1), max(0, x0) : min(w, x1)], |
| 200 | np.clip(radius + 0.5 - d, 0.0, 1.0), |
| 201 | out=canvas[max(0, y0) : min(h, y1), max(0, x0) : min(w, x1)], |
| 202 | ) |
| 203 | return canvas |
| 204 | |
| 205 | |
| 206 | def preview(text: str, label: str, w: int, h: int) -> None: |
| 207 | pts = np.array([list(map(float, line.split("\t"))) for line in text.strip().splitlines()]) |
| 208 | canvas = rasterize(pts, w, h) |
| 209 | # Terminal cells are about twice as tall as wide, so the sample grid is |
| 210 | # twice as fine vertically as horizontally. |
| 211 | cols = 96 |
| 212 | rows = max(1, int(h / w * cols * 0.5)) |
| 213 | ramp = " .:-=+*#%@" |
| 214 | print(f"# {label} {w}x{h}px -> {cols}x{rows} cells") |
| 215 | for r in range(rows): |
| 216 | line = "" |
| 217 | for c in range(cols): |
| 218 | ys = slice(int(r * h / rows), max(int(r * h / rows) + 1, int((r + 1) * h / rows))) |
| 219 | xs = slice(int(c * w / cols), max(int(c * w / cols) + 1, int((c + 1) * w / cols))) |
| 220 | v = canvas[ys, xs].mean() |
| 221 | line += ramp[min(len(ramp) - 1, int(v * len(ramp) * 2.2))] |
| 222 | print(line) |
| 223 | |
| 224 | |
| 225 | def main() -> int: |
| 226 | parser = argparse.ArgumentParser(description=__doc__) |
| 227 | parser.add_argument("--check", action="store_true", help="fail if the file drifted") |
| 228 | parser.add_argument("--preview", action="store_true", help="render at the shipped sizes") |
| 229 | args = parser.parse_args() |
| 230 | |
| 231 | text = render() |
| 232 | if args.preview: |
| 233 | preview(text, "ambient backdrop", 960, 560) |
| 234 | preview(text, "pet panel", 420, 260) |
| 235 | return 0 |
| 236 | if args.check: |
| 237 | if not OUT.exists() or OUT.read_text() != text: |
| 238 | print(f"{OUT} is stale; run scripts/brand/whale-points.py", file=sys.stderr) |
| 239 | return 1 |
| 240 | print(f"{OUT} matches the mark") |
| 241 | return 0 |
| 242 | OUT.write_text(text) |
| 243 | print(f"wrote {OUT} ({COUNT} points)") |
| 244 | return 0 |
| 245 | |
| 246 | |
| 247 | if __name__ == "__main__": |
| 248 | raise SystemExit(main()) |
| 249 |