#!/usr/bin/env python3
"""s27 — 숲 지도 + 중요 영역 상세화 스케치 (TASK 2, 2026-07-08, 커밋 금지).

사용자 지시:
  ① 씬 정보(공간 단서 그래프) 취합 → LLM 이 '숲(전체 포괄) 지도' 저작 —
     구글지도에서 촬영지를 부분 캡쳐한 형태, 꼭 필요한 주변 물체 포함.
     스타일 = TASK 1 산출 '경찰서' FP 도면 스타일 그대로 (육안 확인 완료:
     흰 배경·얇은 검정 윤곽·파스텔 존 채색·원형 마커·존 영문 라벨·N 나침반).
     마커 = 알파벳+한자리 숫자 (A–F 사용).
  ② 그 지도 기반 샷 중요 영역(옥탑방·건물·진입로 등) 상세화 스케치 —
     마커 = 알파벳+한자리 숫자, ①과 완전 상이 (P–W 사용).
  구성(사용자 확정): ①지도 i2/nb2 2장 + ②상세 = 지도 계보 × 엔진 교차 4장.
규칙: 시나리오 중립(코드에 작품 고유명사·ID 없음, 씬 사실=데이터 주입) ·
     ID-free 서술 · 육안 판정=사용자 · 갤러리 8897.
사용: backend/.venv/bin/python s27_forest_map.py [--only spec|maps|details|html|all]
산출: out/forest_map/*.png + plans/forest_map_spec_v1.json + forest_map.html
"""
import argparse
import html as _html
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import forest_lib as F  # noqa: E402

OUT = F.OUT / "forest_map"
PAGE = F.EXP / "forest_map.html"
SPEC = "forest_map_spec_v1"
STYLE_FP = F.EXP / "out" / "fp_v16corr" / "fp_l14_police_front.png"

MAP_GPT, MAP_NB2 = "map_i2.png", "map_nb2.png"
DETAILS = [  # (지도 계보, 엔진, 파일)
    ("i2", "i2", "detail_mapi2_i2.png"),
    ("i2", "nb2", "detail_mapi2_nb2.png"),
    ("nb2", "i2", "detail_mapnb2_i2.png"),
    ("nb2", "nb2", "detail_mapnb2_nb2.png"),
]

# ── 스타일 계약 (경찰서 FP 육안 확인 기반 + s15 STYLE_2D 어휘 조정) ──
STYLE_MAP = "\n".join([
    "Drawing style: a clean flat 2D architectural site map, straight-down",
    "orthographic view — the exact drafting language of the attached style",
    "reference: pure white page background, thin dark outlines, flat muted",
    "pastel colour fills per zone (pale yellow, pale cyan, pale blue, light",
    "tan, soft green, light grey), heavier dark strokes for building",
    "outer walls, simple flat plan symbols. NO 3D, no axonometric tilt, no",
    "perspective, no shadows, no photo textures, no people, no story",
    "props.",
    "Markers: ONLY the circled markers listed below — each is a small",
    "white circle with a thin black ring containing a short code (one",
    "capital letter + one digit). Place each circle exactly on its listed",
    "target. Besides these circles, the only text allowed is the short",
    "English zone labels listed below and a small 'N' north arrow at the",
    "top-right corner. No other words, numbers, legend or title.",
])

STYLE_REF_NOTE = "\n".join([
    "The LAST attached image is a site plan from the same production —",
    "use it ONLY as the DRAFTING-STYLE reference (line weight, pastel",
    "fill language, circled-marker look, label typography). Its place and",
    "layout have NOTHING to do with this map — never copy its content.",
])

MAP_FORM = "\n".join([
    "Format: like a partial screen capture from an online map app of one",
    "small neighbourhood block, but redrawn as an architectural site map.",
    "Streets run off the frame edges; unmarked neighbour buildings are",
    "simple flat grey blocks; the filming property is highlighted with a",
    "dashed orange boundary and slightly warmer fills so it reads as the",
    "subject of the capture.",
])


