| 1 | #!/usr/bin/env python3 |
| 2 | """Export the v2 whale character's still poses as web SVGs. |
| 3 | |
| 4 | Source: `handoff/posters.json` from the whale-character-v2 kit (vendored in |
| 5 | codewhale-app under vendor/whale-character-v2/). Each poster is the one-ink, |
| 6 | reduced-motion pose of one of the 17 actions. This keeps only the visible |
| 7 | paths, rounds coordinates to 0.01, and fills everything with the logo |
| 8 | gradient (#1E8FD8 -> #0B48BB), so the files work as plain <img> sources in |
| 9 | both appearances. Eye and throat apertures are holes in the body path; like |
| 10 | the kit's colour version, a light underlay (the pouch and eye shapes, drawn |
| 11 | first) shows through them, so the belly reads light on any ground. |
| 12 | |
| 13 | Usage: |
| 14 | scripts/export-whale-poses.py <posters.json> [out-dir] # default public/whale |
| 15 | """ |
| 16 | |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import json |
| 20 | import re |
| 21 | import sys |
| 22 | from pathlib import Path |
| 23 | |
| 24 | GRADIENT = ( |
| 25 | '<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-50" y1="-50" ' |
| 26 | 'x2="50" y2="50"><stop stop-color="#1E8FD8"/><stop offset="1" stop-color="#0B48BB"/>' |
| 27 | "</linearGradient></defs>" |
| 28 | ) |
| 29 | PATH = re.compile(r'<path id="([^"]+)" d="([^"]+)"([^>]*)/>') |
| 30 | NUM = re.compile(r"-?\d+\.\d+") |
| 31 | |
| 32 | |
| 33 | def visible(attrs: str) -> float: |
| 34 | m = re.search(r'opacity="([\d.]+)"', attrs) |
| 35 | return float(m.group(1)) if m else 1.0 |
| 36 | |
| 37 | |
| 38 | def compact(d: str) -> str: |
| 39 | d = NUM.sub(lambda m: f"{float(m.group(0)):.2f}".rstrip("0").rstrip("."), d) |
| 40 | return re.sub(r"\s+", " ", d).strip() |
| 41 | |
| 42 | |
| 43 | LIGHT = "#F4F8FF" |
| 44 | UNDERLAY = re.compile(r"(^|-)(pouch|eye)$") |
| 45 | |
| 46 | |
| 47 | def export(scene: str) -> str: |
| 48 | view = re.search(r'viewBox="([^"]+)"', scene).group(1) |
| 49 | under, paths = [], [] |
| 50 | for pid, d, attrs in PATH.findall(scene): |
| 51 | if UNDERLAY.search(pid): |
| 52 | under.append(f'<path d="{compact(d)}"/>') |
| 53 | opacity = visible(attrs) |
| 54 | if opacity <= 0.001: |
| 55 | continue |
| 56 | op = "" if opacity >= 0.999 else f' opacity="{opacity:.2f}"' |
| 57 | paths.append(f'<path d="{compact(d)}"{op}/>') |
| 58 | return ( |
| 59 | f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view}">{GRADIENT}' |
| 60 | f'<g fill="{LIGHT}">{"".join(under)}</g>' |
| 61 | f'<g fill="url(#g)" fill-rule="evenodd">{"".join(paths)}</g></svg>\n' |
| 62 | ) |
| 63 | |
| 64 | |
| 65 | def main() -> int: |
| 66 | if len(sys.argv) < 2: |
| 67 | print(__doc__) |
| 68 | return 2 |
| 69 | posters = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) |
| 70 | out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).resolve().parent.parent / "public/whale" |
| 71 | out.mkdir(parents=True, exist_ok=True) |
| 72 | for poster in posters: |
| 73 | (out / f"{poster['state']}.svg").write_text(export(poster["scene"]), encoding="utf-8") |
| 74 | print(f"wrote {len(posters)} poses to {out}") |
| 75 | return 0 |
| 76 | |
| 77 | |
| 78 | if __name__ == "__main__": |
| 79 | raise SystemExit(main()) |
| 80 |