feat(tools): AI detail plates from the reference photography; fix floating windows

Adds Tools/facade_detail_concepts.py: generates architectural detail plates from a
local reference photograph through OpenRouter's image models. Spec-driven (eight
studies: door recess, entrance, column order, entablature/cornice, parapet/motto,
window rhythm, street wall, banner), never overwrites (writes -vN), writes a
.refgen.json sidecar (model, prompt, source photo, SHA-256) per image, and keeps
all output under the gitignored Art/reference/facade-details/ - local only, never
shipped. Stdlib only; http 429/5xx back-off; refuses to write a sidecar containing
the token. 11 tests cover the study table, prompt assembly, next-free naming,
data-URL decoding (both response shapes plus a refusal) and the local-only rule.

Measurement pass on the plates: the hero window panes were at Y -1.98, i.e. 1.44 m
in FRONT of the wall (Y -0.50) and ahead of the column line, so the glazing floated
in the colonnade instead of reading as openings behind it. Moved to Y -0.56; hero
rebuilt, re-imported, DemoMap rebuilt, full screenshot set re-rendered.

Docs: Tools/README row, Screenshots README known-issues entry now records the fix.
This commit is contained in:
zwitschi committed 2026-11-08 17:27:27 +01:00
1 parent 8851b2272e
commit abace2ad05
32 files changed
+487 -45

No files matched your search