def _evidence_payload():
    g = F.load_plan("spatial_evidence_graph_v1")
    return json.dumps({"claims": g["claims"], "chain": g["chain"]},
                      ensure_ascii=False)


# ── Stage A: LLM 지도/상세 스펙 저작 ──
SPEC_SYSTEM = "\n".join([
    "You are a location scout and drafting supervisor for a film",
    "production. From the given spatial-evidence graph (verbatim quotes",
    "from the screenplay with evidence ids), you author the CONTENT SPEC",
    "for two drawings of the single filming location and its",
    "surroundings. Evidence-bound: every item must restate a cue from the",
    "graph (cite evidence_id) or be a directly-derived necessity of one",
    "(mark inferred=true). Do not invent weather, atmosphere, materials",
    "or measurements beyond the cues. Where cues are silent, stay silent.",
    "All names/descriptions in English, fully ID-free and neutral: no",
    "character codes, no proper nouns from the work — use generic",
    "descriptors ('the rooftop-room building', 'the alley', ...).",
])

SPEC_TASK = "\n".join([
    "Author two specs:",
    "1) NEIGHBOURHOOD MAP ('the forest') — one small-town block around",
    "   the filming property, like a partial map-app capture: the",
    "   surrounding street/alley network, the filming property (the",
    "   building that carries the rooftop room, its yard, gate, external",
    "   stair position), and only the surrounding objects the evidence",
    "   makes necessary (e.g. neighbour low-rise blocks, the spot where",
    "   a car can stop on the alley, a low wall across, sea/harbour edge",
    "   only if evidence supports proximity). Assign each marked item a",
    "   code = one capital letter A-F + one digit 1-9 (e.g. A1, B2).",
    "   Also list 2-4 short English zone labels (like 'Alley', 'Yard').",
    "2) DETAIL SKETCH of the shot-critical areas ('the tree') — a zoomed",
    "   plan of the filming property itself: the building footprint with",
    "   the rooftop deck contents (water tank, clothesline, wooden bench,",
    "   iron door, rooftop-room unit with its window), the external",
    "   stair run with its landing, the yard with its secluded corner,",
    "   the gate, the approach from the alley. Assign each marked item a",
    "   code = one capital letter P-W + one digit 1-9 — codes must share",
    "   NOTHING with the map codes.",
    "For each spec also write layout_narration_en: 6-10 sentences that",
    "walk a draftsman through the whole drawing top-to-bottom (relative",
    "positions only, no coordinates, no invented distances).",
])

ITEM = {
    "type": "object",
    "properties": {
        "code": {"type": "string"},
        "name_en": {"type": "string"},
        "kind": {"type": "string"},
        "placement_en": {"type": "string"},
        "evidence_id": {"type": "string"},
        "inferred": {"type": "boolean"},
    },
    "required": ["code", "name_en", "kind", "placement_en", "evidence_id",
                 "inferred"],
    "additionalProperties": False,
}
SPEC_SCHEMA = {
    "type": "object",
    "properties": {
        "map": {
            "type": "object",
            "properties": {
                "items": {"type": "array", "items": ITEM},
                "zone_labels_en": {"type": "array",
                                   "items": {"type": "string"}},
                "layout_narration_en": {"type": "string"},
            },
            "required": ["items", "zone_labels_en", "layout_narration_en"],
            "additionalProperties": False,
        },
        "detail": {
            "type": "object",
            "properties": {
                "items": {"type": "array", "items": ITEM},
                "zone_labels_en": {"type": "array",
                                   "items": {"type": "string"}},
                "layout_narration_en": {"type": "string"},
            },
            "required": ["items", "zone_labels_en", "layout_narration_en"],
            "additionalProperties": False,
        },
    },
    "required": ["map", "detail"],
    "additionalProperties": False,
}


