"""s14 — 블록 도면 → 이미지 생성 실험 (사용자 지시 2026-07-05, v2 피드백 반영):

블록 세트장 v2(코드 렌더 도면+LLM 구조화 정보)를 컨트롤로, **nb2 와 gpt-image-2
각각**에서 ①평면도 ②실사(부감 로케이션 레퍼런스) 생성. "텍스트는 프롬프트에
중요하게 요약" — layout JSON 을 결정론 코드로 요약(IMPORTANT LAYOUT FACTS)해 주입.
컨트롤 이미지는 범례 패널을 잘라낸 캔버스 영역만(의미는 텍스트가 전달).

v2 수정(사용자 피드백):
  1. 평면도가 3D 로 나옴 → **실내 fp 와 같은 순수 2D top-down 도면 스타일**로:
     실제 실내 fp 를 드래프팅 스타일 레퍼런스로 첨부(내용 복사 금지), 3D/축측
     /원근 명시 금지, 높이는 플랫 톤 차이로만.
  2. 건물 창문이 한 줄에 붙어 나옴 → "N identical openings in a row" 문구 제거,
     **층별·간격 분산 계약**(벽 간격 필수, 붙임 금지)으로 교체. 실사도 재생성.
사용: .venv/bin/python s14_blockgen.py [--only crop|images|html]
산출: out/blockset/gen2_{plan,photo}_{gpt,nb2}.png, blockgen.html (v1=이력)
"""
import argparse
import html as _html
import sys
from pathlib import Path

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

OUTB = F.OUT / "blockset"
PAGE = F.EXP / "blockgen.html"
CANVAS_PNG = OUTB / "blockset_v2_canvas.png"


def crop_canvas(layout):
    """범례 패널 제거 — 도면 캔버스 영역만 (생성 컨트롤용)."""
    from PIL import Image
    cw = float(layout["canvas"]["width"]) or 100.0
    ch = float(layout["canvas"]["height"]) or 100.0
    S, M = 11, 50
    img = Image.open(OUTB / "blockset_v2.png")
    img.crop((M - 8, M + 30 - 8, int(M + cw * S + 8),
              int(M + 30 + ch * S + 8))).save(CANVAS_PNG)
    print("crop ->", CANVAS_PNG)


def indoor_fp(recon):
    """실내 멤버의 fp 1장 — 드래프팅 스타일 레퍼런스 (데이터 규칙: 첫 실내 loc)."""
    for m in recon["members"]:
        if not m["is_indoor"]:
            continue
        fps = recon["fp_by_loc"].get(m["loc_id"]) or []
        if fps:
            return Path(fps[0]["png_path"])
    return None


def _third(v, size):
    t = v / size
    return 0 if t < 1 / 3 else (1 if t < 2 / 3 else 2)


def _pos_words(e, cw, ch):
    cx = e["x"] + (0 if e["shape"] == "circle" else e["w"] / 2)
    cy = e["y"] + (0 if e["shape"] == "circle" else e["h"] / 2)
    ns = ["north", "centre", "south"][_third(cy, ch)]
    we = ["west", "centre", "east"][_third(cx, cw)]
    return f"{ns}-{we}" if ns != "centre" or we != "centre" else "centre"


def _nearest_block_side(e, blocks):
    """개구부의 부착 대상 블록+변 (결정론 기하 — 중심에서 가장 가까운 변)."""
    cx = e["x"] + (0 if e["shape"] == "circle" else e["w"] / 2)
    cy = e["y"] + (0 if e["shape"] == "circle" else e["h"] / 2)
    best = None
    for b in blocks:
        if b["kind"] == "neighbor_mass" or b["shape"] == "circle":
            continue
        edges = [("north", abs(cy - b["y"]), b["x"] <= cx <= b["x"] + b["w"]),
                 ("south", abs(cy - (b["y"] + b["h"])), b["x"] <= cx <= b["x"] + b["w"]),
                 ("west", abs(cx - b["x"]), b["y"] <= cy <= b["y"] + b["h"]),
                 ("east", abs(cx - (b["x"] + b["w"])), b["y"] <= cy <= b["y"] + b["h"])]
        for side, dist, aligned in edges:
            if aligned and dist <= 3.0 and (best is None or dist < best[0]):
                best = (dist, b, side)
    if best:
        return f"on the {best[2]} edge of the {best[1]['name_en']}"
    return None