+7
View File
@@ -60,6 +60,13 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes) and a ground-referenced wall probe (0/35), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. |
| `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. |
## Reference and concept generation
| Tool | Purpose |
| --- | --- |
| `facade_detail_concepts.py` | Generates architectural detail plates from a local reference photograph with an image model (OpenRouter). `--list` is free and shows which studies have their reference present; `--all`/`--only <name>` generates. Never overwrites (writes `-v2`, `-v3`), writes a `.refgen.json` sidecar per image (model, prompt, source photo, SHA-256), and puts everything under `Art/reference/facade-details/` — gitignored, local-only, never shipped. Costs money per image, so it is never automatic. |
| `judge_screenshot.py` | (see Visual review) |
## Input injection
| Tool | Purpose |
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""Generate architectural detail studies 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
architectural detail plate — no crowd, no staging, straight-on, evenly lit — so
the modelling pass has something to measure against instead of a tourist photo.
python3 Tools/facade_detail_concepts.py --list
python3 Tools/facade_detail_concepts.py --only door-recess,parapet-motto
python3 Tools/facade_detail_concepts.py --all
python3 Tools/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 fixed source of geometry and "
"composition: keep the building's proportions, column rhythm, band heights, "
"signage placement and camera angle exactly as photographed. Only restage "
"lighting, weather, people and surface condition where the request asks for "
"it. Do not invent extra storeys, wings or ornaments that the photo does not "
"show."
)
# name -> (reference photo relative to Art/reference/, what the study must show)
STUDIES: dict[str, tuple[str, str]] = {
"door-recess": (
"el-capitan-entrance/01-hollywood-el-capitan-entrance-detail.jpg",
"A clean architectural detail plate of exactly the subject in the "
"reference photograph. Render it as an even-daylight stone study: crisp "
"surface carving, readable depth of every recess and moulding, no people, "
"no signage or banners in front of it, no shop clutter, straight-on to the "
"subject.",
),
"entrance-wide": (
"el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg",
"A straight-on architectural study of the street entrance in the "
"reference photograph, the portal group centred and filling the frame, "
"threshold and pavement readable, no pedestrians or vehicles, even "
"overcast daylight, stone surface legible.",
),
"column-order": (
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"A close architectural study of the column order visible in the "
"reference photograph: the base, shaft and capital read clearly, "
"neighbouring columns framing it. Straight-on, evenly lit, stone surface "
"and carving legible, no people or banners.",
),
"entablature-cornice": (
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"An architectural detail study of the horizontal stone bands above the "
"columns in the reference photograph: every band edge and the projecting "
"cornice with its shadow line. The layered depth of the stone must be "
"readable, flat even light, no people.",
),
"parapet-motto": (
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"An architectural study of the parapet above the colonnade in the "
"reference photograph: the inset panel, any round medallions beside it "
"and the coping at the top. Lettering on the panel is blocked out as "
"unreadable texture. Flat even light, no people.",
),
"window-rhythm": (
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"An architectural study of the wall surface behind and between the "
"columns in the reference photograph: the opening or window rhythm and "
"the reveal depth. Straight-on, no people, no banners covering the wall.",
),
"street-wall": (
"hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg",
"A clean street-wall study from the opposite pavement, as framed in the "
"reference photograph: the building line, the parapet heights of the "
"neighbouring buildings, the shopfront line and the pavement depth. No "
"crowd, even daylight.",
),
"banner-detail": (
"el-capitan-premiere/02-premiere-of-spielberg-s-west-side-story---el-capitan---holly.jpg",
"An architectural study of the vertical banner in the reference "
"photograph: how it is fixed, its proportions against the column behind "
"it, and how it sits against the stone. Daylight, no crowd.",
),
}
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())
+8 -1
View File
@@ -245,10 +245,17 @@ def build():
# --- windows -----------------------------------------------------------
# Two ranks of windows between the columns and behind the colonnade.
# Y must sit on the wall plane: the mass front face is at Y -0.50 and the
# columns stand proud to -1.02. These were at -1.98, i.e. 1.44 m in FRONT of
# the wall and ahead of the column line, so the glazing floated in the
# colonnade instead of reading as openings behind it (visible in the
# review-hero-facade-* shots). WINDOW_Y puts the pane 6 cm proud of the wall,
# safely behind the columns.
WINDOW_Y = -0.56
for rank, z in enumerate((4.4, 8.2), start=1):
for i, x in enumerate(col_xs[:-1], start=1):
wx = x + COLUMN_BAY / 2.0
cube(f"Hero_Window_{rank}_{i}", wx, -1.98, z, 2.0, 0.08, 1.9, glass)
cube(f"Hero_Window_{rank}_{i}", wx, WINDOW_Y, z, 2.0, 0.08, 1.9, glass)
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
return parts, lines
+108
View File
@@ -0,0 +1,108 @@
"""Tests for Tools/facade_detail_concepts.py — the pure parts.
No network calls: the OpenRouter request and the image bytes are represented as
the small structures the API uses. Generation itself is manual and paid.
"""
import base64
import json
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "Tools"))
import facade_detail_concepts as fc # noqa: E402
class StudyTableTest(unittest.TestCase):
def test_every_study_has_a_reference_and_a_prompt(self):
self.assertTrue(fc.STUDIES)
for name, (ref, prompt) in fc.STUDIES.items():
self.assertTrue(ref, f"{name} has no reference")
self.assertTrue(prompt.strip(), f"{name} has no prompt")
# References are relative to Art/reference and never escape it.
self.assertFalse(os.path.isabs(ref), f"{name} reference is absolute")
self.assertNotIn("..", ref, f"{name} reference escapes the directory")
def test_prompt_always_carries_the_anchor_suffix(self):
for name in fc.STUDIES:
prompt = fc.build_prompt(name)
self.assertIn(fc.ANCHOR_SUFFIX, prompt)
self.assertIn(fc.STUDIES[name][1], prompt)
def test_unknown_study_raises(self):
with self.assertRaises(KeyError):
fc.build_prompt("no-such-study")
class NextFreePathTest(unittest.TestCase):
def test_uses_the_plain_name_when_free(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x.png")
def test_never_overwrites(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
open(os.path.join(tmp, "x.png"), "w").close()
self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x-v2.png")
open(os.path.join(tmp, "x-v2.png"), "w").close()
self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x-v3.png")
class DecodeImageTest(unittest.TestCase):
PAYLOAD_BYTES = b"\x89PNG\r\n\x1a\nfake"
def _data_url(self):
return "data:image/png;base64," + base64.b64encode(self.PAYLOAD_BYTES).decode()
def test_reads_message_images(self):
payload = {"choices": [{"message": {
"images": [{"image_url": {"url": self._data_url()}}]}}]}
self.assertEqual(fc.decode_image(payload), self.PAYLOAD_BYTES)
def test_reads_a_data_url_embedded_in_content(self):
payload = {"choices": [{"message": {"content": f"here: {self._data_url()}"}}]}
self.assertEqual(fc.decode_image(payload), self.PAYLOAD_BYTES)
def test_refusal_raises_with_the_text(self):
payload = {"choices": [{"message": {"content": "I cannot do that."}}]}
with self.assertRaises(SystemExit):
fc.decode_image(payload)
class DataUrlTest(unittest.TestCase):
def test_encodes_a_jpeg(self):
import tempfile
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as handle:
handle.write(b"jpegbytes")
path = handle.name
try:
url = fc.data_url(path)
self.assertTrue(url.startswith("data:image/jpeg;base64,"))
finally:
os.unlink(path)
def test_rejects_an_unsupported_type(self):
import tempfile
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as handle:
path = handle.name
try:
with self.assertRaises(SystemExit):
fc.data_url(path)
finally:
os.unlink(path)
class SidecarShapeTest(unittest.TestCase):
def test_studies_and_outputs_are_local_only(self):
# The output directory lives under the gitignored Art/reference tree, so
# neither the images nor the sidecars can be committed.
self.assertIn(os.path.join("Art", "reference"), fc.OUT_DIR)
self.assertIn("gitignored", fc.__doc__)
if __name__ == "__main__":
unittest.main()