def stage_spec():
    user = "\n\n".join([
        SPEC_TASK,
        "SPATIAL EVIDENCE GRAPH (verbatim, full):",
        _evidence_payload(),
    ])
    spec = F.llm("s27_map_spec", SPEC_SYSTEM, user, SPEC_SCHEMA)
    # 마커 계약 코드 검증 (형식만 — 의미 판단 아님)
    import re
    for part, letters in (("map", "ABCDEF"), ("detail", "PQRSTUVW")):
        for it in spec[part]["items"]:
            assert re.fullmatch(rf"[{letters}][1-9]", it["code"]), \
                f"{part} 코드 계약 위반: {it['code']}"
    codes_map = {i["code"] for i in spec["map"]["items"]}
    codes_det = {i["code"] for i in spec["detail"]["items"]}
    assert not (codes_map & codes_det), "①/② 마커 코드 중복"
    F.save_plan(SPEC, spec)
    print(f"[spec] map items={len(spec['map']['items'])} "
          f"detail items={len(spec['detail']['items'])} → plans/{SPEC}")


def _marker_block(items):
    return "\n".join(
        f"- ({it['code']}) {it['name_en']} — {it['placement_en']}"
        for it in items)


def _map_prompt(spec):
    m = spec["map"]
    return "\n\n".join([
        "Draw ONE neighbourhood site map of a filming location.",
        MAP_FORM,
        "LAYOUT (the content ground truth):\n" + m["layout_narration_en"],
        "CIRCLED MARKERS (all of them, nothing else):\n"
        + _marker_block(m["items"]),
        "Allowed zone labels: " + ", ".join(m["zone_labels_en"]),
        STYLE_MAP,
        STYLE_REF_NOTE,
    ])


def _detail_prompt(spec):
    d = spec["detail"]
    return "\n\n".join([
        "Draw ONE detailed top-down architectural sketch of the filming",
        "property shown inside the dashed boundary of the FIRST attached",
        "image (a neighbourhood map). Zoom into that property so the",
        "building, its rooftop deck, the external stair, the yard and the",
        "approach fill the frame; keep every block's relative position,",
        "proportion and opening exactly where the map draws them. This is",
        "a zoomed REDRAW of that map area, not a copy of the map.",
        "LAYOUT DETAIL (the content ground truth):\n"
        + d["layout_narration_en"],
        "CIRCLED MARKERS (all of them, nothing else — these codes replace",
        "the map's codes entirely):\n" + _marker_block(d["items"]),
        "Allowed zone labels: " + ", ".join(d["zone_labels_en"]),
        STYLE_MAP,
        STYLE_REF_NOTE,
    ])


def stage_maps():
    spec = F.load_plan(SPEC)
    prompt = _map_prompt(spec)
    F.img_gpt("s27_map_i2", prompt, refs=[STYLE_FP], size="1024x1024",
              out_path=OUT / MAP_GPT)
    F.img_nb2("s27_map_nb2", prompt, labeled_refs=[
        ("DRAFTING-STYLE REFERENCE ONLY — copy line/fill/marker/label "
         "style, never the content", STYLE_FP)],
        aspect_ratio="1:1", out_path=OUT / MAP_NB2)
    print("[maps] 2장 완료 →", OUT)


def stage_details():
    spec = F.load_plan(SPEC)
    prompt = _detail_prompt(spec)
    maps = {"i2": OUT / MAP_GPT, "nb2": OUT / "map_nb2_r2.png"}  # r2=평면 강제 재시도 채택
    for lineage, engine, fname in DETAILS:
        src = maps[lineage]
        if engine == "i2":
            F.img_gpt(f"s27_detail_{lineage}_{engine}", prompt,
                      refs=[src, STYLE_FP], size="1024x1024",
                      out_path=OUT / fname)
        else:
            F.img_nb2(f"s27_detail_{lineage}_{engine}", prompt, labeled_refs=[
                ("NEIGHBOURHOOD MAP — the layout source of truth; zoom into"
                 " the dashed filming property of THIS map", src),
                ("DRAFTING-STYLE REFERENCE ONLY — copy line/fill/marker/"
                 "label style, never the content", STYLE_FP)],
                aspect_ratio="1:1", out_path=OUT / fname)
    print("[details] 4장 완료 →", OUT)


