One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
329 lines
15 KiB
Python
329 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate frontal building plates from the local reference photography.
|
|
|
|
Each study takes one photograph we already hold (`Art/reference/`, gitignored) as
|
|
the geometric anchor and asks an image model to re-stage it as a clean **frontal
|
|
view of the building** — the whole elevation, the entrance in its facade, or the
|
|
building with its neighbours — evenly lit, no crowd, no staging, so the modelling
|
|
pass has something measurable instead of a tourist photo.
|
|
|
|
Deliberately **not** close-up detail crops: a doorway or cornice crop cannot be
|
|
measured for proportion, which is what the modelling pass actually needs.
|
|
|
|
python3 Tools/gen/facade_detail_concepts.py --list
|
|
python3 Tools/gen/facade_detail_concepts.py --only door-recess,parapet-motto
|
|
python3 Tools/gen/facade_detail_concepts.py --all
|
|
python3 Tools/gen/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image
|
|
|
|
Output: `Art/reference/facade-details/<study>.png` plus a `<study>.refgen.json`
|
|
sidecar recording the model, the source photograph, the prompt and the SHA-256 of
|
|
the result. `Art/reference/` is gitignored, so **neither the images nor the
|
|
sidecars are committed** — they are local working material. The study list and
|
|
the tool are the committed part.
|
|
|
|
Design notes
|
|
------------
|
|
* Standard library only: the OpenRouter chat-completions call is `urllib`, and
|
|
the returned `data:` URL is decoded with `base64` and written straight to disk.
|
|
* An existing output is never overwritten; `-v2`, `-v3` … are used instead, so
|
|
re-running after a prompt edit keeps both studies comparable.
|
|
* Generation costs money, so it is never automatic: `--list` is free, and every
|
|
generating run prints the model and the study count first.
|
|
* The token is read from `OPENROUTER_API_KEY` or the ignored `.env` and is never
|
|
written to a sidecar or a log line.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
REFERENCE_DIR = os.path.join(ROOT, "Art", "reference")
|
|
OUT_DIR = os.path.join(REFERENCE_DIR, "facade-details")
|
|
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
DEFAULT_MODEL = "google/gemini-2.5-flash-image"
|
|
|
|
# The instruction appended to every study. Kept identical to the sibling
|
|
# project's convention so the two sets stay comparable: the photograph fixes the
|
|
# geometry, the prompt only restages light, weather, people and surface.
|
|
ANCHOR_SUFFIX = (
|
|
"Use the attached reference photograph as the source of the architecture: keep "
|
|
"the building's proportions, storey count, column rhythm, band heights and "
|
|
"signage placement exactly as photographed. You may REFRAME — step back across "
|
|
"the street, zoom out or widen the field of view — so that the whole building "
|
|
"fits in the frame from the pavement to the parapet; the reference is often "
|
|
"cropped and the plate must not be. Keep the view exterior and frontal (or "
|
|
"three-quarter where asked). Restage only lighting, weather, people and surface "
|
|
"condition. Do not invent extra storeys, wings or ornaments, and do not change "
|
|
"the architecture to fit the frame."
|
|
)
|
|
|
|
# name -> (reference photo relative to Art/reference/, what the plate must show)
|
|
#
|
|
# Verified framing (vision pre-check, 2026-11-09): the model follows the reference
|
|
# crop closely, so a plate is only as wide as its source photo. `building-entrance`
|
|
# and `building-street-front` come back as full-elevation frontal views; the studies
|
|
# anchored on the tightly cropped 2009 facade photo stay cropped, because the model
|
|
# will not invent the parapet and sky that the photograph does not contain. A full
|
|
# building plate for 6840 needs a full-elevation photograph, which the local set
|
|
# does not have (see Docs/reference-notes-hollywood.md).
|
|
#
|
|
# Every study asks for a *frontal view of a building*, not a crop of one detail:
|
|
# the whole elevation, the entrance in its facade, or the building together with
|
|
# its neighbours. Close-up framing is deliberately absent — a detail crop cannot
|
|
# be measured for proportion, which is what the modelling pass needs.
|
|
STUDIES: dict[str, tuple[str, str]] = {
|
|
"building-front": (
|
|
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
|
|
"A frontal view of the complete building shown in the reference "
|
|
"photograph: the whole elevation from the pavement to the parapet fits in "
|
|
"the frame with clear sky above the roofline and pavement below the ground "
|
|
"floor, shot straight on and centred. Nothing may be cut off at the top or "
|
|
"bottom. Even daylight, no crowd, no vehicles.",
|
|
),
|
|
"building-entrance": (
|
|
"el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg",
|
|
"A frontal view of the building in the reference photograph, framed so its "
|
|
"street entrance is clearly visible as part of the facade — the doors and "
|
|
"the wall around them in proportion, not a close-up of the doorway. "
|
|
"Include the pavement in front. Even daylight, no crowd.",
|
|
),
|
|
"building-with-neighbours": (
|
|
"hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg",
|
|
"A straight-on view of the street wall in the reference photograph: the "
|
|
"building and its neighbouring buildings either side, each at full height "
|
|
"from pavement to parapet with sky above, the joins between them visible, "
|
|
"nothing cut off. Shot from across the road, parallel to the frontage. "
|
|
"Even daylight, no crowd.",
|
|
),
|
|
"building-street-front": (
|
|
"hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg",
|
|
"A frontal view along the pavement of the building in the reference "
|
|
"photograph: the building's frontage seen from street level with its "
|
|
"neighbours continuing either side and the pavement in the foreground. "
|
|
"Straight on, even daylight, no crowd.",
|
|
),
|
|
"building-front-oblique": (
|
|
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
|
|
"A three-quarter view of the complete building in the reference "
|
|
"photograph: the frontal elevation and one side visible together so the "
|
|
"building's depth and mass read, with its neighbouring building at one "
|
|
"edge. The whole building stays in frame. Even daylight, no crowd.",
|
|
),
|
|
"theatre-front-with-neighbours": (
|
|
"el-capitan-theater-detail/01-el-capitan-marquee.jpg",
|
|
"A frontal view of the theatre in the reference photograph together with "
|
|
"the buildings either side of it, the whole frontage and its full height "
|
|
"in frame, shot from across the road. Even daylight, no crowd, marquee "
|
|
"lettering blocked out as unreadable texture.",
|
|
),
|
|
}
|
|
|
|
|
|
def load_token() -> str:
|
|
token = os.environ.get("OPENROUTER_API_KEY")
|
|
if token:
|
|
return token.strip()
|
|
env_path = os.path.join(ROOT, ".env")
|
|
if os.path.exists(env_path):
|
|
with open(env_path, encoding="utf-8") as handle:
|
|
for line in handle:
|
|
line = line.strip()
|
|
if line.startswith("OPENROUTER_API_KEY="):
|
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
raise SystemExit("no OpenRouter key: set OPENROUTER_API_KEY or add it to .env")
|
|
|
|
|
|
def build_prompt(study: str) -> str:
|
|
"""The full prompt for a study: its request plus the fixed anchor suffix."""
|
|
if study not in STUDIES:
|
|
raise KeyError(f"unknown study {study!r}; known: {sorted(STUDIES)}")
|
|
return f"{STUDIES[study][1]}\n\n{ANCHOR_SUFFIX}"
|
|
|
|
|
|
def reference_path(study: str) -> str:
|
|
return os.path.join(REFERENCE_DIR, STUDIES[study][0])
|
|
|
|
|
|
def missing_references() -> list[str]:
|
|
"""Studies whose source photograph is not present locally."""
|
|
return [name for name in STUDIES if not os.path.isfile(reference_path(name))]
|
|
|
|
|
|
def next_free_path(study: str, directory: str = OUT_DIR) -> str:
|
|
"""`<study>.png`, or `<study>-v2.png`, `-v3` … if the name is taken."""
|
|
base = os.path.join(directory, f"{study}.png")
|
|
if not os.path.exists(base):
|
|
return base
|
|
index = 2
|
|
while True:
|
|
candidate = os.path.join(directory, f"{study}-v{index}.png")
|
|
if not os.path.exists(candidate):
|
|
return candidate
|
|
index += 1
|
|
|
|
|
|
def data_url(path: str) -> str:
|
|
suffix = os.path.splitext(path)[1].lower()
|
|
mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}.get(suffix)
|
|
if not mime:
|
|
raise SystemExit(f"unsupported reference type: {path}")
|
|
with open(path, "rb") as handle:
|
|
return f"data:{mime};base64,{base64.b64encode(handle.read()).decode('ascii')}"
|
|
|
|
|
|
def decode_image(payload: dict) -> bytes:
|
|
"""Extract the returned image bytes from a chat-completions payload."""
|
|
message = (payload.get("choices") or [{}])[0].get("message", {})
|
|
urls = [item.get("image_url", {}).get("url", "")
|
|
for item in (message.get("images") or []) if isinstance(item, dict)]
|
|
if not urls:
|
|
match = re.search(r"data:image/[a-z]+;base64,[A-Za-z0-9+/=]+",
|
|
str(message.get("content") or ""))
|
|
if match:
|
|
urls.append(match.group(0))
|
|
if not urls or not urls[0]:
|
|
raise SystemExit(f"model returned no image data: {json.dumps(payload)[:400]}")
|
|
url = urls[0]
|
|
if url.startswith("data:"):
|
|
return base64.b64decode(url.split(",", 1)[1])
|
|
with urllib.request.urlopen(urllib.request.Request(url), timeout=120) as response:
|
|
return response.read()
|
|
|
|
|
|
def generate(study: str, token: str, model: str, force: bool = False) -> str:
|
|
"""Generate one study; returns the written path. Never overwrites by default."""
|
|
path = next_free_path(study)
|
|
if force and os.path.exists(os.path.join(OUT_DIR, f"{study}.png")):
|
|
path = os.path.join(OUT_DIR, f"{study}.png")
|
|
# Write to a temp name then replace, so a failed call cannot destroy the
|
|
# previous result.
|
|
target = path + ".new"
|
|
else:
|
|
target = path
|
|
|
|
body = {
|
|
"model": model,
|
|
"messages": [{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": build_prompt(study)},
|
|
{"type": "image_url", "image_url": {"url": data_url(reference_path(study))}},
|
|
],
|
|
}],
|
|
}
|
|
request = urllib.request.Request(
|
|
API_URL,
|
|
data=json.dumps(body).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/gig-line",
|
|
"X-Title": "gig-line facade details",
|
|
},
|
|
method="POST",
|
|
)
|
|
|
|
payload = None
|
|
for attempt in range(3):
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=180) as response:
|
|
payload = json.load(response)
|
|
break
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 429 or exc.code >= 500:
|
|
wait = 5 * (attempt + 1)
|
|
sys.stderr.write(f" HTTP {exc.code}; retrying in {wait}s\n")
|
|
time.sleep(wait)
|
|
continue
|
|
detail = exc.read().decode("utf-8", "replace")[:300]
|
|
raise SystemExit(f"{study}: HTTP {exc.code} — {detail}")
|
|
except urllib.error.URLError as exc:
|
|
sys.stderr.write(f" network error ({exc.reason}); retrying\n")
|
|
time.sleep(5 * (attempt + 1))
|
|
if payload is None:
|
|
raise SystemExit(f"{study}: no response after retries")
|
|
|
|
image = decode_image(payload)
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
with open(target, "wb") as handle:
|
|
handle.write(image)
|
|
if target != path:
|
|
os.replace(target, path)
|
|
|
|
import hashlib
|
|
sidecar = {
|
|
"study": study,
|
|
"generated": os.path.basename(path),
|
|
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"model": model,
|
|
"prompt": build_prompt(study),
|
|
"reference": {
|
|
"file": STUDIES[study][0],
|
|
"note": "local reference photography; Art/reference is gitignored and never shipped",
|
|
},
|
|
"sha256": hashlib.sha256(image).hexdigest(),
|
|
"bytes": len(image),
|
|
"redistribution": "local development reference only; not committed, not shipped",
|
|
}
|
|
sidecar_path = os.path.splitext(path)[0] + ".refgen.json"
|
|
with open(sidecar_path, "w", encoding="utf-8") as handle:
|
|
json.dump(sidecar, handle, indent=2)
|
|
if token in json.dumps(sidecar):
|
|
raise SystemExit("refusing to write a sidecar containing the token")
|
|
return path
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--list", action="store_true", help="list studies and exit (free)")
|
|
ap.add_argument("--all", action="store_true", help="generate every study")
|
|
ap.add_argument("--only", help="comma-separated study names")
|
|
ap.add_argument("--model", default=DEFAULT_MODEL)
|
|
ap.add_argument("--force", action="store_true",
|
|
help="replace the existing <study>.png instead of writing a -vN")
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.list or not (args.all or args.only):
|
|
missing = set(missing_references())
|
|
print(f"{len(STUDIES)} studies; model {args.model}")
|
|
for name, (ref, _prompt) in STUDIES.items():
|
|
state = "MISSING reference" if name in missing else "ready"
|
|
print(f" {name:22} {state:18} <- {ref}")
|
|
if missing:
|
|
print(f"\n{len(missing)} study(ies) cannot run without their reference photograph.")
|
|
return 0
|
|
|
|
names = list(STUDIES) if args.all else [s.strip() for s in args.only.split(",") if s.strip()]
|
|
unknown = [n for n in names if n not in STUDIES]
|
|
if unknown:
|
|
raise SystemExit(f"unknown study(ies): {unknown}; known: {sorted(STUDIES)}")
|
|
blocked = [n for n in names if n in set(missing_references())]
|
|
if blocked:
|
|
raise SystemExit(f"missing reference photograph for: {blocked}")
|
|
token = load_token()
|
|
|
|
print(f"generating {len(names)} study(ies) with {args.model} -> {OUT_DIR}")
|
|
for index, name in enumerate(names, 1):
|
|
print(f"[{index}/{len(names)}] {name}")
|
|
try:
|
|
path = generate(name, token, args.model, force=args.force)
|
|
except SystemExit as exc:
|
|
sys.stderr.write(f" FAILED: {exc}\n")
|
|
continue
|
|
print(f" wrote {os.path.relpath(path, ROOT)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|