reikhelm/tools/serve.py
Parley Hatch ed00e31009 feat(render): real-geometry depth export + first-person explorable atlas
Render actual generated dungeons through the AI pipeline — both top-down and
first-person (Eye-of-the-Beholder style) — instead of synthetic test depth.
All renderer-side: derived from the envelope's tiles+regions+theme, the core
stores none of it.

- depth.js: top-down height-field depth exporter (walls raised, pools recessed,
  pillars as bumps, subtle per-theme relief), calibrated to the proven room_depth
  levels so one tall room can't crush every floor dark.
- fpv.js: first-person depth via a Wolfenstein-style grid raycaster (16:9),
  reports straight-ahead distance for opening detection.
- explore.js: room->room nav graph derived from REAL tile openings (scan
  boundary gaps, bin by cardinal, flood the corridor to the destination room) —
  nav + open share one source of truth with the rendered passages.
- bake_atlas.py: bakes per-room x4-facing pixel-art frames; per-theme + per-facing
  (wall vs passage) prompts, fixed diffusion seed for cross-frame style coherence,
  per-run unique prefixes so re-bakes don't silently skip.
- explore.html + explore-viewer.js: WASD first-person dungeon explorer
  (room-to-room movement, turning, minimap), integer-pixel fullscreen.
- serve.py: stdlib dev server — static web root + /out tree + POST /save, no-store.
- main.js/render.js/style.css: export hooks, the depth button, distanceToWall export.
- tools/README.md: Stage 0/1/2/3 docs; .dev/2026-06-01-fpv-prompts.md: prompt
  research (cfg-1 negatives are inert; trigger words summon hands; empty-ruin
  reframe; fixed seed = consistency).

Proven end-to-end on seed 7 (17 rooms). Outputs organized under git-ignored
tools/out/. Control strength: 0.80-0.85 top-down, 0.85 first-person.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:02:14 -06:00

105 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Dev server for the reikhelm playground, headless depth/FPV export, and the
pre-baked first-person explorer. Pure stdlib — no deps.
python3 tools/serve.py # serve reikhelm-web on :8000
Three things, one server:
GET /<path> static file from the web root (reikhelm-web/)
GET /out/<path> static file from the output tree (tools/out/) — lets the
explorer load baked atlas frames + manifests
POST /save/<path>.png write the raw request body (a PNG) under tools/out/<path>;
sub-dirs allowed and created (e.g. atlas/7/r3_N.png)
{"ok": true, "path": "...", "bytes": N}
All generated images live under tools/out/ (git-ignored, organized):
out/depth/ top-down + one-off depth maps out/fpv/ first-person stills
out/atlas/<seed>/ a baked explorable dungeon (frames + manifest.json)
"""
import argparse
import json
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def safe_join(base, rel):
"""Join rel under base, collapsing any '..' so it can't escape base."""
rel = os.path.normpath("/" + rel).lstrip("/")
return os.path.join(base, rel)
class Handler(SimpleHTTPRequestHandler):
out_dir = "."
extensions_map = {
**SimpleHTTPRequestHandler.extensions_map,
".js": "text/javascript",
".mjs": "text/javascript",
".wasm": "application/wasm",
".json": "application/json",
}
# Route /out/<path> to the output tree; everything else from the web root (cwd).
def translate_path(self, path):
p = unquote(path.split("?", 1)[0].split("#", 1)[0])
if p == "/out" or p.startswith("/out/"):
return safe_join(self.out_dir, p[len("/out/"):]) if p != "/out" else self.out_dir
return super().translate_path(path)
def end_headers(self):
self.send_header("Cache-Control", "no-store") # dev server: always serve fresh modules
super().end_headers()
def do_POST(self):
if not self.path.startswith("/save/"):
self.send_error(404, "only /save/<path>.png is supported")
return
rel = unquote(self.path[len("/save/"):])
if not (rel.endswith(".png") or rel.endswith(".json")):
self.send_error(400, "path must end in .png or .json")
return
dest = safe_join(self.out_dir, rel)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(body)
payload = json.dumps({"ok": True, "path": dest, "bytes": len(body)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
sys.stderr.write(f"saved {os.path.relpath(dest, REPO)} ({len(body)} bytes)\n")
def log_message(self, fmt, *args): # quiet GETs; POSTs/errors print via do_POST/send_error
pass
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--root", default=os.path.join(REPO, "reikhelm-web"), help="static web root")
ap.add_argument("--out", default=os.path.join(REPO, "tools", "out"), help="output tree (served at /out, target of /save)")
args = ap.parse_args()
Handler.out_dir = os.path.abspath(args.out)
os.makedirs(Handler.out_dir, exist_ok=True)
root = os.path.abspath(args.root)
os.chdir(root)
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
print(f"serving {os.path.relpath(root, REPO)} at http://127.0.0.1:{args.port}")
print(f" /out/ → {os.path.relpath(Handler.out_dir, REPO)} POST /save/<path>.png writes there")
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()
if __name__ == "__main__":
main()