def _sketch_prompt(spec):
    d = spec["detail"]
    return "\n\n".join([
        "Convert the attached top-down architectural plan (the FIRST",
        "attached image — the layout source of truth) into ONE 45-degree",
        "high-angle aerial PERSPECTIVE SKETCH of the same filming",
        "property.",
        "Camera: elevated three-quarter aerial view from the alley side",
        "(the plan's bottom edge), high enough that the whole property —",
        "alley approach, gate, yard, external stair, the building mass and",
        "its rooftop deck with the rooftop-room unit — fits in one frame,",
        "looking across the yard toward the building.",
        "Geometry: keep every structure's position, footprint, proportion",
        "and opening exactly where the plan draws them — the plan fixes",
        "the LAYOUT only; the VERTICAL SCALE comes from the block below.",
        "SCALE & MASSING (evidence-bound ground truth):",
        "- The main building is an aged MULTI-FAMILY VILLA of THREE full",
        "  storeys — its roof deck sits roughly 8-10 m above the yard.",
        "  NOT a single-storey building.",
        "- The rooftop-room unit stands on that roof deck as a small",
        "  fourth-level volume, clearly smaller than the floors below.",
        "- The exterior stair is a later-added steel retrofit stair that",
        "  climbs the FULL three storeys along the outer wall from the",
        "  yard to the rooftop landing — a long multi-flight zig-zag run",
        "  with intermediate landings, never a short single flight. Open",
        "  treads, thin steel plate stringers, support posts, pipe",
        "  railings (creator-confirmed fact).",
        "- Each of the three facade storeys shows a few modest windows of",
        "  the regional multi-family-villa type; the rooftop room keeps",
        "  its own small windows exactly as the plan places them.",
        "- Surrounding neighbour blocks stay dense low-rise context,",
        "  generally lower than or similar to the villa.",
        "Do not invent weather, atmosphere, materials or measurements",
        "beyond this.",
        "LAYOUT DETAIL (the content ground truth):\n"
        + d["layout_narration_en"],
        "CIRCLED MARKERS: reproduce EVERY circled code below as a small",
        "white circle with a thin black ring, attached to the same element",
        "in the sketch:\n" + _marker_block(d["items"]),
        "Media: clean monochrome concept sketch — confident dark",
        "ink/pencil linework, light hatching for shade, plain white",
        "background. No colour fills, no photo textures, no people, no",
        "vehicles. Besides the circled codes there is NO text at all — no",
        "zone labels, no notes, no compass, no title.",
        "The result must be a PERSPECTIVE drawing — not a top-down plan.",
    ])


SKETCHES = [(fname, fname.replace("detail_", "").replace(".png", ""))
            for _, _, fname in DETAILS]  # (src fp 파일, key)


def stage_sketches():
    spec = F.load_plan(SPEC)
    prompt = _sketch_prompt(spec)
    for src_name, key in SKETCHES:
        src = OUT / src_name
        F.img_gpt(f"s27_sketch_{key}_i2", prompt, refs=[src],
                  size="1024x1024", out_path=OUT / f"sketch_{key}_by_i2.png")
        F.img_nb2(f"s27_sketch_{key}_nb2", prompt, labeled_refs=[
            ("ARCHITECTURAL PLAN — the layout source of truth for the"
             " perspective sketch", src)],
            aspect_ratio="1:1", out_path=OUT / f"sketch_{key}_by_nb2.png")
    print("[sketches] 8장 완료 →", OUT)


PHOTO_SRC = "sketch_mapi2_nb2_by_nb2.png"  # 사용자 지목 참조(nb2 스케치)
FP_SRC = "detail_mapi2_nb2.png"  # PHOTO_SRC 스케치의 소스 상세 FP (같은 계보)


def _legend(spec):
    return "\n".join(
        f"- ({it['code']}) = {it['name_en']} — {it['placement_en']}"
        for it in spec["detail"]["items"])


NO_ANNOTATION = "\n".join([
    "The output image must contain ZERO annotations — no circles, no",
    "codes, no letters, no numbers, no text, no signage lettering, no",
    "arrows, no dashed lines.",
])

