diff --git a/TODO.md b/TODO.md index 3c9bf76..5586bb3 100644 --- a/TODO.md +++ b/TODO.md @@ -201,6 +201,10 @@ licence-free zone only. the `CanopySignatures` label is white, Godot 276/276, `12-joke-shop.png` + the three-up board recaptured. Note: fix 2 (blade sign) is already a circular disc on a roof pole in the code (`37f925c`); the ranking note was stale. + **Fix 5 landed:** the red curtain bays are now seven tied-back drapes (hourglass cinch, dark + recess between bays, scalloped hem) instead of four flat red slabs; vision-judged as + "tied-back/gathered fabric" via `tools/describe_image.py`. Joke Shop map is now 25,828 tris / + 518 meshes (under the 150k/700 budget); Godot 277/277, `12-joke-shop.png` + board recaptured. - [ ] Lighting: `lighting`/`lookdev` pass on sconces, canopy, marquee and street lamps with day/late captures. (Attenuation is now gated: `test_venue_lights_use_physical_falloff` asserts every `OmniLight3D`/`SpotLight3D` in all three venues uses `attenuation = 1.0`, diff --git a/docs/screens/boards/jokeshop-day-board.jpg b/docs/screens/boards/jokeshop-day-board.jpg index 3961610..7b8b78b 100644 Binary files a/docs/screens/boards/jokeshop-day-board.jpg and b/docs/screens/boards/jokeshop-day-board.jpg differ diff --git a/docs/screens/boards/jokeshop-day-board.json b/docs/screens/boards/jokeshop-day-board.json index ad5fd23..06fbc31 100644 --- a/docs/screens/boards/jokeshop-day-board.json +++ b/docs/screens/boards/jokeshop-day-board.json @@ -1,6 +1,6 @@ { "board": "jokeshop-day-board.jpg", - "caption": "Joke Shop frontage, day. Photo: local-only research frame (tmp/research/comedy-store). Concept: photo-anchored (gemini + gpt). Capture: dark stucco wall tile + white signature band.", + "caption": "Joke Shop frontage, day. Photo: local-only research frame (tmp/research/comedy-store). Concept: photo-anchored (gemini + gpt). Capture: dark stucco wall tile + white signature band + tied-back curtain bays.", "photo": "tmp/research/comedy-store/01.jpg", "concept": "docs/screens/concepts/joke_shop/joke_shop_entrance_day-v2.png", "capture": "docs/screenshots/12-joke-shop.png" diff --git a/docs/screenshots/12-joke-shop.png b/docs/screenshots/12-joke-shop.png index d7a598a..edddad2 100644 Binary files a/docs/screenshots/12-joke-shop.png and b/docs/screenshots/12-joke-shop.png differ diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index c86a559..ee94708 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -498,20 +498,40 @@ func _build_comedy_store_building() -> void: step_body.shape_owner_set_transform(step_owner, Transform3D(Basis(), Vector3(door_x, 0.07, front + 0.92))) root.add_child(step_body) - # Red curtains hang between the entrance and the signature wall. Three - # narrow folds and a tieback keep them from reading as flat red slabs. - for x in [-3.2, 1.9, 7.0, 11.2]: - for fold in [-0.27, 0.0, 0.27]: - var fold_color := red.lightened(0.10 if fold > 0.0 else 0.0) - _add_box(building, Vector3(0.24, 3.10, 0.18), - Vector3(x + fold, 2.70, front + 4.56 + absf(fold) * 0.08), - fold_color, true, null, Vector2.ONE, "cloth") - _add_box(building, Vector3(0.96, 0.16, 0.22), - Vector3(x, 2.13, front + 4.60), Color(0.24, 0.02, 0.03), - false, null, Vector2.ONE, "cloth") - _add_box(building, Vector3(1.06, 0.52, 0.20), - Vector3(x, 1.34, front + 4.54), Color(0.34, 0.025, 0.035), - false, null, Vector2.ONE, "cloth") + # Red tied-back curtains: seven bays between the entrance and the east end. + # Each drape is cinched at a tieback into an hourglass, leaving the dark + # recess visible on either side, and finishes in a scalloped hem. The taper + # plus tone variation makes them read as gathered fabric, not flat slabs. + for bay in range(7): + var x := -3.2 + float(bay) * 2.4 + # Top drape tapering down to the tieback (widest under the valence). + for top in [[4.05, 1.00], [3.55, 0.80], [3.05, 0.60], [2.55, 0.42]]: + _add_box(building, Vector3(top[1], 0.52, 0.20), + Vector3(x, top[0], front + 4.56), + red.lightened(0.05), true, null, Vector2.ONE, "cloth") + # Tieback cord at the narrowest point. + _add_box(building, Vector3(0.60, 0.14, 0.24), Vector3(x, 2.15, front + 4.58), + Color(0.24, 0.02, 0.03), true, null, Vector2.ONE, "cloth") + # Bottom drape flaring down to the hem. + for bottom in [[1.85, 0.56], [1.48, 0.82]]: + _add_box(building, Vector3(bottom[1], 0.52, 0.20), + Vector3(x, bottom[0], front + 4.56), + red.lightened(0.11), true, null, Vector2.ONE, "cloth") + # Scalloped hem: overlapping shallow spheres along the bottom edge. + for hem_i in range(5): + var hem_x := -0.46 + 0.23 * float(hem_i) + var hem := MeshInstance3D.new() + var hem_mesh := SphereMesh.new() + hem_mesh.radius = 0.25 + hem_mesh.height = 0.48 + hem_mesh.radial_segments = 8 + hem_mesh.rings = 4 + hem.mesh = hem_mesh + hem.position = Vector3(x + hem_x, 1.24, front + 4.56) + hem.scale = Vector3(0.62, 0.36, 0.36) + hem.material_override = Materials.flat(red.lightened(0.12), "cloth") + hem.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON + building.add_child(hem) # Small warm sconces along the black wall. Their bulbs are emissive and each # gets a low-energy practical so the facade is not a black hole after dark. for x in [-12.4, -6.2, 3.0, 9.4]: diff --git a/tools/describe_image.py b/tools/describe_image.py new file mode 100644 index 0000000..a24aad9 --- /dev/null +++ b/tools/describe_image.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Ask an OpenRouter vision model to describe an image (free-form). + +Complements the structured judges (`scene_concepts.py realism --judge`, +`screen_designs.py judge`): those return scores/verdicts, this one returns an +open-ended textual observation for exactly the question asked. Used to review +captures and references when the agent harness itself cannot display images. + +Usage: + python3 tools/describe_image.py [--question "..."] [--model ...] + python3 tools/describe_image.py [--question "..."] # compare + +Requires OPENROUTER_API_KEY (a gitignored `.env` in the repo root holds it; +source with `set -a && . ./.env && set +a`, or the tool reads `.env` directly). +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +import openrouter_judge # noqa: E402 + +CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" + +DEFAULT_QUESTION = ( + "Describe this image as an art director reviewing a game screenshot: the " + "subject, composition, materials, lighting, silhouette, and any obvious " + "visual defects or things that read as flat, broken or out of place. Be " + "concrete and specific." +) + + +def _load_env_file() -> None: + env = ROOT / ".env" + if not env.is_file(): + return + for line in env.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip("\"'")) + + +def _data_url(path: Path) -> str: + return "data:image/png;base64," + base64.b64encode(Path(path).read_bytes()).decode("ascii") + + +def _call(body: dict, timeout: int = 180) -> dict: + key = os.environ.get("OPENROUTER_API_KEY", "").strip() + if not key: + raise ValueError("OPENROUTER_API_KEY is not set") + req = urllib.request.Request( + CHAT_URL, data=json.dumps(body).encode("utf-8"), + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json", + "HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game", + "X-Title": "Gig Line"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}") + + +def build_body(model: str, images: list[Path], question: str) -> dict: + content: list[dict] = [{"type": "text", "text": question}] + for path in images: + content.append({"type": "image_url", "image_url": {"url": _data_url(path)}}) + return {"model": model, "messages": [{"role": "user", "content": content}], + "temperature": 0} + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("images", nargs="+", help="image file(s) to send") + ap.add_argument("--question", default=DEFAULT_QUESTION, + help="question to ask (default: a full art-director description)") + ap.add_argument("--model", default="auto", + help="explicit model, or 'auto' for the cheapest image-capable model") + args = ap.parse_args(argv) + + _load_env_file() + images = [Path(p) for p in args.images] + for path in images: + if not path.is_file(): + print(f"error: not a file: {path}", file=sys.stderr) + return 2 + + models = openrouter_judge.resolve_models(args.model) + if len(images) > 1: + args.question = args.question + ( + f"\n\nI sent {len(images)} images in order. Compare them and describe the " + "differences, especially anything that changed or regressed.") + try: + model, payload = openrouter_judge.call_with_fallback( + models, lambda m: build_body(m, images, args.question), _call) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + try: + text = payload["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError): + text = "unreadable model response" + if isinstance(text, list): + text = "".join(p.get("text", "") for p in text if isinstance(p, dict)) + print(f"[model {model}]") + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())