def _contains(b, e, tol=0.5):
    """rect 블록 b 가 요소 e 를 완전 포함하는지 (결정론 기하 — 이름/내용 무관)."""
    if b is e or b["shape"] == "circle":
        return False
    if e["shape"] == "circle":
        r = e["w"] / 2.0
        x0, y0, x1, y1 = e["x"] - r, e["y"] - r, e["x"] + r, e["y"] + r
    else:
        x0, y0, x1, y1 = e["x"], e["y"], e["x"] + e["w"], e["y"] + e["h"]
    return (b["x"] - tol <= x0 and b["y"] - tol <= y0
            and x1 <= b["x"] + b["w"] + tol and y1 <= b["y"] + b["h"] + tol)


def _containment_phrase(e, blocks):
    """요소를 완전 포함하는 최소 블록 기준의 상대 위치 서술 (결정론 기하).

    캔버스 전역 위치 단어만 전달하면 소속(어느 블록 위인지)이 소실돼 이미지
    모델이 요소를 블록 밖에 그린다(물탱크/평상 옥상 이탈 실증) — 포함 블록이
    있으면 그 블록 기준 서술로 위치를 대체."""
    cands = [b for b in blocks
             if b["kind"] != "neighbor_mass" and _contains(b, e)]
    if not cands:
        return None
    c = min(cands, key=lambda b: b["w"] * b["h"])
    cx = e["x"] + (0 if e["shape"] == "circle" else e["w"] / 2)
    cy = e["y"] + (0 if e["shape"] == "circle" else e["h"] / 2)
    ns = ["north", "centre", "south"][_third(cy - c["y"], c["h"])]
    we = ["west", "centre", "east"][_third(cx - c["x"], c["w"])]
    rel = f"{ns}-{we}" if ns != "centre" or we != "centre" else "centre"
    return f"placed on the {c['name_en']}, in its {rel} part"


def load_overrides():
    """사용자 정정 override (plans/blockset_user_overrides.json) — LLM 산출은
    보존하고 프롬프트 조립 시점에만 대체(provenance 명시)."""
    try:
        return F.load_plan("blockset_user_overrides")
    except Exception:
        return {}


def summary_lines(layout, ids=True):
    """layout JSON → IMPORTANT LAYOUT FACTS (결정론 요약, 영어).

    ids=False: 마커 토큰 "(A)"/"(1)" 를 전부 제거한 순수 서술 — nb2 가 텍스트
    ID 를 이미지 라벨로 그리는 누출(클린 캔버스 A/B 로 확인) 차단용.
    사용자 override: kind=stair 요소명 대체 + facts_extra 줄 추가.
    """
    ov = load_overrides()
    cw = float(layout["canvas"]["width"]) or 100.0
    ch = float(layout["canvas"]["height"]) or 100.0
    lay = sorted(layout["layers"], key=lambda l: l["rel_height"])
    ramp_note = ", ".join(
        f"{l['name_en']} h={l['rel_height']:g} ({'darkest' if i == len(lay) - 1 else ('lightest' if i == 0 else 'mid ' + str(i))})"
        for i, l in enumerate(lay))
    lname = {l["layer_id"]: l["name_en"] for l in layout["layers"]}
    lines = [f"- height layers, low to high: {ramp_note}. Darker fill = higher."]
    blocks = [e for e in layout["elements"] if e["kind"] in S13.BLOCK_KINDS]
    for e in layout["elements"]:
        pos = _pos_words(e, cw, ch)
        at = f"height: {lname.get(e['layer_id'], '?')}"
        if e["kind"] == "window" and e["repeat"] > 1:
            rep = (f", {e['repeat']} windows for the whole facade — distribute"
                   " them across the building's storeys with clear wall between"
                   " each (never fused edge-to-edge, never one tight row)")
        elif e["repeat"] > 1:
            rep = f", {e['repeat']} identical openings"
        else:
            rep = ""
        attach = ""
        if e["kind"] in S13.POINT_KINDS:
            side = _nearest_block_side(e, blocks)
            attach = f", {side}" if side else ""
        where = f"{pos} area"
        if not attach:
            inside = _containment_phrase(e, blocks)
            if inside:
                where = inside
        shape = "round" if e["shape"] == "circle" else ""
        tag = f"({e['marker']}) " if ids else ""
        name = e["name_en"]
        if e["kind"] == "stair" and (ov.get("stair") or {}).get("name_en"):
            name = ov["stair"]["name_en"]
        lines.append(f"- {tag}{name} [{e['kind']}]"
                     f" — {where}{attach}{rep}; {at} {shape}".rstrip())
    lines.append("- any facade windows sit apart with clear wall between them,"
                 " aligned to the building's real storeys — never touching.")
    lines.extend(ov.get("facts_extra") or [])
    return "\n".join(lines)