REAL_FACTS = "\n".join([
    "Real-world rendering facts (creator-confirmed / evidence-bound):",
    "- The main building is an aged MULTI-FAMILY VILLA of three full",
    "  storeys (roof deck roughly 8-10 m above the yard) with the small",
    "  rooftop-room unit on its roof deck — regional vernacular materials",
    "  with varied lived-in aging.",
    "- The exterior stair is a later-added STEEL retrofit stair climbing",
    "  the full three storeys: open treads, thin steel plate stringers,",
    "  support posts, pipe railings. It is NOT a concrete stair.",
    "Lighting: plain neutral daytime light. Do NOT add fog, rain,",
    "dramatic weather, colour-grade moods, or people. No text overlays.",
])


def _photo_prompt(spec):
    return "\n\n".join([
        "Create ONE PHOTOREALISTIC location still using the attached",
        "perspective SKETCH as reference. The sketch is ONLY the",
        "STRUCTURAL SKELETON: it fixes the camera (same 45-degree high",
        "aerial view), and each structure's position, size and proportion.",
        "Everything else must be FLESHED OUT as the real world: real",
        "surfaces, real materials, real weathering — and the SIMPLIFIED",
        "SURROUNDINGS in the sketch (bare neighbour blocks, empty roofs,",
        "blank walls, plain ground) must be AUGMENTED into fully real",
        "buildings and streets: real neighbouring houses of a dense",
        "low-rise Korean town with their own windows, doors, rooftop",
        "clutter, awnings, utility poles and wires, pavement texture —",
        "nothing in the frame may look like an untextured model or a",
        "simplified block.",
        "MARKER LEGEND (annotations on the sketch only): the sketch",
        "carries small circled codes identifying elements. Use this",
        "legend to know what each element is, then render the real",
        "thing:\n" + _legend(spec),
        NO_ANNOTATION,
        REAL_FACTS,
        "The parked bicycle from the sketch stays; add no other vehicles.",
    ])


def _x_prompt(spec):
    d = spec["detail"]
    return "\n\n".join([
        "Create ONE PHOTOREALISTIC aerial location still of a filming",
        "property, from a 45-degree high aerial camera looking across the",
        "yard toward the building (whole property in frame). No reference",
        "image is attached — build the scene from this text alone.",
        "LAYOUT (ground truth):\n" + d["layout_narration_en"],
        "ELEMENTS (render each as the real thing, in its listed place):\n"
        + _legend(spec),
        "SCALE & MASSING: the main building is a three-storey aged",
        "multi-family villa, roof deck roughly 8-10 m above the yard,",
        "with the small rooftop-room unit as a fourth-level volume on the",
        "deck; the steel retrofit stair climbs the full three storeys as",
        "a multi-flight zig-zag run with intermediate landings; each",
        "facade storey shows a few modest windows of the regional",
        "multi-family type; surrounding blocks are a dense low-rise",
        "Korean town, fully real (windows, roofs, wires, pavement).",
        NO_ANNOTATION,
        REAL_FACTS,
    ])


def _xsketch_prompt(spec):
    return "\n\n".join([
        "Draw ONE monochrome perspective CONCEPT SKETCH from the two",
        "attached images.",
        "Reference roles:",
        "- The PHOTO is the reality reference — what the place, its",
        "  materials and its surroundings actually look like.",
        "- The SKETCH is the structural skeleton — camera, positions,",
        "  proportions and openings. On conflict the SKETCH's structure",
        "  wins; the photo only fleshes out detail.",
        "Output media: clean monochrome concept sketch — confident dark",
        "ink/pencil linework, light hatching for shade, plain white",
        "background. No colour, no photo textures.",
        NO_ANNOTATION,
    ])


