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:
1 parent
8851b2272e
commit
abace2ad05
32 files changed
+487
-45
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -58,5 +58,6 @@ Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs t
|
||||
|
||||
- Floor speckle from the `Constant3Vector` wiring in `Tools/ue_build_review_map.py`.
|
||||
- Hero façade renders pale — Blender flat colours with no normal/roughness, so it reads as untextured stone (the `0.1.4` façade patch, not a lighting bug).
|
||||
- Banners and window panels float detached from the colonnade wall (next modelling pass).
|
||||
- Banners sit on the column line rather than the wall (acceptable: they hang on the columns in the photographs).
|
||||
- ~~Banners and window panels float detached from the colonnade wall~~ — **window panes fixed 2026-11-09**: they sat at Y −1.98, 1.44 m in *front* of the wall and ahead of the column line; they now sit on the wall plane at Y −0.56, behind the columns, and read as dark openings (see `review-hero-facade-front`).
|
||||
- Between kerb and building line, the walkable surface is the terrain mesh, not a modelled pavement.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,7 +16,7 @@ Suggested blender skills: **references** (reference-image-match and visual-match
|
||||
### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `scale-facade-0.1.2`)
|
||||
|
||||
- [x] Correct building scale and street proportions flagged against references. **Mapbox adopted 2026-11-09.** `Geo/tools/mapbox_buildings.py` merges our OSM heights with Mapbox's building data for the same footprints; after adopting its two findings the cross-check reports **66 match, 6 non-building, 0 disagreements**. Adopted: **`LEVEL_HEIGHT` 3.2 → 3.1 m** (`block_height_report.py`, and the Blosm `--level-height` default), plus explicit `HEIGHT_OVERRIDES` (3.0 m) for the three rear `building=yes`/`apartments` structures Mapbox gives as single-storey. Blockout regenerated, frontage re-exported/re-imported, DemoMap rebuilt; packaged `-qaselftest` all PASS. Measured effect: 6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m (both now ~1.9 m above their table height = roof mass).
|
||||
- [ ] Refine hero façade details where reference photography shows discrepancies.
|
||||
- [ ] Refine hero façade details where reference photography shows discrepancies. **Started 2026-11-09:** built `Tools/facade_detail_concepts.py`, which generates architectural detail plates from the local reference photographs with an image model (`--list` free, `--all` generates, sidecars + `-vN` no-overwrite, output gitignored). Eight studies generated locally under `Art/reference/facade-details/`: door recess, entrance, column order, entablature/cornice, parapet/motto, window rhythm, street wall, banner. **First fix from the measurement pass:** window panes were 1.44 m in front of the wall (ahead of the columns) and now sit on the wall plane behind the colonnade. Remaining: use the plates to correct the capital/base profiles and the medallion/cornice detail.
|
||||
- [x] **Replace the placeholder heights.** **Resolved 2026-11-08 — they were never unknown buildings.** The three "guessed" ways are not building volumes at all: `1360755125` and `1360755126` are `building=roof` at `layer=1` (elevated canopies) and `1417995292` is `amenity=shelter` / `shelter_type=public_transport` (a transit shelter). The report defaulted them to "2 levels × 3.2 m" and produced the bogus 6.4 m. `Geo/tools/block_height_report.py` now classifies roof/shelter explicitly and reports **0 guessed heights**: all 15 block volumes have a real `height` tag, a `building:levels` derivation, or are correctly excluded as non-building volumes. No photograph measurement was needed, and no frontage building was affected.
|
||||
- [x] Resolve the remaining unknown on the hero: the colonnade currently occupies about 45 % of the height against roughly 50–60 % in the photographs. Fix the vertical proportions. **Fixed 2026-11-09.** Measured baseline: colonnade (columns + capitals) 6.90 m of the 14.50 m envelope = **47.6 %**, because the entablature (3.40 m) and parapet (2.20 m) left only 7.90 m for the order. Thinned both to **3.00 m** and **1.80 m** (`Tools/hero_facade_build.py`), which lifts the colonnade to **7.70 m = 53.1 %** — inside the photographed 50–60 % band, with the 14.50 m envelope, the motto band and the medallions unchanged in position relative to the parapet. Hero rebuilt, re-imported (bounds 2604 × 1257 × 1450 cm) and re-rendered.
|
||||
- [ ] Tag `v0.1.2` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user