def loc_lines(recon):
    return "\n".join(f"- {m.get('label')}: {m.get('summary')}"
                     for m in recon["members"])


HEAD = "\n".join([
    "The FIRST attached image is a BLOCK SET DIAGRAM of one real property:",
    "plain blocks, top-down, and the blocks' RELATIVE sizes and positions are",
    "the layout ground truth. The fill COLOURS encode HEIGHT LAYERS, not",
    "surface colours or materials. Circled letters/numbers are annotations",
    "only — never draw them or any text.",
])


HEAD_NOID = "\n".join([
    "The FIRST attached image is a BLOCK SET DIAGRAM of one real property:",
    "plain blocks, top-down, and the blocks' RELATIVE sizes and positions are",
    "the layout ground truth. The fill COLOURS encode HEIGHT LAYERS, not",
    "surface colours or materials. The output must contain NO text, NO",
    "letters, NO numbers, NO labels of any kind.",
])


def plan_prompt(layout, recon, ids=True):
    return "\n".join([
        HEAD if ids else HEAD_NOID, "",
        "IMPORTANT LAYOUT FACTS (the diagram's data — obey exactly):",
        summary_lines(layout, ids=ids), "",
        "The SECOND attached image is an interior floor plan from the same",
        "production — use it ONLY as the DRAFTING-STYLE reference: same line",
        "weight, same flat colour-fill language, same simple fixture/opening",
        "symbols. NEVER copy its rooms, furniture, numbers or any content.",
        "",
        "Redraw the block diagram as ONE strict TOP-DOWN 2D architectural",
        "SITE PLAN in exactly that floor-plan drafting style. Flat orthographic",
        "plan view only: NO 3D, NO axonometric, NO perspective, NO volume",
        "shading, no cast shadows. Height layers appear ONLY as flat fill",
        "tints (slightly darker = higher). Use standard plan symbols by",
        "element kind: stair tread lines, door-swing arcs, window ticks on",
        "wall lines, circles for round fixtures, thin rectangles for low",
        "fixtures. Keep every block's position, proportion and opening count",
        "exactly. Enclosed upper-shell elements stay an outer shell with",
        "boundary openings only — never interior rooms or furniture. Neutral",
        "document: no people, no story props, no vehicles, no text, no",
        "markers, no legend.",
    ])


KIND_DETAIL_RULES = {
    # kind 스키마 → 도면 상세 어휘 (시나리오 중립 — 구체 요소명은 전부 데이터에서)
    "building_mass": "building masses: walls as double lines with real"
                     " thickness, roof edges with parapet coping lines",
    "upper_shell": "enclosed upper-shell units: thick-walled outline with"
                   " boundary openings only — never interior rooms",
    "terrace": "open decks: edge/parapet lines and a flat surface tone",
    "stair": "stairs: individual treads, stringer/railing lines, top and"
             " bottom landings",
    "door": "doors: leaf + swing arc set in a wall opening",
    "gate": "gates: leaf/swing symbol set in the boundary wall",
    "window": "windows: frame lines set INTO the wall",
    "fixture_round": "round free-standing fixtures: circle with its base ring",
    "fixture_rect": "low rectangular fixtures: outline with slat/edge lines",
    "equipment": "small equipment units: simple outlined box with support/"
                 "mount lines",
    "yard": "yards: paving joints",
    "alley": "alleys: paving joints",
    "road": "roads: kerb lines",
    "wall": "boundary walls: double lines",
    "neighbor_mass": "neighbouring buildings: simplified roof outlines only"
                     " (secondary)",
}


def kind_detail_lines(layout):
    """layout 에 실재하는 kind 만 골라 상세 규칙 나열 (데이터 주도 선택)."""
    kinds = []
    for e in layout["elements"]:
        if e["kind"] not in kinds:
            kinds.append(e["kind"])
    return "\n".join(f"- {KIND_DETAIL_RULES[k]}" for k in kinds
                     if k in KIND_DETAIL_RULES)