def _xphoto_prompt(spec):
    return "\n\n".join([
        "Create ONE PHOTOREALISTIC location still from the two attached",
        "images.",
        "Reference roles:",
        "- The PHOTO is the look source of truth — materials, weathering,",
        "  lighting, the reality of the town around.",
        "- The FLOOR PLAN (flat top-down diagram) is the LAYOUT source of",
        "  truth — where every structure, opening and object sits. On",
        "  conflict the floor plan wins.",
        "Camera: keep the photo's 45-degree high aerial view of the whole",
        "property.",
        "MARKER LEGEND (annotations on the floor plan only — never drawn",
        "in the photo):\n" + _legend(spec),
        NO_ANNOTATION,
        REAL_FACTS,
    ])


def stage_x():
    spec = F.load_plan(SPEC)
    # X: 무참조 T2I 실사 (fp 스펙 변형 프롬프트 — 층수/높이 포함)
    xp = _x_prompt(spec)
    F.img_gpt("s27_x_i2", xp, refs=None, size="1024x1024",
              out_path=OUT / "x_by_i2.png")
    F.img_nb2("s27_x_nb2", xp, labeled_refs=[],
              aspect_ratio="1:1", out_path=OUT / "x_by_nb2.png")
    # X + 스케치 → 스케치 형태 (lane 독립: X_i2→i2, X_nb2→nb2)
    sk = OUT / PHOTO_SRC
    sp = _xsketch_prompt(spec)
    F.img_gpt("s27_xsketch_i2", sp, refs=[OUT / "x_by_i2.png", sk],
              size="1024x1024", out_path=OUT / "xsketch_by_i2.png")
    F.img_nb2("s27_xsketch_nb2", sp, labeled_refs=[
        ("PHOTO — reality reference (materials, surroundings)",
         OUT / "x_by_nb2.png"),
        ("SKETCH — structural skeleton (camera, positions, proportions;"
         " wins on conflict)", sk)],
        aspect_ratio="1:1", out_path=OUT / "xsketch_by_nb2.png")
    # X + FP → 실사 (lane 독립)
    fp = OUT / FP_SRC
    pp = _xphoto_prompt(spec)
    F.img_gpt("s27_xphoto_i2", pp, refs=[OUT / "x_by_i2.png", fp],
              size="1024x1024", out_path=OUT / "xphoto_by_i2.png")
    F.img_nb2("s27_xphoto_nb2", pp, labeled_refs=[
        ("PHOTO — look source of truth", OUT / "x_by_nb2.png"),
        ("FLOOR PLAN — layout source of truth (wins on conflict)", fp)],
        aspect_ratio="1:1", out_path=OUT / "xphoto_by_nb2.png")
    print("[x] 6장 완료 →", OUT)


def stage_photos():
    spec = F.load_plan(SPEC)
    prompt = _photo_prompt(spec)
    src = OUT / PHOTO_SRC
    F.img_gpt("s27_photo_i2", prompt, refs=[src], size="1024x1024",
              out_path=OUT / "photo_by_i2.png")
    F.img_nb2("s27_photo_nb2", prompt, labeled_refs=[
        ("PERSPECTIVE SKETCH — the camera+layout source of truth to render"
         " photorealistically", src)],
        aspect_ratio="1:1", out_path=OUT / "photo_by_nb2.png")
    print("[photos] 2장 완료 →", OUT)


def stage_html():
    spec = F.load_plan(SPEC)
    map_p, det_p = _map_prompt(spec), _detail_prompt(spec)

    def esc(t):
        return _html.escape(t)

    def fig(rel, cap):
        return (f"<figure><a href='{rel}'><img src='{rel}' loading='lazy'>"
                f"</a><figcaption>{esc(cap)}</figcaption></figure>")

    def items_table(items):
        rows = "".join(
            f"<tr><td><b>{esc(i['code'])}</b></td><td>{esc(i['name_en'])}"
            f"</td><td>{esc(i['kind'])}</td><td>{esc(i['placement_en'])}"
            f"</td><td>{esc(i['evidence_id'])}"
            f"{' <i>(inferred)</i>' if i['inferred'] else ''}</td></tr>"
            for i in items)
        return ("<table><tr><th>code</th><th>name</th><th>kind</th>"
                f"<th>placement</th><th>evidence</th></tr>{rows}</table>")

    o = "out/forest_map"
    doc = f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
