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
@@ -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