def plan_prompt_detailed(layout, recon, ids=True):
    """v3 상세화 — v5 중립화: 상세 규칙은 kind 스키마 기반, 구체 내용은 데이터만."""
    return "\n".join([
        HEAD if ids else HEAD_NOID, "",
        "IMPORTANT LAYOUT FACTS (the diagram's data — obey exactly):",
        summary_lines(layout, ids=ids), "",
        "WHAT THIS PLACE LOOKS LIKE (production data):",
        loc_lines(recon), "",
        "The SECOND attached image is an interior floor plan from the same",
        "production — match its DRAFTING LANGUAGE and its LEVEL OF DETAIL:",
        "same line weights, flat colour fills, detailed plan symbols. Never",
        "copy its rooms, furniture or any content.",
        "",
        "Draw ONE strict TOP-DOWN 2D architectural SITE PLAN that DEVELOPS",
        "this block layout into a fully detailed drawing. The blocks give",
        "ONLY each element's position, relative size and count — do NOT",
        "redraw them as plain empty rectangles. Elaborate every element into",
        "its real plan representation with believable construction detail,",
        "by element kind:",
        kind_detail_lines(layout),
        "Ground every added detail in the production data above; nothing",
        "story-specific, no movable props, no posted papers or notices.",
        "Height layers appear only as flat fill tints (darker = higher).",
        "Flat orthographic plan view: NO 3D, NO axonometric, NO perspective,",
        "no shadows. No people, no vehicles, no text, no markers, no legend,",
        "no compass.",
    ])


def photo_prompt(layout, recon, ids=True):
    return "\n".join([
        HEAD if ids else HEAD_NOID, "",
        "IMPORTANT LAYOUT FACTS (the diagram's data — obey exactly):",
        summary_lines(layout, ids=ids), "",
        "WHAT THIS PLACE LOOKS LIKE (production data):",
        loc_lines(recon), "",
        "Render ONE PHOTOREALISTIC bird's-eye location reference of this",
        "property from a high oblique angle tilted about 30 degrees from",
        "vertical: translate the height layers into real built volumes —",
        "ground-level surfaces, building masses with their decks, and the",
        "topmost enclosed unit standing highest, exactly as the layer",
        "heights order them. Keep every position, proportion and opening",
        "count from the diagram exactly; facade windows spread naturally",
        "across the storeys, never one fused row. The diagram's fill colours",
        "are height codes — do NOT copy them as paint or material colours;",
        "use aged everyday urban materials grounded in the production data.",
        "Plain even daylight, completely unpopulated, no movable props, no",
        "posted papers or notices, no text, letters, circles, markers or",
        "legend anywhere. Never expose interior rooms.",
    ])


NB2_LABEL = ("BLOCK SET DIAGRAM (geometry + height control) — block positions/"
             "sizes are layout ground truth; fill colours encode height layers"
             " as the prompt explains; circled letters/numbers are annotations,"
             " never objects — draw none of them and no text at all.")
NB2_STYLE_LABEL = ("DRAFTING STYLE REFERENCE (an interior floor plan) — copy"
                   " ONLY its drawing style: line weight, flat fills, plan"
                   " symbols. Never its rooms, furniture, numbers or content.")


def images(layout, recon):
    pp = plan_prompt(layout, recon)
    hp = photo_prompt(layout, recon)
    fp = indoor_fp(recon)
    plan_refs = [CANVAS_PNG] + ([fp] if fp else [])
    F.img_gpt("blockgen2_plan_gpt", pp, refs=plan_refs,
              out_path=OUTB / "gen2_plan_gpt.png")
    build_html(layout, recon)
    F.img_gpt("blockgen2_photo_gpt", hp, refs=[CANVAS_PNG],
              out_path=OUTB / "gen2_photo_gpt.png")
    build_html(layout, recon)
    nb2_plan_refs = [(NB2_LABEL, CANVAS_PNG)] + (
        [(NB2_STYLE_LABEL, fp)] if fp else [])
    F.img_nb2("blockgen2_plan_nb2", pp, nb2_plan_refs,
              out_path=OUTB / "gen2_plan_nb2.png")
    build_html(layout, recon)
    F.img_nb2("blockgen2_photo_nb2", hp, [(NB2_LABEL, CANVAS_PNG)],
              out_path=OUTB / "gen2_photo_nb2.png")
    build_html(layout, recon)