<title>s27 숲 지도 + 상세화 스케치</title><style>
body{{font-family:'Apple SD Gothic Neo',sans-serif;margin:24px;max-width:1500px}}
figure{{display:inline-block;width:44%;margin:1%;vertical-align:top}}
img{{width:100%;border:1px solid #ccc}} figcaption{{font-size:13px;text-align:center}}
pre{{font-size:11px;background:#f7f7f7;border:1px solid #ddd;padding:8px;
white-space:pre-wrap;max-height:340px;overflow:auto}}
table{{border-collapse:collapse;font-size:12px;width:100%}}
td,th{{border:1px solid #ccc;padding:4px 6px;text-align:left}}
h2{{border-bottom:2px solid #333;padding-bottom:4px;margin-top:36px}}
.thumb{{height:110px;width:auto;border:1px solid #ccc;margin-right:6px}}</style>
</head><body>
<h1>s27 — 숲 지도 + 중요 영역 상세화 (2026-07-08)</h1>
<p>스타일 SOT=경찰서 FP(TASK 1 산출, 아래 썸네일) · 콘텐츠 SOT=공간 단서
그래프 v1(evidence-bound 저작) · 마커: 지도=A–F+숫자 / 상세=P–W+숫자(완전
상이) · 구성=지도 i2/nb2 2장 + 상세(지도 계보×엔진 교차) 4장.</p>
<p><b>스타일 참조:</b><br><img class=thumb src='out/fp_v16corr/fp_l14_police_front.png'>
<img class=thumb src='out/fp_v16corr/fp_l13_police_office.png'></p>

<h2>① 숲(전체 포괄) 지도 — 구글지도 부분 캡쳐 형태</h2>
{fig(f'{o}/{MAP_GPT}', '지도 · i2(gpt-image-2)')}
{fig(f'{o}/map_nb2_r2.png', '지도 · nb2 r2 (평면 강제 재시도 — ★채택, 상세 계보 소스)')}
{fig(f'{o}/{MAP_NB2}', '지도 · nb2 r1 (대체됨 — 3D화+텍스트 발명, nb2 습성 기록용)')}
<h3>지도 마커 스펙 (LLM 저작, evidence-bound)</h3>
{items_table(spec['map']['items'])}
<details><summary>실전송 프롬프트(지도, 양 엔진 공통 텍스트)</summary>
<pre>{esc(map_p)}</pre></details>

<h2>② 중요 영역 상세화 스케치 — 지도 계보 × 엔진 교차</h2>
{fig(f'{o}/detail_mapi2_i2.png', 'i2지도 → i2 상세')}
{fig(f'{o}/detail_mapi2_nb2.png', 'i2지도 → nb2 상세')}
{fig(f'{o}/detail_mapnb2_i2.png', 'nb2지도 → i2 상세')}
{fig(f'{o}/detail_mapnb2_nb2.png', 'nb2지도 → nb2 상세')}
<h3>상세 마커 스펙</h3>
{items_table(spec['detail']['items'])}
<details><summary>실전송 프롬프트(상세, 4장 공통 텍스트 — 참조만 계보별 상이)</summary>
<pre>{esc(det_p)}</pre></details>

<h2>③ 45° 시점 스케치 — 상세 FP 4장 × i2/nb2 (실사 前 기하 확정용)</h2>
{"".join(
    f"<h3>소스 FP: {key}</h3>"
    + fig(f'{o}/{src}', f'소스 상세 FP ({key})')
    + fig(f'{o}/sketch_{key}_by_i2.png', f'{key} → i2 스케치')
    + fig(f'{o}/sketch_{key}_by_nb2.png', f'{key} → nb2 스케치')
    for src, key in SKETCHES)}
<details><summary>실전송 프롬프트(스케치, 8장 공통 텍스트 — 참조 FP만 상이)</summary>
<pre>{esc(_sketch_prompt(spec))}</pre></details>

<h2>④ 실사화 v2 — 스케치=뼈대, 살(실물 표면+주변 증강) — {PHOTO_SRC} × i2/nb2</h2>
{fig(f'{o}/{PHOTO_SRC}', '소스 스케치 (nb2)')}
{fig(f'{o}/photo_by_i2.png', '실사 v2 · i2')}
{fig(f'{o}/photo_by_nb2.png', '실사 v2 · nb2 r1 (마커 전사 실패)')}
{fig(f'{o}/photo_by_nb2_r2.png', '실사 v2 · nb2 r2 (마커 제거+ID-free — 마커 0, 화풍은 일러스트풍 잔존)')}
<details><summary>실전송 프롬프트(실사 v2, 양 엔진 공통)</summary>
<pre>{esc(_photo_prompt(spec))}</pre></details>
<details><summary>대체됨 — 실사 r1 (뼈대/살 구분 조항 前: 주변이 스케치의 간략 블록 그대로)</summary>
{fig(f'{o}/photo_r1_by_i2.png', 'r1 · i2')}
{fig(f'{o}/photo_r1_by_nb2.png', 'r1 · nb2')}
</details>

<h2>⑤ X 실험 — 무참조 T2I 실사(X) → X+스케치→스케치 / X+FP→실사 (lane 독립)</h2>
<h3>X — 참조 0, fp 스펙 변형 텍스트만(층수·높이 포함)</h3>
{fig(f'{o}/x_by_i2.png', 'X · i2')}
{fig(f'{o}/x_by_nb2.png', 'X · nb2 r1 (범례 코드 작도 실패)')}
{fig(f'{o}/x_by_nb2_r2.png', 'X · nb2 r2 (ID-free 범례 — ★성공, 코드 0)')}
<details><summary>실전송 프롬프트(X)</summary><pre>{esc(_x_prompt(spec))}</pre></details>
<h3>X + 스케치 → 스케치 형태 (스케치=구조 SOT, X=실물 참조)</h3>
{fig(f'{o}/xsketch_by_i2.png', 'X_i2+스케치 → i2 스케치')}
{fig(f'{o}/xsketch_by_nb2.png', 'X_nb2+스케치 → nb2 스케치')}
<details><summary>실전송 프롬프트(X+스케치)</summary><pre>{esc(_xsketch_prompt(spec))}</pre></details>
<h3>X + FP → 실사 (FP=배치 SOT, X=룩 SOT — ⓪-5 관할 분리 계약)</h3>
{fig(f'{o}/{FP_SRC}', '배치 SOT · 상세 FP')}
{fig(f'{o}/xphoto_by_i2.png', 'X_i2+FP → i2 실사')}
{fig(f'{o}/xphoto_by_nb2.png', 'X_nb2+FP → nb2 실사')}
<details><summary>실전송 프롬프트(X+FP)</summary><pre>{esc(_xphoto_prompt(spec))}</pre></details>
<details><summary>대체됨 — r1 스케치 8장 (스케일 블록 前: FP에 층수/규모 부재 →
전부 1층 붕괴, 사용자 지적으로 SCALE &amp; MASSING 주입 후 재생성)</summary>
{"".join(
    fig(f'{o}/r1_flat/sketch_{key}_by_i2.png', f'r1 · {key} → i2')
    + fig(f'{o}/r1_flat/sketch_{key}_by_nb2.png', f'r1 · {key} → nb2')
    for _, key in SKETCHES)}
</details>
</body></html>"""
    PAGE.write_text(doc, encoding="utf-8")
    print(f"[html] {PAGE}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all",
                    choices=["spec", "maps", "details", "sketches", "photos",
                             "x", "html", "all"])
    a = ap.parse_args()
    OUT.mkdir(parents=True, exist_ok=True)
    if a.only in ("spec", "all"):
        stage_spec()
    if a.only in ("maps", "all"):
        stage_maps()
    if a.only in ("details", "all"):
        stage_details()
    if a.only in ("sketches", "all"):
        stage_sketches()
    if a.only == "photos":
        stage_photos()
    if a.only == "x":
        stage_x()
    if a.only in ("html", "all"):
        stage_html()
