diff --git a/MEMORY.md b/MEMORY.md index 40f477f..5f3aad3 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -59,6 +59,14 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under `assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run `godot --headless --import` before tests. 57 headless tests guard the pipeline. +- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the + OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model + `bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires + `OPENROUTER_API_KEY` (a gitignored `.env` in the repo root holds it locally; source it + with `set -a && . ./.env && set +a`). `--prompt`/`--name`/`--model`/`--size` override the + default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below + 3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not + lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated). - Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob + attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night). diff --git a/README.md b/README.md index 4729f02..fe19c56 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri - **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` - **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources - **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes -- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`) +- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`). - **Testing:** GUT (Godot Unit Test) in headless mode - **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds - **Targets:** Windows, Linux, and macOS desktop first; web later diff --git a/TODO.md b/TODO.md index eb6d475..b11ed0c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,15 @@ # TODO +## 🎨 AI texture generation (OpenRouter) + +- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode + (`--ai`, `--prompt`, `--name`, `--model`, `--size`), decoding base64 PNG + responses and clamping output to ≤1024px. +- [x] Verify a live `--ai` generation against a real `OPENROUTER_API_KEY` + (generated `ai/brick_facade.png`, `ai/concrete.png`, `ai/asphalt.png` at + 1024×1024; fixed the seedream min-pixel requirement by requesting 2048px + and downscaling). + ## 🪟 Windows installer (self-contained, all game data) Goal: a real Windows installer that includes the entire game (executable + data), diff --git a/assets/textures/ai/asphalt.png b/assets/textures/ai/asphalt.png new file mode 100644 index 0000000..bbf7059 Binary files /dev/null and b/assets/textures/ai/asphalt.png differ diff --git a/assets/textures/ai/awning.png b/assets/textures/ai/awning.png new file mode 100644 index 0000000..f453772 Binary files /dev/null and b/assets/textures/ai/awning.png differ diff --git a/assets/textures/ai/brick_facade.png b/assets/textures/ai/brick_facade.png new file mode 100644 index 0000000..9daa0ec Binary files /dev/null and b/assets/textures/ai/brick_facade.png differ diff --git a/assets/textures/ai/cards/credential.png b/assets/textures/ai/cards/credential.png new file mode 100644 index 0000000..a359880 Binary files /dev/null and b/assets/textures/ai/cards/credential.png differ diff --git a/assets/textures/ai/cards/guest_list.png b/assets/textures/ai/cards/guest_list.png new file mode 100644 index 0000000..6101666 Binary files /dev/null and b/assets/textures/ai/cards/guest_list.png differ diff --git a/assets/textures/ai/cards/id_card.png b/assets/textures/ai/cards/id_card.png new file mode 100644 index 0000000..5431c4b Binary files /dev/null and b/assets/textures/ai/cards/id_card.png differ diff --git a/assets/textures/ai/cards/ticket.png b/assets/textures/ai/cards/ticket.png new file mode 100644 index 0000000..9ac267d Binary files /dev/null and b/assets/textures/ai/cards/ticket.png differ diff --git a/assets/textures/ai/cards/wristband.png b/assets/textures/ai/cards/wristband.png new file mode 100644 index 0000000..4136a0d Binary files /dev/null and b/assets/textures/ai/cards/wristband.png differ diff --git a/assets/textures/ai/concrete.png b/assets/textures/ai/concrete.png new file mode 100644 index 0000000..606b573 Binary files /dev/null and b/assets/textures/ai/concrete.png differ diff --git a/assets/textures/ai/icons/door_locked.png b/assets/textures/ai/icons/door_locked.png new file mode 100644 index 0000000..0bae298 Binary files /dev/null and b/assets/textures/ai/icons/door_locked.png differ diff --git a/assets/textures/ai/icons/flashlight_on.png b/assets/textures/ai/icons/flashlight_on.png new file mode 100644 index 0000000..f84c480 Binary files /dev/null and b/assets/textures/ai/icons/flashlight_on.png differ diff --git a/assets/textures/ai/icons/rec_dot.png b/assets/textures/ai/icons/rec_dot.png new file mode 100644 index 0000000..6d43224 Binary files /dev/null and b/assets/textures/ai/icons/rec_dot.png differ diff --git a/assets/textures/ai/icons/wand.png b/assets/textures/ai/icons/wand.png new file mode 100644 index 0000000..ad914e0 Binary files /dev/null and b/assets/textures/ai/icons/wand.png differ diff --git a/assets/textures/ai/skyline.png b/assets/textures/ai/skyline.png new file mode 100644 index 0000000..fc94b23 Binary files /dev/null and b/assets/textures/ai/skyline.png differ diff --git a/assets/textures/ai/vignette.png b/assets/textures/ai/vignette.png new file mode 100644 index 0000000..1e8f1d5 Binary files /dev/null and b/assets/textures/ai/vignette.png differ diff --git a/tools/generate_textures.py b/tools/generate_textures.py index 2138ea4..9d98c06 100644 --- a/tools/generate_textures.py +++ b/tools/generate_textures.py @@ -3,17 +3,93 @@ All outputs are deterministic (fixed seeds), <=1024px, power-of-two where possible. Run from the repo root: python3 tools/generate_textures.py + +Optional AI mode generates textures through the OpenRouter image API +(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md). +Requires an API key in the OPENROUTER_API_KEY environment variable: + + python3 tools/generate_textures.py --ai + python3 tools/generate_textures.py --ai --prompt "..." --name foo.png """ +import argparse +import base64 +import io +import json import math import os import random import stat +import sys +import urllib.error +import urllib.request from PIL import Image, ImageDraw, ImageFilter, ImageFont ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ASSETS = os.path.join(ROOT, "assets", "textures") +# OpenRouter image generation endpoint and default model (from the API docs). +OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images" +DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5" +MAX_DIM = 1024 # hard cap on the longest side of any generated texture +# px requested from the API; some models (e.g. seedream) need >= 1920 +GEN_SIZE = 2048 + +# Default batch for `--ai`: one entry per game texture (see test_world.gd). +# Each entry is (output name, prompt, transparent_background). +AI_TEXTURES = [ + # --- world surfaces (seamless, flat-lit) --- + ("ai/brick_facade.png", + "seamless tileable red brick wall texture, weathered urban facade, " + "flat even lighting, frontal view, photorealistic game texture", False), + ("ai/concrete.png", + "seamless tileable grey concrete wall texture, subtle cracks and stains, " + "flat even lighting, frontal view, photorealistic game texture", False), + ("ai/asphalt.png", + "seamless tileable dark asphalt road texture, fine grain, " + "flat even lighting, frontal view, photorealistic game texture", False), + # --- storefront dressing --- + ("ai/awning.png", + "red and white striped storefront awning with scalloped bottom edge, " + "frontal view, flat even lighting, game texture", False), + ("ai/skyline.png", + "night city skyline silhouette, dark building shapes with a few lit " + "windows, flat graphic style, isolated on transparent background", True), + # --- screen-space overlay --- + ("ai/vignette.png", + "radial vignette overlay, black edges fading to fully transparent center, " + "grayscale, soft gradient", True), + # --- HUD icons (white glyphs on transparent) --- + ("ai/icons/flashlight_on.png", + "simple flat flashlight icon, white glyph on transparent background, " + "minimal UI icon", True), + ("ai/icons/door_locked.png", + "simple flat locked door icon with keyhole, white outline glyph on " + "transparent background, minimal UI icon", True), + ("ai/icons/wand.png", + "simple flat handheld security wand icon, white glyph on transparent " + "background, minimal UI icon", True), + ("ai/icons/rec_dot.png", + "record button icon, red filled dot inside a white circle outline, on " + "transparent background, minimal UI icon", True), + # --- access artifact cards --- + ("ai/cards/id_card.png", + "driver license ID card, blue plastic card with photo placeholder and " + "text lines, flat frontal view, game UI asset", False), + ("ai/cards/wristband.png", + "red event access wristband with strap, flat frontal view, game UI asset", + False), + ("ai/cards/ticket.png", + "green admission ticket stub with perforated edge, flat frontal view, " + "game UI asset", False), + ("ai/cards/credential.png", + "dark grey staff credential pass with lanyard clip, flat frontal view, " + "game UI asset", False), + ("ai/cards/guest_list.png", + "beige guest list document with ruled lines and checkboxes, flat frontal " + "view, game UI asset", False), +] + FONT_CANDIDATES = [ "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", @@ -21,12 +97,14 @@ FONT_CANDIDATES = [ "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", ] + def font(size: int): for path in FONT_CANDIDATES: if os.path.exists(path): return ImageFont.truetype(path, size) return ImageFont.load_default() + def save(img: Image.Image, name: str) -> None: os.makedirs(ASSETS, exist_ok=True) path = os.path.join(ASSETS, name) @@ -34,6 +112,7 @@ def save(img: Image.Image, name: str) -> None: img.save(path, optimize=True) print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") + def grain(size, base, spread, seed, blur=1.0): rnd = random.Random(seed) img = Image.new("L", (size, size), base) @@ -44,6 +123,7 @@ def grain(size, base, spread, seed, blur=1.0): px[x, y] = max(0, min(255, v)) return img.filter(ImageFilter.GaussianBlur(blur)) + def brick_facade(): size = 512 mortar = (118, 110, 104) @@ -68,13 +148,14 @@ def brick_facade(): bx = rnd.randint(0, size - 1) by = rnd.randint(0, size - 1) d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)), - max(0, base[1] - 10 + rnd.randint(0, 22)), - max(0, base[2] - 8 + rnd.randint(0, 18)))) + max(0, base[1] - 10 + rnd.randint(0, 22)), + max(0, base[2] - 8 + rnd.randint(0, 18)))) # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) for y in [-mortar_t, size - mortar_t]: d.rectangle([0, y, size, y + mortar_t], fill=mortar) save(img, "brick_facade.png") + def seamless_concrete(): size = 512 img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") @@ -83,7 +164,8 @@ def seamless_concrete(): for _ in range(90): x, y = rnd.randint(0, size), rnd.randint(0, size) r = rnd.choice([2, 3, 4]) - d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) + d.ellipse([x - r, y - r, x + r, y + r], + fill=(0, 0, 0, rnd.randint(8, 22))) for _ in range(8): # subtle crack segments x, y = rnd.randint(0, size), rnd.randint(0, size) pts = [(x, y)] @@ -94,6 +176,7 @@ def seamless_concrete(): d.line(pts, fill=(70, 70, 70, 170), width=1) save(img, "concrete.png") + def asphalt(): size = 512 img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") @@ -101,9 +184,11 @@ def asphalt(): rnd = random.Random(31) for _ in range(600): x, y = rnd.randint(0, size), rnd.randint(0, size) - d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),)) + d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) + * 3 + (rnd.randint(40, 110),)) save(img, "asphalt.png") + def awning(): w, h = 1024, 256 img = Image.new("RGB", (w, h), (140, 40, 45)) @@ -111,7 +196,8 @@ def awning(): stripe = 64 for i in range(w // stripe): if i % 2 == 1: - d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) + d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], + fill=(230, 225, 220)) # scalloped bottom edge for x in range(0, w, 32): d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) @@ -125,6 +211,7 @@ def awning(): img = Image.alpha_composite(img.convert("RGBA"), shade) save(img, "awning.png") + def skyline(): w, h = 1024, 256 img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) @@ -141,11 +228,13 @@ def skyline(): wx = x + rnd.randint(4, max(4, bw - 8)) wy = h - rnd.randint(4, bh - 4) if rnd.random() < 0.5: - d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) + d.rectangle([wx, wy, wx + 6, wy + 8], + fill=(255, 200, 120, 220)) x += bw + rnd.randint(10, 46) d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) save(img, "skyline.png") + def vignette(): size = 512 img = Image.new("L", (size, size), 0) @@ -159,37 +248,51 @@ def vignette(): px[x, y] = int(255 * (t * t)) save(img, "vignette.png") + def icon(name, draw_fn, size=64): img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) d = ImageDraw.Draw(img) draw_fn(d, size) save(img, name) + def icons(): def flashlight_on(d, s): - d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255)) - d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255)) + d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], + radius=4, fill=(255, 255, 255, 255)) + d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s * + 0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255)) + def door_locked(d, s): - d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5) - d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255)) - d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5) + d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], + outline=(255, 255, 255, 255), width=5) + d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], + radius=3, fill=(255, 255, 255, 255)) + d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, + 360, fill=(255, 255, 255, 255), width=5) + def wand(d, s): - d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255)) - d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255)) + d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], + radius=3, fill=(255, 255, 255, 255)) + d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255)) + def rec(d, s): - d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5) + d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], + outline=(255, 255, 255, 255), width=5) d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255)) icon("icons/flashlight_on.png", flashlight_on) icon("icons/door_locked.png", door_locked) icon("icons/wand.png", wand) icon("icons/rec_dot.png", rec) + def app_icon(): """Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage (PNG) and the Windows installer (ICO).""" def badge(d, s): d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255)) - d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)], outline=(120, 130, 170, 255), width=max(2, s//64)) + d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)], + outline=(120, 130, 170, 255), width=max(2, s//64)) # gold star cx, cy, r = s*0.5, s*0.44, s*0.30 pts = [] @@ -197,7 +300,8 @@ def app_icon(): ang = -90 + i * 36 rad = r if i % 2 == 0 else r * 0.42 import math - pts.append((cx + rad * math.cos(math.radians(ang)), cy + rad * math.sin(math.radians(ang)))) + pts.append((cx + rad * math.cos(math.radians(ang)), + cy + rad * math.sin(math.radians(ang)))) d.polygon(pts, fill=(255, 205, 80, 255)) icon("icon.png", badge, size=1024) icon("icon.ico", badge, size=256) @@ -205,29 +309,33 @@ def app_icon(): # multi-size ICO for Windows (from the 256 render) src = Image.open(os.path.join(ASSETS, "icon.png")) ico_path = os.path.join(ASSETS, "icon.ico") - src.save(ico_path, format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)]) + src.save(ico_path, format="ICO", sizes=[ + (256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)]) print("wrote assets/textures/icon.ico (multi-size)") + def cards(): CW, CH = 256, 128 - def base(card_name, bg, label, band=(0,0,0,0)): + + def base(card_name, bg, label, band=(0, 0, 0, 0)): img = Image.new("RGBA", (CW, CH), bg + (255,)) d = ImageDraw.Draw(img) - d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2) + d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, + outline=(0, 0, 0, 90), width=2) if band and band[3] == 255: d.rectangle([0, CH-34, CW, CH], fill=band) f = font(26) - d.text((14, 10), label, font=f, fill=(255,255,255,235)) + d.text((14, 10), label, font=f, fill=(255, 255, 255, 235)) # body text lines f2 = font(15) y = 52 for _ in range(4): w = 150 - (24 if _ == 3 else 0) - d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110)) + d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110)) y += 24 # photo square for id-like cards if "photo" in card_name: - d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255)) + d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255)) save(img, f"cards/{card_name}.png") base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255)) @@ -235,7 +343,132 @@ def cards(): base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255)) base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255)) + +def _api_key() -> str: + key = os.environ.get("OPENROUTER_API_KEY", "").strip() + if not key: + sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai") + return key + + +def _generate_image(prompt: str, model: str, size: int, + transparent: bool = False) -> Image.Image: + """Call the OpenRouter image API and return the decoded PIL image. + + Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP + bytes returned in `data[].b64_json`. The requested size is clamped to at + least GEN_SIZE because some models (e.g. seedream) reject small outputs; + callers downscale the result to MAX_DIM afterwards. When `transparent` is + true, the API is asked for a transparent background (PNG alpha). + """ + size = max(GEN_SIZE, int(size)) + body = { + "model": model, + "prompt": prompt, + "size": f"{size}x{size}", + "output_format": "png", + "n": 1, + } + if transparent: + body["background"] = "transparent" + req = urllib.request.Request( + OPENROUTER_IMAGES_URL, + data=json.dumps(body).encode("utf-8"), + headers={ + "Authorization": f"Bearer {_api_key()}", + "Content-Type": "application/json", + "HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game", + "X-Title": "Step Line", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=300) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}") + except urllib.error.URLError as exc: + sys.exit(f"error: could not reach OpenRouter: {exc.reason}") + + data = payload.get("data") or [] + if not data or not data[0].get("b64_json"): + sys.exit(f"error: OpenRouter returned no image data: {payload}") + raw = base64.b64decode(data[0]["b64_json"]) + try: + img = Image.open(io.BytesIO(raw)) + img.load() + except Exception as exc: # noqa: BLE001 - surface any decode failure clearly + sys.exit(f"error: could not decode returned image: {exc}") + return img + + +def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image: + """Downscale to fit within max_dim on the longest side, preserving aspect.""" + w, h = img.size + longest = max(w, h) + if longest <= max_dim: + return img + scale = max_dim / longest + return img.resize((max(1, round(w * scale)), max(1, round(h * scale))), + Image.Resampling.LANCZOS) + + +def generate_ai_textures(prompt: str, name: str, model: str, size: int, + transparent: bool = False) -> None: + """Generate one texture via OpenRouter and save it under assets/textures/.""" + img = _generate_image(prompt, model, size, transparent) + img = _fit(img) + if transparent: + img = img.convert("RGBA") + elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): + img = img.convert("RGBA") + else: + img = img.convert("RGB") + save(img, name) + + +def _run_ai(args: argparse.Namespace) -> None: + if args.prompt: + if not args.name: + sys.exit("error: --name is required when --prompt is given") + generate_ai_textures(args.prompt, args.name, args.model, args.size) + return + for name, prompt, transparent in AI_TEXTURES: + print(f"generating {name} ...") + generate_ai_textures(prompt, name, args.model, args.size, transparent) + + +def _build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Generate Step Line textures (authored or via OpenRouter).") + parser.add_argument( + "--ai", action="store_true", + help="generate textures through the OpenRouter image API " + "(requires OPENROUTER_API_KEY)") + parser.add_argument( + "--prompt", default="", + help="single prompt for --ai (overrides the default batch)") + parser.add_argument( + "--name", default="", + help="output filename under assets/textures/ for a single --prompt") + parser.add_argument( + "--model", default=DEFAULT_IMAGE_MODEL, + help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})") + parser.add_argument( + "--size", type=int, default=GEN_SIZE, + help=f"square size requested from the API in px (min {GEN_SIZE}); " + f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})") + return parser + + if __name__ == "__main__": + args = _build_arg_parser().parse_args() + if args.ai: + _run_ai(args) + print("done") + sys.exit(0) + brick_facade() seamless_concrete() asphalt()