def _nb2_chain_section():
    """s16 nb2 체인 섹션 — 블록(nb2)→상세 fp(nb2)→실사(gpt/nb2)."""
    try:
        prompts = F.load_plan("nb2_chain_prompts")
    except Exception:
        return ""
    try:
        bl = F.load_plan("blocks_llm_prompt")
    except Exception:
        bl = None
    order = [
        ("blocks4_nb2.png", "A‴ ★★★★블록 최종형 — nb2 (스타일=A형 순수 플랫 + 내용=추출 상대배치 lean 텍스트)"),
        ("blocks4_gpt.png", "A‴ 대조 — gpt"),
        ("fp2_nb2.png", "B′ fp — nb2 (A‴ 참조만 + LLM 설명 + 스타일 ref)"),
        ("fp2_gpt.png", "B′ 대조 fp — gpt"),
        ("blocks3_nb2.png", "(이력) A″ — nb2 (살짝 3D끼 — 사용자 지적)"),
        ("blocks3_gpt.png", "(이력) A″ — gpt"),
        ("blocks2_nb2.png", "(이력) A′ — nb2 (실내 구획 포함돼 사용자 지적)"),
        ("blocks2_gpt.png", "(이력) A′ — gpt"),
        ("photo_fromfp3_gpt.png", "C″-1 ★★생활감 — gpt i2 (일상 흔적 자유 창작)"),
        ("photo_fromfp3_nb2.png", "C″-2 ★★생활감 — nb2"),
        ("photo_fromfp3_gptsearch.png", "C″-3 ★★웹검색 그라운딩 — gpt Responses(web_search+image_generation)"),
        ("nb2_blocks.png", "A. nb2 블록 다이어그램 (코드 대체 — 구조 파악 전용, 텍스트만으로)"),
        ("nb2_fp.png", "B. nb2 상세 fp (A=참조만 + LLM 설명 + 스타일 ref)"),
        ("photo_fromfp2_gpt.png", "C-1' ★실사 v2 — gpt (도면=구조만, 실존 지역 전형 재질)"),
        ("photo_fromfp2_nb2.png", "C-2' ★실사 v2 — nb2 (도면=구조만, 실존 지역 전형 재질)"),
        ("photo_fromfp_gpt.png", "(이력) C-1 실사 v1 — gpt (도면 회색이 재질로 번역된 버전)"),
        ("photo_fromfp_nb2.png", "(이력) C-2 실사 v1 — nb2"),
    ]
    cells = ""
    for fn, title in order:
        p = OUTB / fn
        det = ""
        if prompts.get(fn):
            det = (f"<details><summary>프롬프트 원문 (실호출)</summary>"
                   f"<pre>{_html.escape(prompts[fn])}</pre></details>")
        if p.exists():
            rel = p.relative_to(F.EXP)
            cells += (f"<div class='cell'><h4>{_html.escape(title)}</h4>"
                      f"<a href='{rel}' target='_blank'><img src='{rel}'></a>"
                      f"{det}</div>")
        else:
            cells += (f"<div class='cell'><h4>{_html.escape(title)}</h4>"
                      f"<div class='pend'>⏳ 생성 중… (자동 갱신)</div>{det}</div>")
    bl_html = ""
    if bl:
        bl_html = ("<div class='notes'><b>A′ LLM 저작 구조도 프롬프트 (한국어 번역 원문):</b><br>"
                   + _html.escape(bl.get("prompt_ko", ""))
                   + "<br><br><b>근거(notes):</b> " + _html.escape(bl.get("notes", ""))
                   + "</div>")
    return (
        "<h2 style='color:#8fd'>★★ nb2 체인 — 블록(nb2 작도)→상세 fp(nb2)→실사(gpt/nb2)</h2>"
        + bl_html +
        "<div class='guide'>사용자 지시: nb2 가 <b>코드 대신</b> 블록 다이어그램을 직접 작도"
        " — 그림 중심 금지, <b>구조·상대 관계 파악 전용</b>(플랫 단색 블록만, 3D/원근/상세"
        " 금지). 그걸 <b>참조만</b> 삼아 nb2 가 상세 fp → 그 fp 를 공간 SOT 로 gpt/nb2 실사"
        " (LLM 상세 설명·FACTS·장소 데이터 주입).</div>"
        + f"<div class='grid' style='margin-top:14px'>{cells}</div>")


def _fp_redo_section(layout):
    """s15 fp 재생성 섹션 — 산출/프롬프트/설명은 파일에서 읽음 (순환 import 회피)."""
    try:
        prompts = F.load_plan("fp_redo_prompts")
    except Exception:
        prompts = {}
    try:
        desc = F.load_plan("fp_place_desc")
    except Exception:
        desc = None
    order = [
        ("fp_r1_gpt.png", "① 참조 약화 — gpt (블록=참조만, 구도 자유)"),
        ("fp_r1_nb2.png", "① 참조 약화 — nb2"),
        ("fp_r2_gpt.png", "② +LLM 건물 상세 설명(층수 포함) — gpt"),
        ("fp_r2_nb2.png", "② +LLM 건물 상세 설명 — nb2"),
        ("fp_r0_gpt.png", "⓪ T2I 단독 — gpt (레이아웃 참조 0, 추출 텍스트만)"),
        ("fp_r0_nb2.png", "⓪ T2I 단독 — nb2"),
    ]
    if not prompts and not any((OUTB / fn).exists() for fn, _ in order):
        return ""
    cells = ""
    for fn, title in order:
        p = OUTB / fn
        det = ""
        if prompts.get(fn):
            det = (f"<details><summary>프롬프트 원문 (실호출)</summary>"
                   f"<pre>{_html.escape(prompts[fn])}</pre></details>")
        if p.exists():
            rel = p.relative_to(F.EXP)
            cells += (f"<div class='cell'><h4>{_html.escape(title)}</h4>"
                      f"<a href='{rel}' target='_blank'><img src='{rel}'></a>"
                      f"{det}</div>")
        else:
            cells += (f"<div class='cell'><h4>{_html.escape(title)}</h4>"
                      f"<div class='pend'>⏳ 생성 중… (자동 갱신)</div>{det}</div>")
    desc_html = ""
    if desc:
        desc_html = (
            "<div class='notes'><b>LLM 건물/도면 상세 설명 (한국어 원문 — ② 주입 내용):</b><br>"
            + "<br>".join(
                f"<b>{lab}</b> {_html.escape(desc.get(k, ''))}" for lab, k in (
                    ("건물:", "building_ko"), ("상부 유닛/데크:", "upper_ko"),
                    ("지상부:", "grounds_ko"), ("도면 요구:", "plan_ko"),
                    ("근거:", "notes")))
            + "</div>")
    return (
        "<h2 style='color:#8fd'>★ fp 재생성 — ①참조 약화 → ②LLM 상세 설명 → ⓪T2I 단독</h2>"
        "<div class='guide'>사용자 지시: 블록 도면은 <b>참조만</b>(모사/트레이스 금지, 구도"
        " 자유 작도), 건물 층수 구조 등은 <b>LLM 생성 설명</b>이 공급(아래 한국어 원문),"
        " ⓪은 레이아웃 참조 이미지 없이 추출 텍스트만으로 자유 작도. 전부 2D fp 도면 언어.</div>"
        + desc_html + f"<div class='grid' style='margin-top:14px'>{cells}</div>")


def build_html(layout, recon):
    pp = plan_prompt(layout, recon)
    hp = photo_prompt(layout, recon)

    def cell(fn, title, prompt):
        p = OUTB / fn
        det = (f"<details><summary>프롬프트 원문 (실호출)</summary>"
               f"<pre>{_html.escape(prompt)}</pre></details>")
        if not p.exists():
            return (f"<div class='cell'><h4>{title}</h4>"
                    f"<div class='pend'>⏳ 생성 중… (자동 갱신)</div>{det}</div>")
        rel = p.relative_to(F.EXP)
        return (f"<div class='cell'><h4>{title}</h4>"
                f"<a href='{rel}' target='_blank'><img src='{rel}'></a>{det}</div>")

    def hist_cell(fn, title):
        p = OUTB / fn
        if not p.exists():
            return ""
        rel = p.relative_to(F.EXP)
        return (f"<div class='cell'><h4>{title}</h4>"
                f"<a href='{rel}' target='_blank'><img src='{rel}'></a></div>")

    pend = any(not (OUTB / f).exists() for f in (
        "gen2_plan_gpt.png", "gen2_photo_gpt.png",
        "gen2_plan_nb2.png", "gen2_photo_nb2.png"))
    try:
        if F.load_plan("fp_redo_prompts"):
            pend = pend or any(not (OUTB / f).exists() for f in (
                "fp_r1_gpt.png", "fp_r1_nb2.png", "fp_r2_gpt.png",
                "fp_r2_nb2.png", "fp_r0_gpt.png", "fp_r0_nb2.png"))
    except Exception:
        pass
    refresh = "<meta http-equiv='refresh' content='30'>" if pend else ""
    ctrl_rel = CANVAS_PNG.relative_to(F.EXP)
    fp = indoor_fp(recon)
    fp_note = ""
    if fp:
        mir = F.OUT / "inputs" / fp.name
        if mir.exists():
            fp_note = (f"<div class='cell' style='max-width:420px'><h4>스타일 레퍼런스"
                       f" (실내 fp — 도면 언어만 복사)</h4>"
                       f"<img src='{mir.relative_to(F.EXP)}'></div>")
    doc = f"""<meta charset='utf-8'>{refresh}<title>블록 도면 → 생성 실험 v2</title><style>
body{{font-family:sans-serif;background:#171717;color:#eee;margin:24px;max-width:1880px}}
h1{{font-size:22px}} h4{{color:#fd9;margin:2px 0 8px}}
.grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px}}
.cell{{background:#222;padding:12px;border-radius:10px}}
.cell img{{width:100%;border-radius:6px}}
.ctrl{{max-width:700px;border-radius:8px;background:#fff}}
.guide{{background:#1e2430;padding:12px 16px;border-radius:8px;font-size:13px;line-height:1.7;margin-bottom:14px}}
.fixnote{{background:#301e1e;border-left:4px solid #a44;padding:10px 14px;font-size:13px;margin-bottom:14px}}
.pend{{color:#fa5;padding:18px}}
pre{{white-space:pre-wrap;font-size:11px;color:#9c9;background:#1b1b1b;padding:8px;max-height:340px;overflow:auto}}
summary{{cursor:pointer;color:#8ac;font-size:12px}}
details{{margin-top:16px}}
.row{{display:flex;gap:14px;flex-wrap:wrap}} .row .cell{{flex:1 1 380px}}
</style>
<h1>블록 도면 → nb2 / gpt-image-2 생성 v2 (평면도=실내 fp 스타일 · 실사)</h1>
<div class='fixnote'>사용자 피드백 반영: ①평면도 3D 금지 — <b>실내 fp 와 같은 순수
2D top-down 도면</b>(실제 실내 fp 를 스타일 레퍼런스로 첨부, 내용 복사 금지; 높이는
플랫 톤 차이로만) ②건물 창문 — "한 줄 4개 붙임" 문구 제거, <b>층별 분산+벽 간격 필수
(붙임 금지)</b> 계약으로 교체(실사 포함 전부 재생성) ③블록 그대로 금지 — <b>상세화</b>
(블록=골격만, 실도면 발전) ④★<b>계단 정정</b>: 시나리오 원본 전수 검색 결과 재질 서술
0회("계단" 2회 무재질, 철제/시멘트 0회) — "시멘트 계단"은 요소 추출 단계 발명 명명.
사용자 정정 override 로 <b>철제 외부 증축 계단</b>(오픈 발판·강판 스트링거·파이프 난간
·후대 부착) 적용, supersede 계약으로 loc 데이터의 시멘트 문구 무효화.</div>
<div class='guide'>컨트롤 = 블록 도면 캔버스(범례 제거). 레이어 높이/마커 의미/개수·배치
= <b>IMPORTANT LAYOUT FACTS 결정론 요약</b>으로 프롬프트 주입("색=높이 코드, 원문자=주석").
</div>
<div class='row'>
<details open class='cell' style='flex:2'><summary>컨트롤 이미지 (블록 도면 캔버스)</summary>
<a href='{ctrl_rel}' target='_blank'><img class='ctrl' src='{ctrl_rel}'></a></details>
{fp_note}
</div>
{_nb2_chain_section()}
{_fp_redo_section(layout)}
<details><summary style='font-size:16px;color:#fd9'>(이력) v5 이하 — 블록 도면 강결합 시기 산출</summary>
<div class='grid' style='margin-top:14px'>
{cell('gen5_plan_gpt.png', '평면도 v5 ★프롬프트 중립화(kind 규칙) — gpt-image-2', plan_prompt_detailed(layout, recon, ids=True))}
{cell('gen5_plan_nb2.png', '평면도 v5 ★프롬프트 중립화 — nb2 (클린+ID-free)', plan_prompt_detailed(layout, recon, ids=False))}
{cell('gen5_photo_gpt.png', '실사 v5 ★프롬프트 중립화 — gpt-image-2', photo_prompt(layout, recon, ids=True))}
{cell('gen5_photo_nb2.png', '실사 v5 ★프롬프트 중립화 — nb2 (클린+ID-free)', photo_prompt(layout, recon, ids=False))}
{cell('gen4_plan_gpt.png', '(이력) v4 계단 정정 — gpt (프롬프트에 요소명 하드코딩 있던 버전)', '')}
{cell('gen4_photo_gpt.png', '(이력) v4 계단 정정 실사 — gpt', '')}
{cell('gen4_plan_nb2.png', '(이력) v4 계단 정정 — nb2', '')}
{cell('gen4_photo_nb2.png', '(이력) v4 계단 정정 실사 — nb2', '')}
{cell('gen3_plan_gpt.png', '(이력) 평면도 v3 상세화 — gpt (계단=시멘트 발명 상속)', plan_prompt_detailed(layout, recon, ids=True))}
{cell('gen3_plan_nb2.png', '(이력) 평면도 v3 상세화 — nb2', plan_prompt_detailed(layout, recon, ids=False))}
{cell('gen2_plan_gpt.png', '(이력) 평면도 v2 — gpt (블록 그대로 재스타일 — 상세화 지적)', pp)}
{cell('gen2_plan_nb2.png', '(이력) 평면도 v2 — nb2', pp)}
{cell('gen2_photo_gpt.png', '실사 v2 — gpt-image-2 (창문 분산)', hp)}
{cell('gen2_photo_nb2.png', '실사 v2 — nb2 (창문 분산)', hp)}
{cell('gen2_plan_nb2_clean.png', '평면도 v2 — nb2 ★클린 캔버스 A/B (마커=텍스트로만 → 텍스트 ID 누출 확인)', pp)}
{cell('gen2_photo_nb2_clean.png', '실사 v2 — nb2 ★클린 캔버스 A/B (마커=텍스트로만 → 텍스트 ID 누출 확인)', hp)}
{cell('gen2_plan_nb2_noid.png', '평면도 v2 — nb2 ★★클린 캔버스+ID 제거 FACTS (최종 계약)', plan_prompt(layout, recon, ids=False))}
{cell('gen2_photo_nb2_noid.png', '실사 v2 — nb2 ★★클린 캔버스+ID 제거 FACTS (최종 계약)', photo_prompt(layout, recon, ids=False))}
</div></details>
<details><summary>(이력) v1 — 평면도가 3D 매싱으로 나옴 + 창문 4개 한 줄 붙음 (사용자 지적으로 재생성)
· nb2 는 마커 누출(평면=텍스트 라벨, 실사=원형 배지)</summary>
<div class='grid' style='margin-top:10px'>
{hist_cell('gen_plan_gpt.png', 'v1 평면도 gpt (3D 매싱 — 지적 대상)')}
{hist_cell('gen_plan_nb2.png', 'v1 평면도 nb2 (3D+마커 텍스트 누출)')}
{hist_cell('gen_photo_gpt.png', 'v1 실사 gpt (창 4개 한 줄)')}
{hist_cell('gen_photo_nb2.png', 'v1 실사 nb2 (마커 배지 누출)')}
</div></details>
<div class='guide' style='margin-top:14px'>판정 축: 2D 순수 도면 여부(3D 금지), fp 도면
언어 재현, 배치/비율 보존, 창문 분산(붙임 0), 마커/텍스트 누출 0, 실내 미노출.
블록 도면·근거 테이블: <a href='blockset.html' style='color:#8ac'>blockset.html</a></div>
"""
    PAGE.write_text(doc)
    print("page ->", PAGE)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all", choices=["all", "crop", "images",
                                                      "html"])
    args = ap.parse_args()
    recon = F.load_recon()
    layout = F.load_plan("blockset_layout_v2")
    if args.only in ("all", "crop"):
        crop_canvas(layout)
    if args.only in ("all", "images"):
        images(layout, recon)
    if args.only in ("all", "html"):
        build_html(layout, recon)
    F.runlog({"kind": "stage", "stage": "s14_blockgen", "done": args.only})


if __name__ == "__main__":
    main()
