import contextlib
import customtkinter as ctk
import datetime
import hashlib
import io
import json
import logging
import os
import sys
import threading
import time as _time
from pathlib import Path
from tkinter import filedialog, messagebox

# 런타임 경로 — PyInstaller 번들 vs 소스 실행 자동 분기. 자산은 asset_root,
# 쓰기 데이터(DB·로그·캐시)는 user_data_dir(`%LOCALAPPDATA%\\S-CANVAS\\`).
from resource_paths import (
    resource_path, user_data_dir, db_path,
    harness_log_path, diagnostic_log_path, cache_dir,
)

# GIS/3D/Network 라이브러리
import ezdxf
import numpy as np
import pyvista as pv
from scipy.spatial import Delaunay
import pyproj
import requests
from PIL import Image, ImageDraw, ImageFilter
import tkintermapview
from matplotlib.colors import LinearSegmentedColormap

# 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림).
# 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체.
_TIN_EARTH_CMAP = LinearSegmentedColormap.from_list(
    "scanvas_earth",
    [
        (0.00, "#3F2E1A"),  # 저지대 — 짙은 갈색 (배수로 음영)
        (0.20, "#6E5235"),  # 토양
        (0.45, "#9C7B4F"),  # 황토
        (0.70, "#C7AA7C"),  # 모래/건조
        (0.88, "#E5D5B0"),  # 고지 능선
        (1.00, "#F5EBD3"),  # 정상 광택
    ],
    N=256,
)

# Harness 모듈 (동일 디렉토리의 harness/ 폴더)
try:
    from harness.logger import init_db, get_db_session, JobLogger, setup_logging, get_logger
    from harness.seed_manager import SeedManager
    from harness.quality_validator import QualityValidator
    from harness.prompt_registry import PromptRegistry
    HARNESS_AVAILABLE = True
except ImportError:
    HARNESS_AVAILABLE = False

# 구조물 상세도면 치수 파서
try:
    from detail_parser import DetailParser, dimensions_to_structure_params
    DETAIL_PARSER_AVAILABLE = True
except ImportError:
    DETAIL_PARSER_AVAILABLE = False

# 구조물 템플릿 시스템 (취수탑/제수변실/옹벽/수문 등 상세 빌더)
try:
    from structure_templates import REGISTRY as STRUCTURE_REGISTRY
    from structure_placement import (
        apply_placement,
        compute_orientation_from_points,
        fit_meshes_to_quad,
    )
    from filename_classifier import suggest_with_confidence  # noqa: F401  (protective availability check)
    STRUCTURE_TEMPLATES_AVAILABLE = True
except ImportError as _e:
    STRUCTURE_REGISTRY = None
    STRUCTURE_TEMPLATES_AVAILABLE = False
    print(f"[Warning] structure_templates not available: {_e}")

# DEM 기반 지형 확장 (DXF 범위 밖의 실제 지형)
try:
    from dem_extender import (
        build_extended_terrain_ring,
        fetch_terrarium_grid,
        _sample_grid_bilinear,
    )
    DEM_EXTENDER_AVAILABLE = True
except ImportError as _e:
    DEM_EXTENDER_AVAILABLE = False
    print(f"[Warning] dem_extender not available: {_e}")

# 구조물 파서·빌더 결과 ↔ 원본 도면 VLM 피드백 루프 (Gemini Vision)
try:
    import structure_vlm_feedback as _svf
    STRUCTURE_VLM_AVAILABLE = True
except ImportError as _e:
    STRUCTURE_VLM_AVAILABLE = False
    print(f"[Warning] structure_vlm_feedback not available: {_e}")

# 폰트 에러 방지 — matplotlib font_manager 로그 비활성.
logging.getLogger('matplotlib.font_manager').disabled = True
os.environ['PYTHONIOENCODING'] = 'utf-8'

# UI 테마 — **Default Light**. 사용자가 사이드바에서 언제든 Dark 로 전환 가능.
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")


def _load_image_strip_white_bg(path, threshold: int = 240):
    """PIL 이미지 로드 후 (R,G,B 모두 ≥ threshold 인) 흰색 배경을 투명화.

    GIF 같은 팔레트 이미지·이미 RGBA 이미지 모두 처리. SAMAN_CI 흰 배경 제거용.
    """
    pil = Image.open(path).convert("RGBA")
    arr = np.asarray(pil).copy()
    r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
    white = (r >= threshold) & (g >= threshold) & (b >= threshold)
    arr[..., 3] = np.where(white, 0, arr[..., 3])
    return Image.fromarray(arr)



def _load_image_strip_dark_bg(path, v_low: int = 30, v_high: int = 80):
    """어두운 배경(밤하늘·검정)을 부드럽게 투명화. logo_V2.png 처럼
    V(=max RGB) 가 낮은 픽셀이 배경인 경우 사용.

    - max(R,G,B) ≤ v_low  → 완전 투명(alpha 0)
    - max(R,G,B) ≥ v_high → 완전 불투명(원본 alpha 유지)
    - 사이 구간은 선형 보간 → 부드러운 경계(halo 최소화)
    """
    pil = Image.open(path).convert("RGBA")
    arr = np.asarray(pil).astype(np.float32).copy()
    v = np.maximum.reduce([arr[..., 0], arr[..., 1], arr[..., 2]])
    factor = np.clip((v - v_low) / max(v_high - v_low, 1.0), 0.0, 1.0)
    arr[..., 3] = arr[..., 3] * factor
    return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))



def _signed_distance_to_polygon(points_xy: np.ndarray,
                                 poly_pts: np.ndarray) -> np.ndarray:
    """각 점에서 다각형 경계까지의 부호 있는 거리. 음수=내부, 양수=외부.

    - 경계 최소거리는 모든 엣지에 대한 점-선분 거리 중 최소값
    - 부호는 matplotlib.path의 inside 판정으로 결정
    - 폐합 폴리곤(N>=3) 가정. 끝-처음 엣지도 자동 포함.
    """
    from matplotlib.path import Path as _MplPath
    pts = np.asarray(points_xy, dtype=np.float64)
    poly = np.asarray(poly_pts, dtype=np.float64)
    n_edges = len(poly)
    if len(pts) == 0 or n_edges < 2:
        return np.full(len(pts), np.inf, dtype=np.float64)

    min_d = np.full(len(pts), np.inf, dtype=np.float64)
    for i in range(n_edges):
        p1 = poly[i]
        p2 = poly[(i + 1) % n_edges]
        edge = p2 - p1
        edge_len_sq = float(edge @ edge)
        if edge_len_sq < 1e-14:
            continue
        rel = pts - p1
        t = np.clip((rel @ edge) / edge_len_sq, 0.0, 1.0)
        proj = p1 + np.outer(t, edge)
        d = np.linalg.norm(pts - proj, axis=1)
        np.minimum(min_d, d, out=min_d)

    try:
        inside = _MplPath(poly).contains_points(pts)
    except Exception:
        inside = np.zeros(len(pts), dtype=bool)
    return np.where(inside, -min_d, min_d)


class SCanvasApp(ctk.CTk):
    def __init__(self):
        super().__init__()

        # 프로그램 기본 설정
        self.title("S-CANVAS — Generative Design & Visualization Engine")
        self.geometry("1200x900")
        self._setup_window_icon()  # 타이틀바·작업표시줄 'S' 아이콘

        # 인스턴스 변수
        self.tin_mesh = None
        self.total_mesh = None # 텍스처가 입혀진 메쉬 저장용
        self.tin_extension_mesh = None  # DEM 기반 외곽 확장 메시 (도넛)
        self.tin_extension_textured = None  # 텍스처 UV 매핑된 확장 메시
        self._dem_extend_info = ""  # 마지막 DEM 확장 요약 로그
        # [TIN 이용 범위 3-zone] core_bbox = 사용자가 지정한 정밀 TIN 구역(abs XY).
        # None = 전체 TIN 사용(legacy). 설정 시 Step 1.5에서 core/transition/DEM 3구역
        # smoothstep 블렌드 적용.
        self.tin_core_bbox = None
        self.tin_blend_width_m = 80.0
        # core zone 변경 이력 — 초기화/재선택 구분용
        self._tin_core_original_points = None  # core 적용 전 원본 Z 백업
        self.dxf_path = None
        self.origin = np.array([0.0, 0.0, 0.0])

        # 제어맵 & 렌더링 결과 저장
        self.depth_map = None       # PIL Image
        self.lineart_map = None     # PIL Image
        self.guide_image = None     # PIL Image (합성본)
        self.capture_image = None   # PIL Image (3D 캡처)
        self._saved_camera = None   # PyVista 카메라 위치 (pos, focal, up)
        self._saved_window_size = None  # 인터랙티브 뷰어 창 크기 (w, h) — 캡처 화면비 보존용
        # Step 3 화면비 락 — 사용자가 명시 비율을 클릭할 때까지는 None(자유 모드)
        self.extraction_aspect_ratio = None  # None=자유 / (rw, rh) 정수 튜플
        self._aspect_buttons = []            # [(x_norm, y_norm, w_norm, h_norm, label, ratio, actor)]
        # Step 4 출력 해상도 — 렌더 후 PIL.LANCZOS 리사이즈 타깃
        self.target_resolution = None        # None / (target_w, target_h)
        self.gemini_api_key = ctk.StringVar(value="")

        # gcp-key.json 자동 로드: 서비스 어카운트 키 파일이 프로젝트 루트에 있으면
        # GOOGLE_APPLICATION_CREDENTIALS 환경변수로 설정해 gcloud auth 없이 Vertex AI
        # 인증 가능. project_id도 JSON에서 추출해 기본값으로 사용.
        self._gcp_key_project_id = None
        try:
            # gcp-key.json 검색 순서: (1) user_data_dir (배포본 권장)
            #                         (2) 자산 루트 옆 (개발/legacy)
            _gcp_key_user = user_data_dir() / "gcp-key.json"
            _gcp_key_dev = resource_path("gcp-key.json")
            _gcp_key_path = _gcp_key_user if _gcp_key_user.exists() else _gcp_key_dev
            if _gcp_key_path.exists():
                os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(_gcp_key_path)
                with open(_gcp_key_path, encoding="utf-8") as _gf:
                    _gcp_key_data = json.load(_gf)
                self._gcp_key_project_id = _gcp_key_data.get("project_id")
        except Exception:
            self._gcp_key_project_id = None

        # Phase 4: 레이어 시맨틱 매핑
        self.dxf_doc = None              # ezdxf 문서 객체
        self.layer_mapping = {}          # {레이어명: 구조물유형ID}
        self.layer_geometries = {}       # {레이어명: [(type, coords), ...]}
        self.structure_types = {}        # YAML에서 로드된 구조물 유형 정의
        self.layer_elevations = {}       # {레이어명: {"mode": "terrain"|"manual", "start_el": float, "end_el": float}}
        self.structure_registry = {}     # {레이어명: {"centroid": (x,y), "bounds": (minx,miny,maxx,maxy), "name": str, "type_id": str, "detail_params": dict|None, "detail_dxf": str|None}}
        self._load_structure_types()

        # Harness 통합 (품질검증, seed, 프롬프트, 로거)
        if HARNESS_AVAILABLE:
            setup_logging(log_file=harness_log_path())
            init_db(str(db_path()))
            self.hlog = get_logger("scanvas")
            self.job_logger = JobLogger()
            self.seed_mgr = SeedManager()
            self.quality_val = QualityValidator(min_resolution=1024, sharpness_threshold=50.0)
            self.prompt_reg = PromptRegistry(resource_path("prompt_templates"))
            self.hlog.info("Harness 모듈 로드 완료", modules=["logger", "seed", "quality", "prompt"])
        else:
            self.hlog = None
            self.job_logger = None
            self.seed_mgr = None
            self.quality_val = None
            self.prompt_reg = None

        # 구조물 분류/추출 진단 로그 (Step 1 실행 시 덮어씀)
        self.diag_log_path = diagnostic_log_path()

        # 렌더링 옵션
        self.time_of_day = ctk.StringVar(value="낮 (Daytime)")
        self.camera_elevation = ctk.DoubleVar(value=45.0)   # 카메라 앙각 (도)
        self.camera_azimuth = ctk.DoubleVar(value=225.0)     # 카메라 방위각 (도)
        self.render_strength = ctk.DoubleVar(value=0.3)      # upscale creativity (낮을수록 원본 유지)

        # 위성 타일 서버 설정
        self.tile_servers = {
            "Google Satellite": "https://mt{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
            "Google Hybrid": "https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
            "ArcGIS World Imagery": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
            "ArcGIS Hybrid": "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}",
            "Bing Aerial": "https://ecn.t{s}.tiles.virtualearth.net/tiles/a{q}.jpeg?g=1",
            "OpenStreetMap": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
            "OpenTopo": "https://tile.opentopomap.org/{z}/{x}/{y}.png",
            "Vworld 위성 (키필요)": "https://api.vworld.kr/req/wmts/1.0.0/{vworld_key}/Satellite/{z}/{y}/{x}.jpeg",
            "Vworld 기본 (키필요)": "https://api.vworld.kr/req/wmts/1.0.0/{vworld_key}/Base/{z}/{y}/{x}.png",
            "Vworld 하이브리드 (키필요)": "https://api.vworld.kr/req/wmts/1.0.0/{vworld_key}/Hybrid/{z}/{y}/{x}.png",
        }
        # Vworld API Key 기본값 — 사용자 제공 키 프리필(빈 칸이면 Vworld 타일 실패).
        self.vworld_api_key = ctk.StringVar(value="383CB30A-2AD8-3199-8A7B-215DE3E4280C")

        # 그리드 레이아웃 설정
        self.grid_columnconfigure(1, weight=1)
        self.grid_rowconfigure(0, weight=1)

        # --- 사이드바 프레임 (스크롤 가능 — 창이 작아도 모든 위젯 접근 보장) ---
        # 바깥 컨테이너: 고정 폭 확보 + 스크롤 프레임을 담는 역할
        self.sidebar_container = ctk.CTkFrame(self, width=270, corner_radius=0)
        self.sidebar_container.grid(row=0, column=0, sticky="nsew")
        self.sidebar_container.grid_propagate(False)   # 내부 위젯이 폭을 바꿔도 고정
        self.sidebar_container.grid_columnconfigure(0, weight=1)
        self.sidebar_container.grid_rowconfigure(0, weight=1)

        self.sidebar_frame = ctk.CTkScrollableFrame(
            self.sidebar_container, width=250, corner_radius=0,
            label_text=None, fg_color="transparent")
        self.sidebar_frame.grid(row=0, column=0, sticky="nsew")
        self.sidebar_frame.grid_columnconfigure(0, weight=1)

        # pack(row) 일괄 관리 — add 순서대로 자동 증가. row 번호 충돌 방지.
        self._sidebar_row = 0
        def _next_row():
            r = self._sidebar_row
            self._sidebar_row += 1
            return r

        pad = {"padx": 14}

        def _divider(pady=(8, 8)):
            """Thin 1px 수평 구분선 — Light/Dark 테마별 색상 쌍."""
            d = ctk.CTkFrame(
                self.sidebar_frame, height=1, corner_radius=0,
                fg_color=("#DEE2E6", "#3F3F3F"))
            d.grid(row=_next_row(), column=0, sticky="ew", padx=14, pady=pady)
            return d

        # S-CANVAS 로고 — Design/Logo.png.
        #   이미지가 이미 알파 채널이 있어도 엣지에 남은 near-white 픽셀 / 체커 배경
        #   아티팩트를 확실히 제거하기 위해 `_load_image_strip_white_bg` 를 거침.
        #   CTkLabel `fg_color="transparent"` 로 사이드바 배경이 투명 픽셀을 관통해
        #   자연스럽게 보이도록.
        self._saman_asset_dir = resource_path("Design")
        try:
            # logo_V2.png — 다크 네이비 배경 + 회로 패턴. 어두운 배경을 소프트
            # 임계로 투명화(v_low=25 ~ v_high=75)해 사이드바 bg 와 자연스럽게
            # 어우러지게. 로고/서브타이틀 자체는 충분히 밝아 보존됨.
            _logo_src = self._saman_asset_dir / "logo_V2.png"
            _logo_pil = _load_image_strip_dark_bg(_logo_src, v_low=25, v_high=75)
            _lw, _lh = _logo_pil.size
            _target_w = 230
            _target_h = int(_lh * _target_w / max(_lw, 1))
            _logo_img = ctk.CTkImage(
                light_image=_logo_pil, dark_image=_logo_pil,
                size=(_target_w, _target_h),
            )
            self.logo_label = ctk.CTkLabel(
                self.sidebar_frame, image=_logo_img, text="",
                fg_color="transparent")
        except Exception as _le:
            self.logo_label = ctk.CTkLabel(
                self.sidebar_frame, text="S-CANVAS",
                font=ctk.CTkFont(size=24, weight="bold"),
                fg_color="transparent")
            print(f"[Warning] Logo 이미지 로드 실패, 텍스트 폴백: {_le}")
        self.logo_label.grid(row=_next_row(), column=0, pady=(22, 6), **pad)

        self.sub_logo_label = ctk.CTkLabel(
            self.sidebar_frame,
            text="Generative Design & Visualization Engine",
            font=ctk.CTkFont(size=10, slant="italic"),
            text_color=("#6C757D", "#9A9A9A"))
        self.sub_logo_label.grid(row=_next_row(), column=0, pady=(0, 14), **pad)

        # --- 구분선: 헤더 ↔ SETTINGS ---
        _divider(pady=(0, 12))

        # SETTINGS — 섹션 헤더 (uppercase 소문자 tracking 느낌의 bold).
        self.settings_label = ctk.CTkLabel(
            self.sidebar_frame, text="SETTINGS",
            font=ctk.CTkFont(size=10, weight="bold"),
            text_color=("#6C757D", "#9A9A9A"))
        self.settings_label.grid(row=_next_row(), column=0, pady=(0, 6), sticky="w", **pad)

        # 위성 타일 소스 선택
        self.tile_label = ctk.CTkLabel(self.sidebar_frame, text="Satellite Source:",
                                       font=ctk.CTkFont(size=11))
        self.tile_label.grid(row=_next_row(), column=0, sticky="w", **pad)
        self.tile_source_option = ctk.CTkOptionMenu(
            self.sidebar_frame,
            values=list(self.tile_servers.keys()),
            command=self._on_tile_source_changed,
        )
        self.tile_source_option.grid(row=_next_row(), column=0, pady=(0, 3), sticky="ew", **pad)
        self.tile_source_option.set("Google Satellite")

        # Vworld API Key
        self.vworld_label = ctk.CTkLabel(self.sidebar_frame, text="Vworld API Key:",
                                         font=ctk.CTkFont(size=11))
        self.vworld_label.grid(row=_next_row(), column=0, sticky="w", **pad)
        self.vworld_entry = ctk.CTkEntry(
            self.sidebar_frame, textvariable=self.vworld_api_key,
            placeholder_text="Vworld 외에는 불필요", show="*")
        self.vworld_entry.grid(row=_next_row(), column=0, pady=(0, 4), sticky="ew", **pad)

        # 렌더링 엔진
        self.render_engine = ctk.StringVar(value="Gemini (Vertex AI)")
        self.engine_label = ctk.CTkLabel(self.sidebar_frame, text="AI Render Engine:",
                                         font=ctk.CTkFont(size=11))
        self.engine_label.grid(row=_next_row(), column=0, sticky="w", **pad)
        self.engine_option = ctk.CTkOptionMenu(
            self.sidebar_frame, variable=self.render_engine,
            values=["Gemini (Vertex AI)", "Gemini (API Key)", "Stability AI (API)"],
            command=self._on_engine_changed)
        self.engine_option.grid(row=_next_row(), column=0, pady=(0, 3), sticky="ew", **pad)

        # API Key / GCP Project ID
        self.stab_label = ctk.CTkLabel(
            self.sidebar_frame, text="GCP Project ID / API Key:",
            font=ctk.CTkFont(size=11))
        self.stab_label.grid(row=_next_row(), column=0, sticky="w", **pad)
        default_proj = (self._gcp_key_project_id
                        or os.environ.get("GCP_PROJECT_ID", ""))
        self.gemini_api_key.set(default_proj)
        self.stab_entry = ctk.CTkEntry(
            self.sidebar_frame, textvariable=self.gemini_api_key,
            placeholder_text="GCP 프로젝트 ID (Vertex AI) / API Key",
            show="*")
        self.stab_entry.grid(row=_next_row(), column=0, pady=(0, 3), sticky="ew", **pad)

        # Vertex AI Location
        self.vertex_location = ctk.StringVar(value=os.environ.get("GCP_LOCATION", "global"))
        self.loc_label = ctk.CTkLabel(
            self.sidebar_frame, text="Vertex AI Location:",
            font=ctk.CTkFont(size=10), text_color="gray")
        self.loc_label.grid(row=_next_row(), column=0, sticky="w", **pad)
        self.loc_entry = ctk.CTkEntry(
            self.sidebar_frame, textvariable=self.vertex_location,
            placeholder_text="us-central1")
        self.loc_entry.grid(row=_next_row(), column=0, pady=(0, 4), sticky="ew", **pad)

        # 좌표계
        self.crs_label = ctk.CTkLabel(self.sidebar_frame, text="Project CRS (DXF):",
                                      font=ctk.CTkFont(size=11))
        self.crs_label.grid(row=_next_row(), column=0, sticky="w", **pad)
        self.crs_option = ctk.CTkOptionMenu(
            self.sidebar_frame,
            values=["EPSG:5187", "EPSG:5186", "EPSG:5185", "EPSG:5181", "EPSG:3857"])
        self.crs_option.grid(row=_next_row(), column=0, pady=(0, 8), sticky="ew", **pad)
        self.crs_option.set("EPSG:5187")

        # --- 구분선 + WORKFLOW 섹션 ---
        _divider(pady=(10, 10))
        self.workflow_label = ctk.CTkLabel(
            self.sidebar_frame, text="WORKFLOW",
            font=ctk.CTkFont(size=10, weight="bold"),
            text_color=("#6C757D", "#9A9A9A"))
        self.workflow_label.grid(row=_next_row(), column=0, pady=(0, 6), sticky="w", **pad)

        # Workflow 버튼 — 높이 축소·간격 축소
        self.btn_step1 = self.create_sidebar_button(
            "1. TIN 생성 (DXF)", self.btn_tin_callback, row=_next_row())
        self.btn_step1_core = self.create_sidebar_button(
            "🎯 TIN 이용 범위 (정밀 구역)", self.btn_select_core_range_callback,
            row=_next_row(), fg_color="transparent", border_width=1)
        self.btn_step1p5 = self.create_sidebar_button(
            "1.5 DEM으로 TIN 확장", self.btn_extend_tin_with_dem_callback,
            row=_next_row(), fg_color="transparent", border_width=1)
        self.btn_step2 = self.create_sidebar_button(
            "2. 위성지도 결합", self.btn_draping_callback, row=_next_row(),
            fg_color="transparent", border_width=1)
        self.btn_step3 = self.create_sidebar_button(
            "3. 제어맵 추출", self.btn_control_map_callback, row=_next_row(),
            fg_color="transparent", border_width=1)

        # 메인 액션 버튼 — S-CANVAS 브랜드 오렌지(Saman Corp 컬러 계열).
        #   사이드바 내 다른 버튼(blue-theme 파생)과 대비되어 사용자의 눈이
        #   자연스럽게 이 버튼으로 유도됨.
        self.btn_step4 = ctk.CTkButton(
            self.sidebar_frame, text="4. AI 렌더링",
            command=self.btn_ai_render_callback, height=40,
            fg_color="#E67E22", hover_color="#BA6116",
            font=ctk.CTkFont(weight="bold"))
        self.btn_step4.grid(row=_next_row(), column=0, pady=(6, 6), sticky="ew", **pad)

        self.btn_struct_build = ctk.CTkButton(
            self.sidebar_frame, text="구조물 상세 3D 빌드",
            command=self._open_structure_template_dialog, height=32,
            fg_color="#27AE60", hover_color="#1E8449", text_color="white",
            font=ctk.CTkFont(size=12, weight="bold"))
        self.btn_struct_build.grid(row=_next_row(), column=0, pady=(3, 0), sticky="ew", **pad)

        self.btn_detail = ctk.CTkButton(
            self.sidebar_frame, text="간단 치수 추가 (구)",
            command=self._open_detail_upload_dialog, height=26,
            fg_color="transparent", border_width=1,
            border_color=("#CED4DA", "#3F3F3F"),
            text_color=("#6C757D", "#7F8C8D"),
            hover_color=("#E9ECEF", "#2C3E50"),
            font=ctk.CTkFont(size=10))
        self.btn_detail.grid(row=_next_row(), column=0, pady=(3, 0), sticky="ew", **pad)

        self.btn_reopen_3d = ctk.CTkButton(
            self.sidebar_frame, text="🗔 3D 뷰 다시 열기",
            command=self._reopen_3d_preview, height=30,
            fg_color=("#343A40", "#2C3E50"),
            hover_color=("#212529", "#34495E"),
            text_color="#FFFFFF",
            font=ctk.CTkFont(size=11, weight="bold"))
        self.btn_reopen_3d.grid(row=_next_row(), column=0, pady=(6, 0), sticky="ew", **pad)

        # --- 구분선 + OPTIONS 섹션 ---
        _divider(pady=(12, 10))
        self.options_label = ctk.CTkLabel(
            self.sidebar_frame, text="OPTIONS",
            font=ctk.CTkFont(size=10, weight="bold"),
            text_color=("#6C757D", "#9A9A9A"))
        self.options_label.grid(row=_next_row(), column=0, pady=(0, 6), sticky="w", **pad)

        self.wireframe_var = ctk.BooleanVar(value=False)
        self.wireframe_check = ctk.CTkCheckBox(
            self.sidebar_frame, text="와이어프레임 보기",
            variable=self.wireframe_var)
        self.wireframe_check.grid(row=_next_row(), column=0, pady=(8, 2), sticky="w", **pad)

        # 뷰/DEM 버퍼 설정
        self.dem_extend_var = ctk.BooleanVar(value=False)
        self.dem_frame = ctk.CTkFrame(self.sidebar_frame, fg_color="transparent")
        self.dem_frame.grid(row=_next_row(), column=0, pady=(2, 4), sticky="ew", **pad)
        self.dem_frame.grid_columnconfigure(0, weight=1)

        self.buffer_percent_label = ctk.CTkLabel(
            self.dem_frame, text="뷰 버퍼 (%) [Step2/3]",
            font=ctk.CTkFont(size=11))
        self.buffer_percent_label.grid(row=0, column=0, sticky="w")
        self.buffer_percent_var = ctk.StringVar(value="5")
        self.buffer_percent_entry = ctk.CTkEntry(
            self.dem_frame, textvariable=self.buffer_percent_var,
            placeholder_text="%", width=60,
            font=ctk.CTkFont(size=11))
        self.buffer_percent_entry.grid(row=0, column=1, padx=(6, 0), sticky="e")

        self.dem_extend_check = ctk.CTkCheckBox(
            self.dem_frame, text="지형 확장 (DEM)",
            variable=self.dem_extend_var,
            font=ctk.CTkFont(size=11))
        self.dem_extend_check.grid(row=1, column=0, pady=(4, 0), sticky="w")
        self.dem_buffer_var = ctk.StringVar(value="1000")
        self.dem_buffer_entry = ctk.CTkEntry(
            self.dem_frame, textvariable=self.dem_buffer_var,
            placeholder_text="m", width=60,
            font=ctk.CTkFont(size=11))
        self.dem_buffer_entry.grid(row=1, column=1, padx=(6, 0), pady=(4, 0), sticky="e")

        # 테마 설정 — 별도 row (이전에는 dem_frame과 row 충돌)
        self.appearance_mode_optionemenu = ctk.CTkOptionMenu(
            self.sidebar_frame, values=["Dark", "Light"],
            command=self.change_appearance_mode_event)
        self.appearance_mode_optionemenu.grid(
            row=_next_row(), column=0, pady=(8, 4), sticky="ew", **pad)
        self.appearance_mode_optionemenu.set("Light")

        # --- 구분선: OPTIONS ↔ Saman 크레딧 푸터 ---
        _divider(pady=(14, 0))

        # SAMAN Corp 크레딧 — 사이드바 최하단 푸터.
        #   Design/SAMAN_CI.gif 는 흰 배경 GIF → `_load_image_strip_white_bg` 로
        #   흰색 픽셀을 알파 0 으로 변환해 Light/Dark 테마 어느 쪽에서도 배경과
        #   자연스럽게 어우러지도록. 실패 시 텍스트 폴백.
        try:
            _saman_src = self._saman_asset_dir / "SAMAN_CI.gif"
            _saman_pil = _load_image_strip_white_bg(_saman_src, threshold=235)
            _sw, _sh = _saman_pil.size
            _saman_w = 150
            _saman_h = int(_sh * _saman_w / max(_sw, 1))
            _saman_img = ctk.CTkImage(
                light_image=_saman_pil, dark_image=_saman_pil,
                size=(_saman_w, _saman_h),
            )
            self.saman_credit = ctk.CTkLabel(
                self.sidebar_frame, image=_saman_img, text="",
                fg_color="transparent")
        except Exception as _se:
            self.saman_credit = ctk.CTkLabel(
                self.sidebar_frame, text="© Saman Corp.",
                font=ctk.CTkFont(size=10),
                text_color=("#6C757D", "gray"),
                fg_color="transparent")
            print(f"[Warning] SAMAN_CI 로드 실패: {_se}")
        self.saman_credit.grid(row=_next_row(), column=0, pady=(10, 16), **pad)

        # --- 메인 콘텐츠 프레임 ---
        self.main_frame = ctk.CTkFrame(self, corner_radius=15, fg_color="transparent")
        self.main_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
        self.main_frame.grid_columnconfigure(0, weight=1)
        self.main_frame.grid_rowconfigure(0, weight=3)  # 지도 (넓게)
        self.main_frame.grid_rowconfigure(1, weight=1)  # 로그 (좁게)

        # 1. 지도 (상단 — 넓게). Light/Dark 테마별 배경 쌍 — tkintermapview
        #   타일 주변의 얇은 padding 에 이 색이 보임.
        self.map_frame = ctk.CTkFrame(
            self.main_frame, corner_radius=12,
            fg_color=("#FFFFFF", "#2b2b2b"),
            border_width=1, border_color=("#DEE2E6", "#3F3F3F"),
        )
        self.map_frame.grid(row=0, column=0, padx=0, pady=(0, 8), sticky="nsew")
        self.map_frame.grid_columnconfigure(0, weight=1)
        self.map_frame.grid_rowconfigure(0, weight=1)

        self.map_view = tkintermapview.TkinterMapView(self.map_frame, corner_radius=10)
        self.map_view.grid(row=0, column=0, padx=5, pady=5, sticky="nsew")
        self.map_view.set_tile_server("https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}")
        self.map_view.set_zoom(6)
        self.map_view.set_position(36.5, 127.5)

        # 2. 로그 (하단 — 스크롤 가능, 높이 줄임)
        self.textbox = ctk.CTkTextbox(self.main_frame, height=120, font=ctk.CTkFont(family="Consolas", size=12), border_width=1)
        self.textbox.grid(row=1, column=0, padx=0, pady=0, sticky="nsew")

        # 3. 하단 상태 바
        self.status_bar = ctk.CTkFrame(self.main_frame, height=28, fg_color="transparent")
        self.status_bar.grid(row=2, column=0, sticky="ew", pady=(5, 0))

        self.status_indicator = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#2ECC71", font=ctk.CTkFont(size=12, weight="bold"))
        self.status_indicator.pack(side="left", padx=10)

        self.status_text = ctk.CTkLabel(self.status_bar, text="지형 데이터를 로드해 주세요.", font=ctk.CTkFont(size=12))
        self.status_text.pack(side="left")

        self.log("S-CANVAS Generative Design Engine 구동 완료.")

    def create_sidebar_button(self, text, command, row, **kwargs):
        btn = ctk.CTkButton(
            self.sidebar_frame, text=text, command=command, height=34, **kwargs)
        btn.grid(row=row, column=0, padx=14, pady=4, sticky="ew")
        return btn

    def _setup_window_icon(self):
        """logo_V2.png 의 'S' 글자만 잘라 ICO 로 변환 → 타이틀바·작업표시줄 아이콘.

        절차:
            1. logo_V2.png 다크 배경 소프트 스트립
            2. 좌측 850px 크롭 → getbbox 로 'S' 만 타이트하게 트림
            3. 정사각 패딩(투명) → 멀티사이즈 ICO 저장 (16/32/48/64/128/256)
            4. self.iconbitmap 으로 적용. 실패 시 PhotoImage 폴백.

        캐시: cache/icons/scanvas_S.ico. logo_V2.png 가 더 새로우면 재생성.
        """
        try:
            src = resource_path("Design", "logo_V2.png")
            if not src.exists():
                return
            icon_cache = cache_dir("icons")
            ico_path = icon_cache / "scanvas_S.ico"
            png_path = icon_cache / "scanvas_S.png"

            need_regen = (
                not ico_path.exists()
                or src.stat().st_mtime > ico_path.stat().st_mtime
            )
            if need_regen:
                # 1) 다크 bg **하드 스트립** (아이콘용 — 잔여 회로 패턴 제거를 위해
                #    소프트 전이 대신 단일 임계 사용. v < 90 인 픽셀은 알파 0).
                pil = Image.open(src).convert("RGBA")
                _arr = np.asarray(pil).copy()
                _v = np.maximum.reduce([_arr[..., 0], _arr[..., 1], _arr[..., 2]])
                _arr[..., 3] = np.where(_v < 90, 0, _arr[..., 3])
                w = pil.width
                # 2) 좌측 ~32% 크롭 (S 영역)
                crop_w = min(w, 850)
                _carr = _arr[:, :crop_w, :]
                # 3) **연결 컴포넌트 기반 S 만 추출** — 회로 패턴/EG-BIM 워터마크 등
                #    잔여 잡음 제거. 가장 큰 컴포넌트 + 그 10% 이상 크기인 다른
                #    컴포넌트(예: S 의 하부 곡선이 strip 으로 끊긴 경우) 만 유지.
                from scipy import ndimage as _nd
                _mask = _carr[..., 3] > 100
                _labeled, _ncomp = _nd.label(_mask)
                if _ncomp > 0:
                    _sizes = _nd.sum(_mask, _labeled, range(1, _ncomp + 1))
                    _max_size = float(_sizes.max())
                    _keep_ids = [i + 1 for i, s in enumerate(_sizes)
                                 if s >= _max_size * 0.1]
                    _keep_mask = np.isin(_labeled, _keep_ids)
                    # 비-S 픽셀의 알파 0 처리
                    _carr_clean = _carr.copy()
                    _carr_clean[..., 3] = np.where(_keep_mask, _carr[..., 3], 0)
                    # 타이트 bbox
                    _ys, _xs = np.where(_keep_mask)
                    _x0, _x1 = int(_xs.min()), int(_xs.max()) + 1
                    _y0, _y1 = int(_ys.min()), int(_ys.max()) + 1
                    cropped = Image.fromarray(
                        _carr_clean[_y0:_y1, _x0:_x1, :], mode="RGBA")
                else:
                    cropped = Image.fromarray(_carr, mode="RGBA")
                # 4) 정사각 패딩 (투명)
                cw, ch = cropped.size
                side = max(cw, ch)
                # 살짝 외곽 padding 추가 (아이콘이 너무 꽉 차 보이지 않게)
                pad_px = max(int(side * 0.04), 4)
                side_p = side + 2 * pad_px
                square = Image.new("RGBA", (side_p, side_p), (0, 0, 0, 0))
                offset = ((side_p - cw) // 2, (side_p - ch) // 2)
                square.paste(cropped, offset, cropped)
                # 4) ICO 멀티사이즈 저장 + PNG 폴백 사본
                square.save(
                    ico_path, format="ICO",
                    sizes=[(16, 16), (32, 32), (48, 48), (64, 64),
                           (128, 128), (256, 256)],
                )
                square.resize((256, 256), Image.LANCZOS).save(png_path)

            # 5) Tk 적용 — Windows 는 iconbitmap, 실패 시 iconphoto 폴백
            try:
                self.iconbitmap(default=str(ico_path))
            except Exception:
                try:
                    self.iconbitmap(str(ico_path))
                except Exception:
                    from tkinter import PhotoImage
                    photo = PhotoImage(file=str(png_path))
                    self.iconphoto(True, photo)
                    self._icon_photo_ref = photo  # GC 방지
        except Exception as e:
            print(f"[Warning] 윈도우 아이콘 설정 실패: {e}")

    def log(self, message):
        timestamp = datetime.datetime.now().strftime("[%H:%M:%S]")
        def _update():
            self.textbox.insert("end", f"{timestamp} {message}\n")
            self.textbox.see("end")
        self.after(0, _update)

    def _diag(self, message, *, reset=False):
        """구조물 분류/추출 진단 로그 (scanvas_diagnostic.log).

        reset=True면 파일을 새로 덮어쓰기(세션 시작). 그 외에는 append.
        사용자가 이 파일을 제출하면 Step 1 흐름의 모든 결정을 재구성할 수 있다.
        """
        mode = "w" if reset else "a"
        try:
            with open(self.diag_log_path, mode, encoding="utf-8") as f:
                ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                f.write(f"[{ts}] {message}\n")
        except Exception:
            pass

    def set_status(self, text, indicator_color="#2ECC71"):
        def _update():
            self.status_text.configure(text=text)
            self.status_indicator.configure(text_color=indicator_color)
        self.after(0, _update)

    def change_appearance_mode_event(self, new_appearance_mode: str):
        ctk.set_appearance_mode(new_appearance_mode)

    def _load_structure_types(self):
        """YAML에서 구조물 유형 레지스트리를 로드"""
        try:
            import yaml
            yaml_path = resource_path("structure_types", "structure_v1.yaml")
            if yaml_path.exists():
                with open(yaml_path, encoding="utf-8") as f:
                    data = yaml.safe_load(f)
                self.structure_types = data.get("types", {})
            else:
                # 기본 유형 (YAML 없을 때 폴백)
                self.structure_types = {
                    "terrain": {"name_ko": "지형 (등고선)", "render_mode": "tin", "color": "#8B7355"},
                    "excavation": {"name_ko": "굴착", "render_mode": "surface_overlay", "color": "#D4A373"},
                    "road": {"name_ko": "도로/공사용도로", "render_mode": "path_extrude", "color": "#3D3D3D"},
                    "cofferdam_upstream": {"name_ko": "상류 가물막이", "render_mode": "wall_extrude", "color": "#6C757D"},
                    "cofferdam_downstream": {"name_ko": "하류 가물막이", "render_mode": "wall_extrude", "color": "#8D9CA6"},
                    "building": {"name_ko": "건축물/가설건물", "render_mode": "box_extrude", "color": "#BDC3C7"},
                    "temp_facility": {"name_ko": "가설부지/야적장", "render_mode": "surface_overlay", "color": "#E8DACC"},
                    "boundary": {"name_ko": "경계선 (참고용)", "render_mode": "line_only", "color": "#E74C3C"},
                    "ignore": {"name_ko": "무시 (사용 안 함)", "render_mode": "none", "color": "#CCCCCC"},
                }
        except Exception:
            self.structure_types = {"terrain": {"name_ko": "지형", "render_mode": "tin"},
                                    "ignore": {"name_ko": "무시", "render_mode": "none"}}

    def _open_layer_classifier(self):
        """DXF 레이어 목록을 표시하고 사용자가 각 레이어에 구조물 유형을 지정하는 팝업.
        레이어별 엔티티 분석 결과(타입, 개수, Z값 유무)를 함께 표시.
        """
        if not self.dxf_doc:
            return False

        # 레이어별 엔티티 분석 (자동 스캔)
        layer_info = self._scan_layer_entities()
        layers = sorted(layer_info.keys())

        if not layers:
            return False

        # 유형 선택지 구성
        type_options = {tid: tdef.get("name_ko", tid) for tid, tdef in self.structure_types.items()}
        option_list = list(type_options.values())

        # 팝업 창
        win = ctk.CTkToplevel(self)
        win.title("S-CANVAS: DXF 레이어 분류")
        win.geometry("900x650")
        win.grab_set()

        header = ctk.CTkLabel(win, text="각 레이어에 구조물 유형을 지정하세요",
                             font=ctk.CTkFont(size=15, weight="bold"))
        header.pack(padx=20, pady=(15, 5))

        hint = ctk.CTkLabel(win,
            text="★ = Z값 있음 (지형 후보)  |  엔티티 타입과 개수를 참고하세요  |  초록색 = 지형 자동 감지",
            font=ctk.CTkFont(size=12), text_color="gray")
        hint.pack(padx=20, pady=(0, 10))

        # 스크롤 가능한 프레임
        scroll_frame = ctk.CTkScrollableFrame(win, height=440)
        scroll_frame.pack(padx=20, pady=5, fill="both", expand=True)
        scroll_frame.grid_columnconfigure(0, weight=1)   # 레이어 이름
        scroll_frame.grid_columnconfigure(1, weight=0)   # 엔티티 정보
        scroll_frame.grid_columnconfigure(2, weight=0)   # 드롭다운

        # Z값 기반 상위 3개 지형 후보 계산
        # 블랙리스트: 블록의 기본 레이어("0") 및 AutoCAD 메타 레이어는 auto-terrain 제외
        # — INSERT 재귀 explode 후 "0"에 구조물 치수/보조선 Z값이 쌓여 false-positive됨
        _terrain_blacklist = {"0", "Defpoints", "DEFPOINTS"}
        scored = []
        for ln, inf in layer_info.items():
            if ln in _terrain_blacklist:
                continue
            if ln.upper().startswith(("CR-", "AM_", "_")):  # 도면 템플릿/프레임 레이어
                continue
            if inf.get("has_z", False):
                zv = len(inf.get("z_values", set()))
                pc = inf.get("point_count", 0)
                if zv * pc > 0:
                    scored.append((ln, zv * pc))
        scored.sort(key=lambda x: -x[1])
        top3_terrain = set(s[0] for s in scored[:3])

        self._diag("=== 레이어 분류 UI 초기 자동추측 ===")
        self._diag(f"  top3 terrain 후보 (Z값 기반): {sorted(top3_terrain)}")

        layer_vars = {}
        for i, layer_name in enumerate(layers):
            info = layer_info[layer_name]
            entity_summary = info.get("summary", "")
            has_z = info.get("has_z", False)

            # 자동 추측: 상위 3개는 지형, 나머지는 키워드
            if layer_name in top3_terrain:
                guessed = "terrain"
                reason = "top3 Z"
            else:
                guessed = self._guess_layer_type(layer_name)
                reason = "keyword" if guessed != "ignore" else "fallback"
            self._diag(f"  {layer_name}  →  [guess] {guessed} ({reason})")

            # 레이어 이름 (Z값 있으면 ★ 표시)
            z_marker = "★ " if has_z else "   "
            name_text = f"{z_marker}{layer_name}"

            name_color = "#2ECC71" if guessed == "terrain" else None
            lbl = ctk.CTkLabel(scroll_frame, text=name_text, font=ctk.CTkFont(size=12),
                              anchor="w", width=320, text_color=name_color)
            lbl.grid(row=i, column=0, padx=(5, 3), pady=2, sticky="w")

            # 엔티티 정보
            info_lbl = ctk.CTkLabel(scroll_frame, text=entity_summary,
                                    font=ctk.CTkFont(size=10), text_color="gray",
                                    anchor="w", width=250)
            info_lbl.grid(row=i, column=1, padx=3, pady=2, sticky="w")

            # 드롭다운
            var = ctk.StringVar(value=type_options.get(guessed, type_options.get("ignore", option_list[-1])))
            dropdown = ctk.CTkOptionMenu(scroll_frame, variable=var, values=option_list, width=200)
            dropdown.grid(row=i, column=2, padx=(3, 5), pady=2, sticky="e")

            layer_vars[layer_name] = var

        # 확인/취소 버튼
        result = [False]

        def on_confirm():
            name_to_id = {v: k for k, v in type_options.items()}
            self.layer_mapping = {}
            for layer_name, var in layer_vars.items():
                selected_name = var.get()
                type_id = name_to_id.get(selected_name, "ignore")
                self.layer_mapping[layer_name] = type_id
            result[0] = True
            # 진단 로그: 분류 결과
            self._diag("=== 레이어 분류 확인 (사용자 최종 선택) ===")
            for ln, tid in self.layer_mapping.items():
                tdef = self.structure_types.get(tid, {})
                rmode = tdef.get("render_mode", "?")
                self._diag(f"  {ln}  →  {tid} ({tdef.get('name_ko', '?')}, render_mode={rmode})")
            win.destroy()

        def on_cancel():
            win.destroy()

        def set_all(type_id):
            target = type_options.get(type_id, option_list[-1])
            for v in layer_vars.values():
                v.set(target)

        btn_frame = ctk.CTkFrame(win, fg_color="transparent")
        btn_frame.pack(padx=20, pady=10, fill="x")

        ctk.CTkButton(btn_frame, text="전체 무시", width=90, fg_color="transparent", border_width=1,
                      command=lambda: set_all("ignore")).pack(side="left", padx=3)
        ctk.CTkButton(btn_frame, text="전체 지형", width=90, fg_color="transparent", border_width=1,
                      command=lambda: set_all("terrain")).pack(side="left", padx=3)
        ctk.CTkButton(btn_frame, text="★ 자동 감지", width=100, fg_color="transparent", border_width=1,
                      command=lambda: self._auto_detect_layers(layer_vars, layer_info, type_options)).pack(side="left", padx=3)
        ctk.CTkButton(btn_frame, text="취소", width=80, fg_color="transparent", border_width=1,
                      command=on_cancel).pack(side="right", padx=3)
        ctk.CTkButton(btn_frame, text="확인 (분류 적용)", width=160,
                      command=on_confirm).pack(side="right", padx=3)

        win.wait_window()
        return result[0]

    def _iter_exploded_entities(self, msp):
        """msp의 모든 엔티티를 INSERT 재귀 explode.

        Yield: (실제_레이어, entity, from_insert)
          from_insert=True면 블록 참조 내부에서 나온 엔티티 (도로 등에서 제외 판단용)

        AutoCAD 블록 규칙:
          - 블록 내부 엔티티 dxf.layer="0" → INSERT의 레이어 상속
          - 구체 레이어명 → 유지
          - 중첩 INSERT 재귀
        """
        def _walk(ent, inherit_layer, from_insert):
            etype = ent.dxftype()
            if etype == "INSERT":
                try:
                    for sub in ent.virtual_entities():
                        sub_layer = sub.dxf.layer
                        effective = inherit_layer if sub_layer == "0" else sub_layer
                        yield from _walk(sub, effective, True)
                except Exception:
                    pass
            else:
                yield (inherit_layer, ent, from_insert)

        for entity in msp:
            yield from _walk(entity, entity.dxf.layer, False)

    def _scan_layer_entities(self):
        """모든 레이어의 엔티티 타입, 개수, Z값 유무를 스캔 (INSERT explode 포함)"""
        msp = self.dxf_doc.modelspace()
        layer_info = {}

        for layer_name, entity, _from_insert in self._iter_exploded_entities(msp):
            if layer_name not in layer_info:
                layer_info[layer_name] = {"types": {}, "has_z": False, "point_count": 0, "z_values": set()}

            info = layer_info[layer_name]
            etype = entity.dxftype()
            info["types"][etype] = info["types"].get(etype, 0) + 1

            # Z값 체크
            try:
                if etype == "LWPOLYLINE":
                    z = entity.dxf.elevation if entity.dxf.hasattr('elevation') else 0.0
                    pts = list(entity.get_points())
                    info["point_count"] += len(pts)
                    if abs(z) > 0.01:
                        info["has_z"] = True
                        info["z_values"].add(round(z, 1))
                elif etype == "LINE":
                    for attr in ['start', 'end']:
                        pt = getattr(entity.dxf, attr)
                        if abs(pt.z) > 0.01:
                            info["has_z"] = True
                            info["z_values"].add(round(pt.z, 1))
                    info["point_count"] += 2
                elif etype == "POLYLINE":
                    for v in entity.vertices:
                        loc = v.dxf.location
                        if abs(loc.z) > 0.01:
                            info["has_z"] = True
                            info["z_values"].add(round(loc.z, 1))
                        info["point_count"] += 1
                elif etype == "POINT":
                    pt = entity.dxf.location
                    if abs(pt.z) > 0.01:
                        info["has_z"] = True
                    info["point_count"] += 1
                elif etype == "SPLINE":
                    for pt in entity.control_points:
                        if len(pt) > 2 and abs(pt[2]) > 0.01:
                            info["has_z"] = True
                        info["point_count"] += 1
            except Exception:
                pass

        # 요약 문자열 생성
        for layer_name, info in layer_info.items():
            parts = []
            for etype, count in sorted(info["types"].items(), key=lambda x: -x[1]):
                parts.append(f"{etype}:{count}")
            z_tag = f" Z({len(info['z_values'])}종)" if info["has_z"] else ""
            info["summary"] = ", ".join(parts[:3]) + z_tag

        # 진단 로그: 전체 레이어 엔티티 스캔 결과
        self._diag("=== _scan_layer_entities 결과 (DXF 원본 분석) ===")
        for ln in sorted(layer_info.keys()):
            inf = layer_info[ln]
            self._diag(f"  {ln}: entities={dict(inf['types'])}, has_z={inf['has_z']}, points={inf['point_count']}")

        # 레이어 목록에 없지만 layers 테이블에 있는 것도 추가 (빈 레이어)
        for layer in self.dxf_doc.layers:
            if layer.dxf.name not in layer_info:
                layer_info[layer.dxf.name] = {"types": {}, "has_z": False, "point_count": 0,
                                               "z_values": set(), "summary": "(빈 레이어)"}

        return layer_info

    def _auto_detect_layers(self, layer_vars, layer_info, type_options):
        """Z값 다양성 × 점 개수로 점수화 → 상위 3개만 지형, 나머지는 키워드 추측 또는 무시"""

        # 1. Z값이 있는 레이어만 점수 계산
        scored = []
        for layer_name, info in layer_info.items():
            if not info.get("has_z", False):
                continue
            z_variety = len(info.get("z_values", set()))  # Z값 종류 수
            pt_count = info.get("point_count", 0)
            # 점수 = Z값 다양성 × 점 개수 (등고선은 Z값 종류가 많고 점도 많음)
            score = z_variety * pt_count
            if score > 0:
                scored.append((layer_name, score, z_variety, pt_count))

        # 2. 점수 내림차순 정렬 → 상위 3개
        scored.sort(key=lambda x: -x[1])
        top3 = set(s[0] for s in scored[:3])

        # 로그
        if scored:
            self.log("  ★ 자동 감지 — Z값 기반 지형 랭킹:")
            for rank, (name, score, zv, pc) in enumerate(scored[:5], 1):
                marker = "→ 지형" if name in top3 else "  (제외)"
                self.log(f"    {rank}. {name}: 점수={score:,} (Z {zv}종 × {pc:,}점) {marker}")
        else:
            self.log("  ★ 자동 감지 — Z값이 있는 레이어를 찾지 못했습니다.")

        # 3. 적용
        for layer_name, var in layer_vars.items():
            if layer_name in top3:
                var.set(type_options.get("terrain", "지형 (등고선)"))
            else:
                guessed = self._guess_layer_type(layer_name)
                if guessed != "ignore":
                    var.set(type_options.get(guessed, type_options.get("ignore")))
                else:
                    var.set(type_options.get("ignore", "무시 (사용 안 함)"))

    def _guess_layer_type(self, layer_name):
        """레이어 이름에서 구조물 유형을 자동 추측.

        한글/영문/약어/복합어를 넓게 커버. 긴 키워드 우선 매칭되도록
        순서를 구체 → 일반 순으로 둔다.
        """
        name = layer_name.lower()

        # "노리"(비탈면/사면 표시선)는 도로가 아니라 경계선으로 분류
        if "노리" in name or "nori" in name or "사면" in name or "slope_line" in name:
            return "boundary"

        # 한글/영문 키워드 매핑 (구체적인 것부터 위에)
        # 순서 중요: 복합어("취수탑_옹벽")는 앞에 오는 키워드가 우선 매칭됨
        keywords = {
            "terrain": ["등고", "contour", "지형", "elev", "topo", "tin", "ground"],
            "intake_tower": ["취수탑", "intake_tower", "intake tower", "intake"],
            "valve_chamber": ["제수변실", "밸브실", "valve_chamber", "valve chamber"],
            "spillway_gate": ["수문", "gate", "게이트", "래디얼", "radial", "spillway_gate"],
            "cofferdam_upstream": ["상류가물", "upstream_coffer", "상류물막이", "상류 가물막이"],
            "cofferdam_downstream": ["하류가물", "downstream_coffer", "하류물막이", "하류 가물막이"],
            "diversion": ["유수전환", "diversion", "전환수로"],
            "spillway": ["여수로", "spillway", "방류", "spill_way"],
            "retaining_wall": ["옹벽", "retaining", "ret_wall", "좌안옹벽", "우안옹벽", "방벽"],
            "revetment": ["호안", "revetment"],
            "excavation": ["굴착", "excavat", "절토", "터파기", "cut"],
            "embankment": ["성토", "제체", "embankment", "fill", "dam_body"],
            "building": ["건물", "건축물", "building", "가설건물", "사무소", "현장사무", "관리동"],
            "temp_facility": ["가설", "야적", "temp", "staging", "부지"],
            "bridge": ["교량", "bridge", "viaduct"],
            "tunnel": ["터널", "tunnel", "갱구"],
            "pipeline": ["관로", "pipe", "파이프"],
            "road": ["도로", "road", "진입", "access", "가도", "공사용도로", "관리도로"],
            "boundary": ["경계", "boundary", "범위", "부지경계"],
            # "wall"은 retaining_wall 다음에 — 너무 일반적이라 최하위
        }
        for type_id, kws in keywords.items():
            if any(kw in name for kw in kws):
                return type_id
        # wall 단독 매칭 (다른 키워드 모두 실패 후에만)
        if "wall" in name:
            return "retaining_wall"
        return "ignore"

    def _extract_layer_geometries(self):
        """분류된 레이어에서 지오메트리를 추출 (Phase 4-1: 좌표만 저장)"""
        import math as _math

        self._diag("=== _extract_layer_geometries 시작 ===")

        if not self.dxf_doc or not self.layer_mapping:
            self.log("  계획선 추출 건너뜀: DXF 문서 또는 레이어 매핑 없음")
            self._diag(f"  건너뜀: dxf_doc={bool(self.dxf_doc)}, layer_mapping={len(self.layer_mapping or {})}")
            return 0

        msp = self.dxf_doc.modelspace()
        self.layer_geometries = {}

        # 지형/무시 제외한 레이어만 처리
        target_layers = {ln: tid for ln, tid in self.layer_mapping.items()
                         if tid not in ("ignore", "terrain")}

        self._diag(f"  전체 레이어: {len(self.layer_mapping)}개, 추출대상(비terrain/비ignore): {len(target_layers)}개")

        if not target_layers:
            self.log("  계획선 추출 건너뜀: 지형/무시 외 분류된 레이어 없음")
            self._diag("  결과: 추출대상 0개 — 모든 레이어가 terrain/ignore로 분류됨")
            return 0

        self.log(f"  계획선 대상 레이어: {list(target_layers.keys())}")
        self._diag(f"  대상 레이어 상세: {target_layers}")

        # Phase 1: 전체 msp를 한 번만 순회하며 (레이어 → 엔티티 리스트) 버킷팅
        #          INSERT는 재귀 explode하여 sub-entity의 실제 레이어로 귀속
        buckets = {ln: [] for ln in target_layers}
        for ent_layer, ent, from_insert in self._iter_exploded_entities(msp):
            if ent_layer not in buckets:
                continue
            # 도로/path_extrude 레이어의 INSERT 내부 엔티티는 제외 (횡단/사면/치수 혼재)
            type_id = target_layers[ent_layer]
            rmode = self.structure_types.get(type_id, {}).get("render_mode")
            if from_insert and (type_id == "road" or rmode == "path_extrude"):
                continue
            buckets[ent_layer].append(ent)

        # Phase 2: 각 레이어의 엔티티에서 geometry 추출
        for layer_name, type_id in target_layers.items():
            geoms = []
            entity_types_found = {}

            for entity in buckets[layer_name]:
                etype = entity.dxftype()
                entity_types_found[etype] = entity_types_found.get(etype, 0) + 1

                if etype == "LWPOLYLINE":
                    pts = [(p[0], p[1]) for p in entity.get_points()]
                    is_closed = entity.closed
                    if len(pts) >= 2:
                        geoms.append({"type": "polyline", "points": pts, "closed": is_closed})

                elif etype == "LINE":
                    geoms.append({"type": "line",
                                  "start": (entity.dxf.start.x, entity.dxf.start.y),
                                  "end": (entity.dxf.end.x, entity.dxf.end.y)})

                elif etype == "POLYLINE":
                    pts = [(v.dxf.location.x, v.dxf.location.y) for v in entity.vertices]
                    if len(pts) >= 2:
                        geoms.append({"type": "polyline", "points": pts, "closed": entity.is_closed})

                elif etype == "CIRCLE":
                    c = entity.dxf.center
                    geoms.append({"type": "circle", "center": (c.x, c.y), "radius": entity.dxf.radius})

                elif etype == "ARC":
                    c = entity.dxf.center
                    sa = _math.radians(entity.dxf.start_angle)
                    ea = _math.radians(entity.dxf.end_angle)
                    if ea < sa: ea += 2 * _math.pi
                    r = entity.dxf.radius
                    n_pts = max(8, int((ea - sa) / _math.radians(5)))
                    arc_pts = []
                    for ai in range(n_pts + 1):
                        angle = sa + (ea - sa) * ai / n_pts
                        arc_pts.append((c.x + r * _math.cos(angle), c.y + r * _math.sin(angle)))
                    if len(arc_pts) >= 2:
                        geoms.append({"type": "polyline", "points": arc_pts, "closed": False})

                elif etype == "SPLINE":
                    try:
                        pts = [(pt[0], pt[1]) for pt in entity.control_points]
                        if len(pts) >= 2:
                            geoms.append({"type": "polyline", "points": pts, "closed": entity.closed})
                    except Exception:
                        pass

                elif etype == "HATCH":
                    try:
                        for boundary_path in entity.paths:
                            if hasattr(boundary_path, 'vertices'):
                                pts = [(v[0], v[1]) for v in boundary_path.vertices]
                                if len(pts) >= 3:
                                    geoms.append({"type": "polyline", "points": pts, "closed": True})
                            elif hasattr(boundary_path, 'edges'):
                                geoms.extend({"type": "line",
                                              "start": (edge.start[0], edge.start[1]),
                                              "end": (edge.end[0], edge.end[1])}
                                             for edge in boundary_path.edges
                                             if hasattr(edge, 'start') and hasattr(edge, 'end'))
                    except Exception:
                        pass

            # 로그: 어떤 엔티티 타입이 있었는지 표시
            types_str = ", ".join(f"{k}:{v}" for k, v in sorted(entity_types_found.items(), key=lambda x: -x[1]))
            if geoms:
                self.layer_geometries[layer_name] = {
                    "type_id": type_id,
                    "type_def": self.structure_types.get(type_id, {}),
                    "geometries": geoms
                }
                self.log(f"    {layer_name}: {len(geoms)}개 요소 → {type_id} ({types_str})")
                self._diag(f"  [OK] {layer_name} ({type_id}): geoms={len(geoms)}, entities={{{types_str}}}")
            else:
                self.log(f"    {layer_name}: 추출 가능한 요소 없음 (엔티티: {types_str if types_str else '없음'})")
                self._diag(f"  [SKIP] {layer_name} ({type_id}): geoms=0, entities={{{types_str if types_str else '없음'}}}"
                           f" — 지원 엔티티(LWPOLYLINE/LINE/POLYLINE/CIRCLE/ARC/SPLINE/HATCH/INSERT) 없음")

        self._diag(f"=== _extract_layer_geometries 완료: {len(self.layer_geometries)}개 레이어에서 geom 추출 ===")
        return len(self.layer_geometries)

    # --- Phase 4-1b: 구조물 위치 레지스트리 ---

    def _populate_structure_registry(self):
        """layer_geometries에서 구조물 위치/경계/이름을 레지스트리에 등록.

        TIN 변형 대상(path_extrude, surface_overlay)은 이미 지형에 반영되므로
        레지스트리에서는 별도 상세도면이 필요한 구조물만 등록한다:
        wall_extrude, box_extrude, elevated_path, tube_path, line_only.
        """
        import math

        self._diag("=== _populate_structure_registry 시작 ===")
        self.structure_registry = {}

        if not self.layer_geometries:
            self._diag("  건너뜀: layer_geometries 비어있음 (geom 추출 실패)")
            return

        # 상세도면 대상 렌더 모드 (지형 변형 모드 제외)
        detail_target_modes = {"wall_extrude", "box_extrude", "elevated_path", "tube_path", "line_only"}
        self._diag(f"  레지스트리 대상 render_mode: {sorted(detail_target_modes)}")

        # DXF에서 TEXT 엔티티 수집 (구조물명 자동 인식용)
        text_entities = []
        if self.dxf_doc:
            msp = self.dxf_doc.modelspace()
            for e in msp:
                if e.dxftype() in ("TEXT", "MTEXT"):
                    try:
                        txt = e.dxf.text.strip() if e.dxftype() == "TEXT" else (e.text or "").strip()
                        pos = e.dxf.insert
                        if txt and len(txt) >= 2:
                            text_entities.append((txt, pos.x, pos.y))
                    except Exception:
                        pass

        for layer_name, layer_data in self.layer_geometries.items():
            type_def = layer_data["type_def"]
            render_mode = type_def.get("render_mode", "line_only")
            type_id = layer_data["type_id"]

            if render_mode not in detail_target_modes:
                self._diag(f"  [SKIP] {layer_name} ({type_id}): render_mode={render_mode} — 상세빌드 대상 아님 (지형변형으로 처리됨)")
                continue

            geoms = layer_data["geometries"]
            if not geoms:
                self._diag(f"  [SKIP] {layer_name} ({type_id}): geoms=0")
                continue

            # 중심/바운드 계산용 "구조체 몸체 포인트" 수집
            # 치수선/지시선(LINE) outlier가 centroid를 끌어당기지 않도록
            # 1순위: 폐합 폴리라인(구조물 외곽) → 2순위: LWPOLYLINE → 3순위: 전체
            closed_poly_pts = []
            open_poly_pts = []
            line_pts = []
            circle_pts = []
            for geom in geoms:
                if geom["type"] == "polyline":
                    if geom.get("closed"):
                        closed_poly_pts.extend(geom["points"])
                    else:
                        open_poly_pts.extend(geom["points"])
                elif geom["type"] == "line":
                    line_pts.extend([geom["start"], geom["end"]])
                elif geom["type"] == "circle":
                    cx, cy = geom["center"]
                    r = geom["radius"]
                    circle_pts.extend([(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)])

            all_pts = closed_poly_pts + open_poly_pts + line_pts + circle_pts
            if not all_pts:
                continue

            # 중심 계산용 body_pts: 폐합 폴리라인 우선, 없으면 열린 폴리라인+원
            body_pts = closed_poly_pts or (open_poly_pts + circle_pts) or all_pts
            body_arr = np.array(body_pts)
            full_arr = np.array(all_pts)

            # 중위값 centroid (outlier에 강함)
            centroid = (float(np.median(body_arr[:, 0])), float(np.median(body_arr[:, 1])))
            # 5~95 퍼센타일 bounds (치수선 끝점 무시)
            bounds = (
                float(np.percentile(body_arr[:, 0], 5)),
                float(np.percentile(body_arr[:, 1], 5)),
                float(np.percentile(body_arr[:, 0], 95)),
                float(np.percentile(body_arr[:, 1], 95)),
            )
            self._diag(f"  [CENTROID] {layer_name}: body_pts={len(body_pts)}/전체 {len(all_pts)} "
                       f"→ median ({centroid[0]:.1f}, {centroid[1]:.1f}) "
                       f"vs mean ({float(np.mean(full_arr[:,0])):.1f}, {float(np.mean(full_arr[:,1])):.1f})")

            # 인근 TEXT에서 구조물 이름 추론
            name = type_def.get("name_ko", layer_name)
            if text_entities:
                best_dist = float("inf")
                for txt, tx, ty in text_entities:
                    dist = math.sqrt((tx - centroid[0]) ** 2 + (ty - centroid[1]) ** 2)
                    # 구조물 범위의 2배 내에 있는 TEXT 중 가장 가까운 것
                    extent = max(bounds[2] - bounds[0], bounds[3] - bounds[1], 10)
                    if dist < extent * 2 and dist < best_dist:
                        # 숫자만 있는 텍스트, 좌표 형식(N,NNN) 제외
                        import re as _re
                        if not _re.match(r"^[\d.,\s]+$", txt):
                            best_dist = dist
                            name = txt

            # === 템플릿 자동 추정 ===
            suggested_template = self._suggest_template_for_structure(
                layer_name, name, type_id, all_pts
            )

            # === 오리엔테이션 계산 (PCA) — body_pts 기반 (치수선 제외) ===
            orientation_deg = 0.0
            if STRUCTURE_TEMPLATES_AVAILABLE and len(body_pts) >= 3:
                with contextlib.suppress(Exception):
                    orientation_deg = compute_orientation_from_points(body_pts)

            self.structure_registry[layer_name] = {
                "centroid": centroid,
                "bounds": bounds,
                "name": name,
                "type_id": type_id,
                "render_mode": render_mode,
                "detail_params": None,        # 기존 치수 파싱 (하위 호환)
                "detail_dxf": None,
                # --- 새 필드: 템플릿 시스템 통합 ---
                "template_id": suggested_template,  # 자동 추정 템플릿
                "template_detail_dxfs": [],         # 상세 DXF 파일들
                "template_params": None,            # 템플릿 파라미터 (StructureParams)
                "template_meshes": None,            # 빌드된 3D 메쉬
                "orientation_deg": orientation_deg, # Z축 기준 회전각
                "z_mode": "terrain",                # 기본: 지형에 맞춤
            }

        if self.structure_registry:
            self.log(f"  구조물 레지스트리: {len(self.structure_registry)}개 등록")
            self._diag(f"=== _populate_structure_registry 완료: {len(self.structure_registry)}개 등록 ===")
            for ln, info in self.structure_registry.items():
                cx, cy = info["centroid"]
                tpl = info.get("template_id") or "-"
                rot = info.get("orientation_deg", 0.0)
                self.log(f"    [{info['type_id']}→{tpl}] {info['name']} "
                         f"@ ({cx:.0f}, {cy:.0f}) rot={rot:+.1f}°")
                self._diag(f"  [REG] {ln} ({info['type_id']}→{tpl}): {info['name']} @ ({cx:.1f}, {cy:.1f}) rot={rot:+.1f}°")
        else:
            self._diag("=== _populate_structure_registry 완료: 등록된 구조물 0개 ===")

    def _suggest_template_for_structure(self, layer_name: str, name: str,
                                          type_id: str, points: list) -> str:
        """구조물의 레이어/이름/유형으로 적절한 템플릿 추정.

        우선순위:
        1. 레이어명/name에 명시적 키워드
        2. YAML type_id → template_id 매핑
        3. 기본값 "generic"
        """
        if not STRUCTURE_TEMPLATES_AVAILABLE:
            return "generic"

        combined_text = f"{layer_name} {name}".lower()

        # 명시적 키워드 매핑 (우선순위순)
        keyword_map = [
            (["취수탑", "intake.*tower"], "intake_tower"),
            (["제수변실", "밸브실", "도수관", "valve.*(?:room|chamber)"], "valve_chamber"),
            (["옹벽", "retaining.*wall"], "retaining_wall"),
            (["수문", "여수로.*수문", "spillway.*gate", "래디얼"], "spillway_gate"),
            (["교량", "bridge", "공도교"], "bridge"),
            (["터널", "갱구", "tunnel"], "tunnel_portal"),
            (["건물", "건축", "사무소", "관리동", "building"], "building"),
        ]
        import re
        for kws, tid in keyword_map:
            for kw in kws:
                if re.search(kw, combined_text, re.IGNORECASE):
                    return tid

        # type_id 직접 매핑 (YAML 유형 → 템플릿)
        type_to_template = {
            "cofferdam_upstream": "spillway_gate",
            "cofferdam_downstream": "spillway_gate",
            "diversion": "tunnel_portal",
            "spillway": "spillway_gate",
            "spillway_gate": "spillway_gate",
            "intake_tower": "intake_tower",
            "valve_chamber": "valve_chamber",
            "building": "building",
            "temp_facility": "building",
            "retaining_wall": "retaining_wall",
            "bridge": "bridge",
            "tunnel": "tunnel_portal",
            "pipeline": "valve_chamber",
        }
        if type_id in type_to_template:
            return type_to_template[type_id]

        return "generic"

    # ========================================================================
    # 구조물 템플릿 빌드 다이얼로그 (structure_templates 통합)
    # ========================================================================

    def _open_structure_template_dialog(self):
        """각 등록 구조물에 대해 전용 템플릿(취수탑/제수변실/옹벽/수문 등)
        으로 상세 3D를 빌드하는 통합 다이얼로그.

        1. 구조물 목록 (자동 추정된 템플릿 표시)
        2. 템플릿 변경 가능
        3. 상세 DXF 업로드
        4. 템플릿 파싱 + 3D 빌드
        5. 빌드된 메쉬를 레지스트리에 저장
        """
        if not STRUCTURE_TEMPLATES_AVAILABLE:
            messagebox.showerror("오류", "structure_templates 모듈을 찾을 수 없습니다.")
            return

        if not self.structure_registry:
            messagebox.showinfo("안내",
                "등록된 구조물이 없습니다. 먼저 Step 1 (TIN 생성 + 레이어 분류)을 "
                "완료하여 구조물 레이어를 등록해 주세요.")
            return

        win = ctk.CTkToplevel(self)
        win.title("S-CANVAS: 구조물 상세 3D 빌드 (템플릿 기반)")
        win.geometry("1100x650")
        win.grab_set()

        ctk.CTkLabel(win,
            text="구조물별 상세도면을 업로드하여 정밀 3D 모델을 생성합니다",
            font=ctk.CTkFont(size=14, weight="bold")
        ).pack(padx=20, pady=(15, 5))

        ctk.CTkLabel(win,
            text="자동 추정된 템플릿을 확인하고, 상세 DXF를 추가하세요. "
                 "빌드된 구조물은 지형 3D 뷰에 자동 배치됩니다.",
            font=ctk.CTkFont(size=11), text_color="gray"
        ).pack(padx=20, pady=(0, 10))

        # 헤더 + 구조물 목록 프레임
        list_frame = ctk.CTkScrollableFrame(win, height=460)
        list_frame.pack(padx=15, pady=5, fill="both", expand=True)

        headers = ["구조물 이름", "위치 (m)", "템플릿", "상세 DXF", "빌드 상태", "작업"]
        widths = [180, 120, 160, 180, 120, 100]
        for ci, (h, w) in enumerate(zip(headers, widths, strict=False)):
            list_frame.grid_columnconfigure(ci, weight=1 if ci == 0 else 0,
                                             minsize=w)
            ctk.CTkLabel(list_frame, text=h,
                         font=ctk.CTkFont(size=11, weight="bold")
                         ).grid(row=0, column=ci, padx=3, pady=5, sticky="w")

        template_choices = [(t.template_id, t.name_ko)
                            for t in STRUCTURE_REGISTRY.list_all()]
        template_id_to_name = {tid: name for tid, name in template_choices}
        template_name_to_id = {name: tid for tid, name in template_choices}

        row_widgets = {}  # layer_name → widget refs

        for ri, (layer_name, info) in enumerate(self.structure_registry.items(), 1):
            name = info["name"]
            centroid = info["centroid"]
            tid = info.get("template_id", "generic")

            ctk.CTkLabel(list_frame, text=name[:22],
                         font=ctk.CTkFont(size=11)
                         ).grid(row=ri, column=0, padx=3, pady=3, sticky="w")
            ctk.CTkLabel(list_frame, text=f"({centroid[0]:.0f}, {centroid[1]:.0f})",
                         font=ctk.CTkFont(size=10), text_color="gray"
                         ).grid(row=ri, column=1, padx=3, pady=3, sticky="w")

            # 템플릿 드롭다운
            current_name = template_id_to_name.get(tid, "일반 / 범용")
            tpl_var = ctk.StringVar(value=current_name)
            tpl_menu = ctk.CTkOptionMenu(
                list_frame, variable=tpl_var,
                values=[n for _, n in template_choices], width=150,
            )
            tpl_menu.grid(row=ri, column=2, padx=3, pady=3, sticky="w")

            # 상세 DXF 파일 상태
            dxf_var = ctk.StringVar(value="미등록")
            dxfs = info.get("template_detail_dxfs", [])
            if dxfs:
                dxf_var.set(f"{len(dxfs)}개 파일")
            dxf_label = ctk.CTkLabel(list_frame, textvariable=dxf_var,
                                      font=ctk.CTkFont(size=10),
                                      text_color="#3498DB")
            dxf_label.grid(row=ri, column=3, padx=3, pady=3, sticky="w")

            # 빌드 상태
            status_var = ctk.StringVar(value="대기")
            if info.get("template_meshes"):
                status_var.set(f"빌드됨 ({len(info['template_meshes'])}개)")
            status_label = ctk.CTkLabel(list_frame, textvariable=status_var,
                                         font=ctk.CTkFont(size=10),
                                         text_color="#2ECC71")
            status_label.grid(row=ri, column=4, padx=3, pady=3, sticky="w")

            # 작업 버튼
            btn_frame = ctk.CTkFrame(list_frame, fg_color="transparent")
            btn_frame.grid(row=ri, column=5, padx=3, pady=3, sticky="w")

            def _make_review(ln=layer_name, tv=tpl_var):
                def _review():
                    self._open_structure_review_dialog(
                        ln, tv, template_name_to_id, row_widgets,
                    )
                return _review

            ctk.CTkButton(btn_frame, text="검토+빌드", width=95, height=26,
                          command=_make_review(),
                          fg_color="#1f538d", hover_color="#14375e",
                          font=ctk.CTkFont(size=10)
                          ).pack(side="left")

            row_widgets[layer_name] = {
                "tpl_var": tpl_var, "dxf_var": dxf_var, "status_var": status_var,
            }

        # 하단 버튼
        bottom = ctk.CTkFrame(win, fg_color="transparent")
        bottom.pack(padx=20, pady=10, fill="x")

        ctk.CTkLabel(bottom,
            text="※ 상세 DXF 없이 기본 파라미터로 빌드도 가능합니다.",
            font=ctk.CTkFont(size=10), text_color="gray"
        ).pack(side="left", padx=5)

        ctk.CTkButton(bottom, text="닫기", width=80,
                      fg_color="transparent", border_width=1,
                      command=win.destroy).pack(side="right", padx=5)

        def _build_all_without_detail():
            """모든 구조물을 상세 DXF 없이 기본 템플릿 값으로 빌드."""
            for ln, info in self.structure_registry.items():
                if info.get("template_meshes"):
                    continue  # 이미 빌드됨
                tid = info.get("template_id", "generic")
                tpl = STRUCTURE_REGISTRY.get(tid)
                if not tpl:
                    continue
                try:
                    params = tpl.parse([])  # 빈 리스트 → 기본값
                    meshes = tpl.build_meshes(params)
                    info["template_params"] = params
                    info["template_meshes"] = meshes
                    w = row_widgets.get(ln)
                    if w:
                        w["status_var"].set(f"빌드됨 ({len(meshes)}개)")
                    self.log(f"  [{ln}] 기본값 빌드: {len(meshes)}개 메쉬")
                except Exception as e:
                    self.log(f"  [{ln}] 빌드 실패: {e}")

            messagebox.showinfo("완료",
                "모든 구조물이 기본값으로 빌드되었습니다. "
                "3D 프리뷰에서 확인하세요.")

        ctk.CTkButton(bottom, text="기본값으로 모두 빌드", width=150,
                      command=_build_all_without_detail
                      ).pack(side="right", padx=5)

        def _apply_structures_to_tin():
            """확정된 구조물들을 TIN에 반영 (굴착 + 배치) → 3D 프리뷰."""
            confirmed = [ln for ln, i in self.structure_registry.items()
                         if i.get("template_meshes")]
            if not confirmed:
                messagebox.showinfo("안내", "확정된 구조물이 없습니다.\n"
                                    "각 구조물의 '검토+빌드'에서 확정하세요.")
                return
            self.log(f">>> 구조물 TIN 반영 시작 ({len(confirmed)}개 확정)")
            self._excavate_tin_for_structures()
            win.destroy()
            self.show_3d_preview(textured=False)

        ctk.CTkButton(bottom, text="TIN에 구조물 반영 + 3D 보기", width=200,
                      fg_color="#E67E22", hover_color="#D35400",
                      text_color="white",
                      font=ctk.CTkFont(size=11, weight="bold"),
                      command=_apply_structures_to_tin
                      ).pack(side="right", padx=5)

    def _open_structure_review_dialog(self, layer_name, tpl_var,
                                        template_name_to_id, row_widgets):
        """구조물 상세 검토/편집/미리보기/확정 4단계 다이얼로그.

        Parse → Edit → Build Preview → Confirm 흐름.
        중간 단계에서 자유롭게 파라미터 수정/재빌드 가능.
        확정 전까지는 structure_registry에 반영되지 않음.
        """
        info = self.structure_registry[layer_name]
        name = info["name"]

        # 템플릿 확정 (메인 다이얼로그의 드롭다운 선택값)
        tid = template_name_to_id.get(tpl_var.get(), info.get("template_id", "generic"))
        tpl = STRUCTURE_REGISTRY.get(tid)
        if not tpl:
            messagebox.showerror("오류", f"템플릿을 찾을 수 없습니다: {tid}")
            return

        win = ctk.CTkToplevel(self)
        win.title(f"구조물 검토: {name}")
        win.geometry("760x820")
        win.grab_set()

        # placement_transform: geo_referencing.PlacementTransform | None
        state = {
            "params": info.get("template_params"),
            "meshes": info.get("template_meshes"),
            "dxf_paths": list(info.get("template_detail_dxfs") or []),
            "placement_transform": info.get("placement_transform"),
        }

        # --- 헤더 ---
        cx, cy = info["centroid"]
        rot = info.get("orientation_deg", 0.0)
        ctk.CTkLabel(win, text=f"{tpl.name_ko}: {name}",
                     font=ctk.CTkFont(size=15, weight="bold")).pack(padx=15, pady=(12, 2))
        ctk.CTkLabel(win, text=f"위치 ({cx:.0f}, {cy:.0f}) · 방향 {rot:+.1f}° · 템플릿 {tid}",
                     font=ctk.CTkFont(size=10), text_color="gray").pack(pady=(0, 8))

        # --- DXF 선택 영역 ---
        dxf_frame = ctk.CTkFrame(win)
        dxf_frame.pack(fill="x", padx=15, pady=5)

        _initial_dxf_text = (
            f"{len(state['dxf_paths'])}개 파일: " + ", ".join(Path(p).name for p in state['dxf_paths'][:2])
            if state['dxf_paths'] else "상세 DXF 미선택 (기본값으로 빌드 가능)"
        )
        dxf_label_var = ctk.StringVar(value=_initial_dxf_text)
        ctk.CTkLabel(dxf_frame, textvariable=dxf_label_var,
                     font=ctk.CTkFont(size=11), wraplength=500,
                     anchor="w").pack(side="left", padx=10, pady=8, fill="x", expand=True)

        # --- 굴착 깊이 (필수) ---
        exc_frame = ctk.CTkFrame(win)
        exc_frame.pack(fill="x", padx=15, pady=5)
        ctk.CTkLabel(exc_frame, text="원지반 대비 굴착 깊이 (m):",
                     font=ctk.CTkFont(size=12, weight="bold")
                     ).pack(side="left", padx=10, pady=8)
        exc_entry = ctk.CTkEntry(exc_frame, width=100,
                                  placeholder_text="예: 3.0")
        exc_entry.insert(0, str(info.get("excavation_depth", 0.0)))
        exc_entry.pack(side="left", padx=5, pady=8)
        ctk.CTkLabel(exc_frame, text="양수(+)=굴착, 음수(-)=성토",
                     font=ctk.CTkFont(size=10), text_color="gray"
                     ).pack(side="left", padx=10, pady=8)

        # --- 파라미터 편집 영역 ---
        param_frame = ctk.CTkScrollableFrame(win, height=340)
        param_frame.pack(fill="both", expand=True, padx=15, pady=5)
        ctk.CTkLabel(param_frame, text="파라미터 (자동 파싱 후 직접 편집 가능)",
                     font=ctk.CTkFont(size=12, weight="bold")
                     ).grid(row=0, column=0, columnspan=4, sticky="w", pady=(2, 8))

        schema = tpl.get_parameter_schema()
        entries = {}
        for ri, pf in enumerate(schema, 1):
            ctk.CTkLabel(param_frame, text=pf.label, font=ctk.CTkFont(size=11)
                         ).grid(row=ri, column=0, sticky="w", padx=(5, 5), pady=2)
            entry = ctk.CTkEntry(param_frame, width=120)
            entry.insert(0, str(pf.default))
            entry.grid(row=ri, column=1, sticky="w", padx=5, pady=2)
            entries[pf.name] = entry
            ctk.CTkLabel(param_frame, text=pf.unit, font=ctk.CTkFont(size=10), text_color="gray"
                         ).grid(row=ri, column=2, sticky="w", padx=5, pady=2)
            if pf.description:
                ctk.CTkLabel(param_frame, text=pf.description[:70],
                             font=ctk.CTkFont(size=9), text_color="#7F8C8D",
                             wraplength=280, justify="left"
                             ).grid(row=ri, column=3, sticky="w", padx=5, pady=2)

        def _populate_entries(params):
            """StructureParams → entry 위젯에 값 세팅."""
            if params is None:
                return
            for key, entry in entries.items():
                v = params.get(key)
                if v is not None:
                    entry.delete(0, "end")
                    entry.insert(0, str(v))

        # 기존 파라미터 있으면 채우기
        if state["params"] is not None:
            _populate_entries(state["params"])

        # --- 상태 표시 ---
        status_frame = ctk.CTkFrame(win, fg_color="transparent")
        status_frame.pack(fill="x", padx=15, pady=(5, 0))
        status_var = ctk.StringVar(value="대기 중 (DXF 선택 또는 파라미터 수동 입력)")
        if state["meshes"]:
            status_var.set(f"기존 빌드 유지 · {len(state['meshes'])}개 메쉬")
        ctk.CTkLabel(status_frame, textvariable=status_var,
                     font=ctk.CTkFont(size=11), text_color="#F39C12",
                     anchor="w").pack(side="left", fill="x", expand=True)

        # --- 동작 함수들 ---
        def _do_parse():
            if not state["dxf_paths"]:
                messagebox.showinfo("안내", "먼저 DXF 파일을 선택하세요.")
                return
            try:
                params = tpl.parse(state["dxf_paths"])
                state["params"] = params
                state["meshes"] = None  # 파싱 바뀌면 이전 빌드 무효화
                _populate_entries(params)
                status_var.set(f"파싱 완료 · {len(params.params)}개 항목 · 미리보기 대기")
                self.log(f"  [{name}] 파싱: {list(params.params.keys())}")
            except Exception as e:
                import traceback; traceback.print_exc()
                messagebox.showerror("파싱 오류", f"{e}")
                status_var.set("파싱 오류")

        def _select_dxf():
            paths = filedialog.askopenfilenames(
                title=f"상세 DXF: {name}",
                filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")])
            if not paths:
                return
            state["dxf_paths"] = list(paths)
            names = ", ".join(Path(p).name for p in paths[:2])
            if len(paths) > 2:
                names += f" 외 {len(paths) - 2}"
            dxf_label_var.set(f"{len(paths)}개 파일: {names}")
            _do_parse()

        def _make_default_params():
            """schema 기본값으로 빈 StructureParams 생성 (parse() 호출 없이).

            일부 템플릿은 parse([])에서 IndexError 발생 (최소 1개 DXF 요구).
            """
            from structure_templates import StructureParams
            sp = StructureParams(template_id=tid, name=name)
            for pf in schema:
                sp.set(pf.name, pf.default)
            return sp

        def _collect_params():
            """entry 위젯 값을 StructureParams에 반영."""
            if state["params"] is None:
                state["params"] = _make_default_params()
            for key, entry in entries.items():
                txt = entry.get().strip()
                if not txt:
                    continue
                try:
                    v = float(txt)
                    if v.is_integer():
                        v = int(v)
                    state["params"].set(key, v)
                except ValueError:
                    state["params"].set(key, txt)
            return state["params"]

        def _do_build_preview():
            params = _collect_params()
            try:
                meshes = tpl.build_meshes(params)
                state["meshes"] = meshes
                # 미리보기에는 물/지면/apron/backfill 제외 (구조물 본체만)
                from geo_referencing import filter_terrain_meshes, to_ascii_title
                meshes_view = filter_terrain_meshes(meshes)
                status_var.set(
                    f"빌드 완료 · {len(meshes)}개 메쉬 "
                    f"(미리보기 {len(meshes_view)}개: 구조물만) · 다음 단계: 위치 설정"
                )
                self.log(f"  [{name}] 빌드: {len(meshes)}개 메쉬 "
                         f"(미리보기 {len(meshes_view)}개)")
                # 독립 미리보기 창 — VTK native 창은 한글 깨짐 방지 위해 ASCII 제목 사용
                ascii_title = (
                    f"Preview: {to_ascii_title(tpl.name_ko, 'structure')}"
                    f" - {to_ascii_title(name, 'layer')}"
                )
                p = pv.Plotter(title=ascii_title)
                p.set_background("#1e1e1e")
                for mesh, color, opacity in meshes_view:
                    with contextlib.suppress(Exception):
                        p.add_mesh(mesh, color=color, opacity=opacity, smooth_shading=True)
                p.enable_3_lights()
                p.add_axes()
                try:
                    p.show_bounds(xlabel="X (m)", ylabel="Y (m)", zlabel="Z (m)",
                                  color="#CCCCCC", grid=True)
                except Exception:
                    p.show_grid(color="gray")
                p.view_isometric()
                p.show()  # 마우스 드래그로 회전·줌·팬 가능
            except Exception as e:
                import traceback; traceback.print_exc()
                messagebox.showerror("빌드 오류", f"{e}")
                status_var.set("빌드 오류 · 파라미터 확인 후 재시도")

        def _do_geo_referencing():
            """구조물 평면도 ↔ TIN 평면도 4점 매칭 창을 열어 위치 변환을 설정."""
            if not state["meshes"]:
                messagebox.showwarning(
                    "안내", "먼저 '미리보기 (빌드)'로 모델을 생성한 뒤 사용하세요.",
                    parent=win)
                return
            if not state["dxf_paths"]:
                messagebox.showwarning(
                    "안내", "구조물 상세 DXF가 필요합니다 ('DXF 선택 + 파싱' 먼저).",
                    parent=win)
                return
            if not self.dxf_path:
                messagebox.showwarning(
                    "안내", "TIN 생성용 메인 DXF가 로드되지 않았습니다.\n"
                            "Step 1에서 계획 평면도를 먼저 업로드하세요.",
                    parent=win)
                return

            try:
                from geo_referencing import GeoReferencingDialog
            except Exception as e:
                messagebox.showerror("모듈 로드 오류",
                    f"geo_referencing 모듈 로드 실패:\n{e}", parent=win)
                return

            def _on_transform(tr):
                state["placement_transform"] = tr
                status_var.set(
                    f"위치 설정 완료 · scale={tr.scale:.3f} "
                    f"rot={tr.rotation_deg:+.1f}° "
                    f"tx={tr.tx:+.1f} ty={tr.ty:+.1f} "
                    f"res={tr.residual:.2f}m · 확정 대기"
                )
                self.log(
                    f"  [{name}] 위치 설정: {tr.describe()}"
                )

            # 기존 설정이 있으면 복원해서 재편집 편의
            existing = state.get("placement_transform")
            # TIN 캔버스를 self.origin 차감한 로컬 좌표계로 표시
            # → 사용자 픽이 바로 TIN 로컬 좌표로 저장됨 (self.origin vs pick 좌표계 일치)
            # → 초기 뷰도 self.tin_mesh 바운드로 고정해 주 콘텐츠가 크게 보이게
            tin_view_bounds = None
            if getattr(self, "tin_mesh", None) is not None:
                _tb = self.tin_mesh.bounds
                tin_view_bounds = (float(_tb.x_min), float(_tb.y_min),
                                   float(_tb.x_max), float(_tb.y_max))
            GeoReferencingDialog(
                win, state["dxf_paths"], self.dxf_path,
                layer_name=name,
                initial_transform=existing,
                on_confirm=_on_transform,
                tin_origin=self.origin,
                tin_view_bounds=tin_view_bounds,
            )

        def _do_reset_to_default():
            """파라미터를 템플릿 schema 기본값으로 복원."""
            default_params = _make_default_params()
            state["params"] = default_params
            _populate_entries(default_params)
            status_var.set("기본값 복원됨 · 미리보기로 확인")

        def _do_blender_render():
            """Blender Cycles로 구조물 단독 고품질 렌더 (별도 트랙).

            AI 워크플로(Step 4)와 별개로 실행. 결과는 'structure_render.png'.
            transparent_bg=True 로 RGBA 출력 → 추후 지형 합성 입력으로도 사용 가능.
            """
            try:
                from blender_renderer import run_blender_render
                from params_to_json import dump_dataclass_to_json as _dump_gate
            except ImportError as e:
                messagebox.showerror(
                    "모듈 없음",
                    f"Blender 렌더 모듈을 찾을 수 없습니다:\n{e}\n\n"
                    "blender_renderer.py / gate_3d_builder_bpy.py 가 "
                    "S-CANVAS 폴더에 있는지 확인하세요.",
                    parent=win,
                )
                return

            if state["params"] is None:
                messagebox.showwarning(
                    "안내", "파라미터가 비어있습니다. '미리보기 (빌드)' 먼저 실행하세요.",
                    parent=win,
                )
                return

            # 현재는 수문(spillway_gate)만 지원.
            if tid != "spillway_gate":
                messagebox.showinfo(
                    "지원 예정",
                    f"Blender 렌더는 현재 '여수로 수문(spillway_gate)' 템플릿만 "
                    f"지원합니다.\n현재 템플릿: {tid}",
                    parent=win,
                )
                return

            # 옵션 다이얼로그 — 시간대 + 투명배경 + 샘플
            opt_win = ctk.CTkToplevel(win)
            opt_win.title("Blender Cycles 렌더 옵션")
            opt_win.geometry("420x340")
            opt_win.transient(win); opt_win.grab_set()

            ctk.CTkLabel(
                opt_win, text="Blender Cycles 렌더 옵션",
                font=ctk.CTkFont(size=14, weight="bold"),
            ).pack(pady=(15, 10))

            ctk.CTkLabel(opt_win, text="조명 / 시간대").pack(anchor="w", padx=20)
            time_var = ctk.StringVar(value="daytime")
            tf = ctk.CTkFrame(opt_win, fg_color="transparent")
            tf.pack(fill="x", padx=20, pady=(0, 10))
            for v, lbl in [("daytime", "주간"), ("sunset", "노을"), ("overcast", "흐림")]:
                ctk.CTkRadioButton(tf, text=lbl, variable=time_var, value=v).pack(side="left", padx=8)

            transparent_var = ctk.BooleanVar(value=False)
            ctk.CTkCheckBox(
                opt_win,
                text="투명 배경 (RGBA) — 지형 합성용",
                variable=transparent_var,
            ).pack(anchor="w", padx=20, pady=(0, 8))

            ctk.CTkLabel(opt_win, text="Cycles 샘플 (높을수록 깨끗·느림)").pack(anchor="w", padx=20)
            samples_var = ctk.StringVar(value="128")
            sf = ctk.CTkFrame(opt_win, fg_color="transparent")
            sf.pack(fill="x", padx=20, pady=(0, 8))
            for s in ("32", "64", "128", "256"):
                ctk.CTkRadioButton(sf, text=s, variable=samples_var, value=s).pack(side="left", padx=6)

            save_blend_var = ctk.BooleanVar(value=False)
            save_glb_var = ctk.BooleanVar(value=False)
            ctk.CTkCheckBox(opt_win, text=".blend 저장",
                            variable=save_blend_var).pack(anchor="w", padx=20)
            ctk.CTkCheckBox(opt_win, text=".glb 저장 (외부 뷰어/VR)",
                            variable=save_glb_var).pack(anchor="w", padx=20, pady=(0, 6))

            def _start():
                try:
                    samples = int(samples_var.get())
                except ValueError:
                    samples = 128
                t_preset = time_var.get()
                trans = bool(transparent_var.get())
                save_b = bool(save_blend_var.get())
                save_g = bool(save_glb_var.get())
                opt_win.destroy()

                try:
                    json_path = "gate_params.json"
                    _dump_gate(state["params"], json_path)
                    self.log(f"  [{name}] GateParams -> {json_path}")
                except Exception as e:
                    messagebox.showerror("JSON 저장 실패",
                        f"GateParams JSON 직렬화 실패:\n{e}", parent=win)
                    return

                self.log(f"  [{name}] Blender 렌더 시작 "
                         f"(time={t_preset}, samples={samples}, "
                         f"bg={'투명' if trans else 'sky'})")
                threading.Thread(
                    target=run_blender_render,
                    args=(self, None, json_path),
                    kwargs=dict(
                        time_preset=t_preset,
                        engine="CYCLES",
                        samples=samples,
                        output_path="structure_render.png",
                        transparent_bg=trans,
                        save_blend=save_b,
                        save_glb=save_g,
                        structure_kind="gate",
                    ),
                    daemon=True,
                ).start()

            bf = ctk.CTkFrame(opt_win, fg_color="transparent")
            bf.pack(fill="x", pady=15, padx=20)
            ctk.CTkButton(bf, text="취소", width=80,
                          fg_color="transparent", border_width=1,
                          command=opt_win.destroy).pack(side="left")
            ctk.CTkButton(bf, text="🎨 렌더 시작", width=140,
                          fg_color="#16A085", hover_color="#117A65",
                          text_color="white",
                          command=_start).pack(side="right")

        def _do_vlm_feedback():
            """Gemini Vision으로 빌드 결과와 원본 도면 비교 → 파라미터 diff 제안."""
            if not STRUCTURE_VLM_AVAILABLE:
                messagebox.showerror("모듈 없음",
                    "structure_vlm_feedback 모듈을 찾을 수 없습니다.", parent=win)
                return
            if state["meshes"] is None:
                messagebox.showwarning("안내",
                    "먼저 '미리보기 (빌드)'로 메시를 생성해 주세요.", parent=win)
                return
            if not state["dxf_paths"]:
                messagebox.showwarning("안내",
                    "상세 DXF가 필요합니다 ('DXF 선택 + 파싱' 먼저).", parent=win)
                return
            if state["params"] is None:
                messagebox.showwarning("안내", "파라미터가 비어있습니다.", parent=win)
                return

            # 구조물 유형 추정 (템플릿 id 사용)
            st_type = tid or "generic"

            # Gemini 인증: scanvas_maker의 Vertex 경로와 동일하게
            try:
                project = (self._gcp_key_project_id
                           or os.environ.get("GCP_PROJECT_ID", "")
                           or (self.gemini_api_key.get().strip() if hasattr(self, "gemini_api_key") else ""))
                location = (self.vertex_location.get().strip()
                            if hasattr(self, "vertex_location") else "global") or "global"
                client = _svf.build_genai_client(
                    project=project if project else None,
                    location=location,
                    use_vertex=bool(self._gcp_key_project_id) or bool(os.environ.get("GCP_PROJECT_ID")),
                    api_key=None,
                    log_fn=self.log,
                )
            except Exception as e:
                messagebox.showerror("Gemini 인증 실패",
                    f"Vertex AI client 생성 실패:\n{e}\n\n"
                    "gcp-key.json 또는 GCP Project ID 설정을 확인하세요.",
                    parent=win)
                return

            status_var.set("AI 검증 중... (Gemini Vision 호출, 10~20초 소요)")
            self.log(f"  [{name}] AI 검증 시작 (유형: {st_type})")

            def _worker():
                try:
                    diff = _svf.run_feedback_once(
                        state["params"], state["meshes"], state["dxf_paths"],
                        client, structure_type=st_type,
                        model="gemini-2.5-flash",
                        work_dir=f"cache/vlm/{st_type}",
                        log_fn=self.log,
                    )
                    self.after(0, lambda: _show_diff_dialog(diff))
                except Exception as e:
                    import traceback; traceback.print_exc()
                    err_msg = str(e)
                    self.after(0, lambda msg=err_msg: (
                        status_var.set("AI 검증 실패"),
                        self.log(f"  [{name}] VLM 오류: {msg}"),
                        messagebox.showerror("AI 검증 실패", msg, parent=win),
                    ))

            threading.Thread(target=_worker, daemon=True).start()

        def _show_diff_dialog(diff: dict):
            """Gemini가 반환한 diff를 테이블로 보여주고 사용자가 체크한 항목만 적용."""
            dwin = ctk.CTkToplevel(win)
            dwin.title(f"AI 검증 결과: {name}")
            dwin.geometry("820x680")
            dwin.grab_set()

            score = diff.get("match_score", 0.0)
            try:
                score_f = float(score)
            except (TypeError, ValueError):
                score_f = 0.0
            score_color = ("#27AE60" if score_f >= 0.85 else
                           "#F39C12" if score_f >= 0.6 else "#E74C3C")

            hdr = ctk.CTkFrame(dwin, fg_color="transparent")
            hdr.pack(fill="x", padx=15, pady=(12, 4))
            ctk.CTkLabel(hdr, text=f"매치 점수: {score_f:.2f}",
                         font=ctk.CTkFont(size=15, weight="bold"),
                         text_color=score_color).pack(side="left")
            ctk.CTkLabel(hdr, text="  (모델: gemini-2.5-flash)",
                         font=ctk.CTkFont(size=10), text_color="gray"
                         ).pack(side="left")

            summary = str(diff.get("summary", "")).strip() or "(요약 없음)"
            ctk.CTkLabel(dwin, text=summary, font=ctk.CTkFont(size=11),
                         wraplength=780, justify="left", anchor="w"
                         ).pack(fill="x", padx=15, pady=(0, 8))

            # 스크롤 프레임
            scroll = ctk.CTkScrollableFrame(dwin, label_text="제안된 변경 (체크한 항목만 적용)")
            scroll.pack(fill="both", expand=True, padx=15, pady=5)

            # 각 카테고리별 체크박스 변수
            sel = {"param_updates": [], "valves_missing": [], "pipes_missing": []}

            def _section(title, items, key_list, make_text):
                if not items:
                    return
                ctk.CTkLabel(scroll, text=title,
                             font=ctk.CTkFont(size=12, weight="bold"),
                             anchor="w").pack(fill="x", pady=(8, 2))
                for i, it in enumerate(items):
                    var = ctk.BooleanVar(value=True)
                    key_list.append(var)
                    fr = ctk.CTkFrame(scroll, fg_color="#2a2a2a", corner_radius=4)
                    fr.pack(fill="x", padx=4, pady=2)
                    ctk.CTkCheckBox(fr, text="", variable=var, width=20
                                    ).pack(side="left", padx=(6, 2))
                    ctk.CTkLabel(fr, text=make_text(it), font=ctk.CTkFont(size=10),
                                 wraplength=720, justify="left", anchor="w"
                                 ).pack(side="left", fill="x", expand=True, padx=4, pady=4)

            _section("파라미터 업데이트",
                     diff.get("param_updates", []) or [],
                     sel["param_updates"],
                     lambda u: f"• {u.get('path','?')}: "
                               f"{u.get('current','?')} → {u.get('suggested','?')}   "
                               f"({u.get('reason','')})")
            _section("누락 밸브 추가",
                     diff.get("valves_missing", []) or [],
                     sel["valves_missing"],
                     lambda v: f"• {v.get('name','?')} [{v.get('valve_type','GATE')}] "
                               f"@ ({v.get('x',0):.2f}, {v.get('y',0):.2f}) "
                               f"D{v.get('diameter_mm','?')}mm — {v.get('reason','')}")
            _section("누락 관로 추가",
                     diff.get("pipes_missing", []) or [],
                     sel["pipes_missing"],
                     lambda p: f"• {p.get('name','?')} D{p.get('diameter_mm','?')}mm "
                               f"{p.get('start','?')} → {p.get('end','?')} — {p.get('reason','')}")

            excess = diff.get("excess_notes", []) or []
            if excess:
                ctk.CTkLabel(scroll, text="참고: 모델에만 있는 요소 (적용 아님)",
                             font=ctk.CTkFont(size=12, weight="bold"),
                             text_color="#E74C3C", anchor="w").pack(fill="x", pady=(10, 2))
                for note in excess:
                    ctk.CTkLabel(scroll, text=f"• {note}", font=ctk.CTkFont(size=10),
                                 text_color="#E67E22", wraplength=760, justify="left",
                                 anchor="w").pack(fill="x", padx=8, pady=1)

            valves_incorrect = diff.get("valves_incorrect", []) or []
            pipes_incorrect = diff.get("pipes_incorrect", []) or []
            if valves_incorrect or pipes_incorrect:
                ctk.CTkLabel(scroll, text="참고: 리스트 요소 필드 수정 제안 (수동 반영 권장)",
                             font=ctk.CTkFont(size=12, weight="bold"),
                             text_color="#8E44AD", anchor="w").pack(fill="x", pady=(10, 2))
                for v in valves_incorrect:
                    ctk.CTkLabel(scroll,
                                 text=f"• 밸브 {v.get('name','?')}.{v.get('field','?')}: "
                                      f"{v.get('current','?')} → {v.get('suggested','?')} "
                                      f"({v.get('reason','')})",
                                 font=ctk.CTkFont(size=10), wraplength=760,
                                 justify="left", anchor="w").pack(fill="x", padx=8, pady=1)
                for p in pipes_incorrect:
                    ctk.CTkLabel(scroll,
                                 text=f"• 관로 {p.get('name','?')}.{p.get('field','?')}: "
                                      f"{p.get('current','?')} → {p.get('suggested','?')} "
                                      f"({p.get('reason','')})",
                                 font=ctk.CTkFont(size=10), wraplength=760,
                                 justify="left", anchor="w").pack(fill="x", padx=8, pady=1)

            # 하단 버튼
            btns = ctk.CTkFrame(dwin, fg_color="transparent")
            btns.pack(fill="x", padx=15, pady=10)

            def _apply_and_rebuild():
                selections = {
                    k: [var.get() for var in vars_list]
                    for k, vars_list in sel.items()
                }
                result = _svf.apply_diff_to_params(
                    state["params"], diff, selections=selections, log_fn=self.log,
                )
                self.log(f"  [{name}] VLM 적용: {result['applied']}개, "
                         f"오류 {len(result['errors'])}건")
                # 엔트리 위젯 갱신 (스칼라 필드 반영)
                with contextlib.suppress(Exception):
                    _populate_entries(state["params"])
                # 자동 재빌드
                try:
                    meshes = tpl.build_meshes(state["params"])
                    state["meshes"] = meshes
                    status_var.set(
                        f"AI 적용 완료 ({result['applied']}건) · 재빌드 {len(meshes)} 메쉬"
                    )
                except Exception as e:
                    self.log(f"  [{name}] 재빌드 오류: {e}")
                    status_var.set("AI 적용됨 (재빌드 실패 — 로그 확인)")
                dwin.destroy()

            ctk.CTkButton(btns, text="취소", width=80,
                          fg_color="transparent", border_width=1,
                          command=dwin.destroy).pack(side="right", padx=4)
            ctk.CTkButton(btns, text="✓ 선택 항목 적용 + 재빌드", width=220,
                          fg_color="#27AE60", hover_color="#1E8449",
                          font=ctk.CTkFont(size=11, weight="bold"),
                          command=_apply_and_rebuild).pack(side="right", padx=4)
            # 생성된 비교 이미지 경로 안내
            arts = diff.get("_artifacts", {})
            if arts:
                ctk.CTkLabel(btns,
                             text=f"비교 이미지: {Path(arts.get('drawing_png','')).name}, "
                                  f"{Path(arts.get('render_png','')).name}",
                             font=ctk.CTkFont(size=9), text_color="gray"
                             ).pack(side="left", padx=4)

        def _do_confirm():
            if state["meshes"] is None:
                if not messagebox.askyesno(
                    "미빌드 확정",
                    "아직 빌드/미리보기를 하지 않았습니다.\n"
                    "지금 파라미터로 빌드 후 확정할까요?"):
                    return
                params = _collect_params()
                try:
                    state["meshes"] = tpl.build_meshes(params)
                except Exception as e:
                    messagebox.showerror("빌드 오류", f"{e}")
                    return
            # 굴착 깊이 저장
            try:
                info["excavation_depth"] = float(exc_entry.get().strip() or "0")
            except ValueError:
                messagebox.showerror("입력 오류", "굴착 깊이를 숫자로 입력해 주세요.")
                return

            # 위치 설정 미완료 경고 (하위호환: 기존 centroid/orientation 폴백)
            tr = state.get("placement_transform")
            scale_mode = "none"  # 기본: 위치·회전만, 구조물 크기 유지
            if tr is None:
                if not messagebox.askyesno(
                    "위치 설정 건너뜀",
                    "'위치 설정 (Geo-Referencing)'이 수행되지 않았습니다.\n"
                    "이대로 확정하면 평면도 centroid + PCA 주축으로 배치됩니다.\n\n"
                    "계속 확정할까요?",
                    parent=win):
                    return
            else:
                # 스케일 비교: mismatch가 있으면 사용자에게 크기 조정 여부 질문
                if not (0.9 <= tr.scale <= 1.1):
                    ans = messagebox.askyesnocancel(
                        "배치 모드 선택",
                        f"구조물 도면과 평면도(TIN) 사이 스케일 비: {tr.scale:.4f}\n"
                        f"(잔차 {tr.residual:.3f} m)\n\n"
                        "어떻게 배치할까요?\n\n"
                        "[예] 평면도 4점에 맞춰 구조물 크기까지 균등 조정\n"
                        "    → XY/Z 모두 sqrt(scale_x×scale_y)로 스케일 후 배치\n"
                        "    → 구조물이 작아 보이는 단위 불일치 수정 시\n\n"
                        "[아니오] 위치·회전만 맞추고 구조물은 설계 크기 유지 (권장)\n"
                        "    → 빌더가 생성한 m 단위 메쉬 그대로 올림\n\n"
                        "[취소] 확정 취소, 다시 위치 설정",
                        parent=win)
                    if ans is None:
                        return
                    scale_mode = "xyz_uniform" if ans else "none"
                if tr.residual > 0.5 and not messagebox.askyesno(
                        "잔차 과다",
                        f"4점 매칭 잔차가 {tr.residual:.2f}m 로 큽니다.\n"
                        "점 위치를 재확인하지 않고 확정할까요?",
                        parent=win):
                    return

            # 메쉬는 건드리지 않음. 스케일 모드는 info에 저장해 배치 시점에 적용.
            info["template_id"] = tid
            info["template_detail_dxfs"] = state["dxf_paths"]
            info["template_params"] = state["params"]
            info["template_meshes"] = state["meshes"]
            info["placement_transform"] = tr
            info["placement_scale_mode"] = scale_mode
            # 메인 다이얼로그 행 갱신
            w = row_widgets.get(layer_name)
            if w:
                geo_tag = " · 📍 위치 설정됨" if tr is not None else ""
                w["status_var"].set(f"✓ 확정 ({len(state['meshes'])}개){geo_tag}")
                if state["dxf_paths"]:
                    w["dxf_var"].set(f"{len(state['dxf_paths'])}개 파일")
            if tr is not None:
                self.log(f"  [{name}] 레지스트리 확정 · 위치: {tr.describe()}")
            else:
                self.log(f"  [{name}] 레지스트리 확정 (위치 설정 생략 — centroid 폴백)")
            win.destroy()

        # DXF 선택 버튼 (파싱 함수 정의 후 배치)
        ctk.CTkButton(dxf_frame, text="DXF 선택 + 파싱", width=140,
                      command=_select_dxf).pack(side="right", padx=10, pady=6)

        # --- 하단 버튼 행 ---
        bottom = ctk.CTkFrame(win, fg_color="transparent")
        bottom.pack(fill="x", padx=15, pady=10)

        # 시각 순서(좌→우): [재파싱] [기본값 복원]  ...  [미리보기] [위치 설정] [확정] [취소]
        # side='right'는 역순 쌓임 → 취소를 먼저 추가
        ctk.CTkButton(bottom, text="취소", width=70,
                      fg_color="transparent", border_width=1,
                      command=win.destroy).pack(side="right", padx=3)
        ctk.CTkButton(bottom, text="✓ 확정 (레지스트리 저장)", width=190,
                      fg_color="#27AE60", hover_color="#1E8449",
                      text_color="white",
                      font=ctk.CTkFont(size=11, weight="bold"),
                      command=_do_confirm).pack(side="right", padx=3)
        ctk.CTkButton(bottom, text="📍 위치 설정", width=130,
                      fg_color="#8E44AD", hover_color="#6C3483",
                      text_color="white",
                      font=ctk.CTkFont(size=11, weight="bold"),
                      command=_do_geo_referencing).pack(side="right", padx=3)
        ctk.CTkButton(bottom, text="🗔 미리보기 (빌드)", width=160,
                      fg_color="#1f538d", hover_color="#14375e",
                      command=_do_build_preview).pack(side="right", padx=3)
        ctk.CTkButton(bottom, text="🤖 AI 검증", width=110,
                      fg_color="#D35400", hover_color="#A04000",
                      text_color="white",
                      font=ctk.CTkFont(size=11, weight="bold"),
                      command=_do_vlm_feedback).pack(side="right", padx=3)
        ctk.CTkButton(bottom, text="🎨 Blender 렌더", width=140,
                      fg_color="#16A085", hover_color="#117A65",
                      text_color="white",
                      font=ctk.CTkFont(size=11, weight="bold"),
                      command=_do_blender_render).pack(side="right", padx=3)
        ctk.CTkButton(bottom, text="재파싱", width=80,
                      fg_color="transparent", border_width=1,
                      command=_do_parse).pack(side="left", padx=3)
        ctk.CTkButton(bottom, text="기본값 복원", width=100,
                      fg_color="transparent", border_width=1,
                      command=_do_reset_to_default).pack(side="left", padx=3)

    def _browse_template_detail_dxf(self, layer_name, dxf_var, status_var,
                                      tpl_var, template_name_to_id):
        """구조물 하나에 상세 DXF 업로드 → 템플릿 파싱 + 3D 빌드."""
        paths = filedialog.askopenfilenames(
            title=f"상세도면 선택: {self.structure_registry[layer_name]['name']}",
            filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")],
        )
        if not paths:
            return

        # 템플릿 확정 (드롭다운에서 선택된 것)
        selected_name = tpl_var.get()
        tid = template_name_to_id.get(selected_name, "generic")
        tpl = STRUCTURE_REGISTRY.get(tid)
        if not tpl:
            messagebox.showerror("오류", f"템플릿을 찾을 수 없습니다: {tid}")
            return

        self.log(f"  [{layer_name}] 템플릿 [{tpl.name_ko}]로 {len(paths)}개 DXF 파싱 중...")
        dxf_var.set(f"{len(paths)}개 파일")

        try:
            params = tpl.parse(list(paths))
            meshes = tpl.build_meshes(params)
            info = self.structure_registry[layer_name]
            info["template_id"] = tid
            info["template_detail_dxfs"] = list(paths)
            info["template_params"] = params
            info["template_meshes"] = meshes
            status_var.set(f"빌드됨 ({len(meshes)}개)")
            self.log(f"    → {len(meshes)}개 mesh 생성 완료")
        except Exception as e:
            self.log(f"    빌드 오류: {e}")
            import traceback
            traceback.print_exc()
            status_var.set("오류")
            messagebox.showerror("빌드 오류",
                f"템플릿 [{tpl.name_ko}] 빌드 중 오류:\n\n{e}")

    # ========================================================================
    # 기존: 간단 치수 파서 다이얼로그 (하위 호환)
    # ========================================================================

    def _open_detail_upload_dialog(self):
        """등록된 구조물에 상세도면 DXF를 추가하는 팝업 다이얼로그.

        구조물 목록을 표시하고, 각 구조물에 대해 상세도면 파일을 선택,
        자동 파싱된 치수를 확인/수정할 수 있게 한다.
        """
        if not self.structure_registry:
            messagebox.showinfo("안내", "상세도면을 추가할 구조물이 없습니다.\n"
                                "벽체, 건물, 교량, 관로 등의 레이어를 분류해 주세요.")
            return

        if not DETAIL_PARSER_AVAILABLE:
            messagebox.showerror("오류", "detail_parser 모듈을 찾을 수 없습니다.")
            return

        win = ctk.CTkToplevel(self)
        win.title("S-CANVAS: 구조물 상세도면 추가")
        win.geometry("950x600")
        win.grab_set()

        ctk.CTkLabel(win, text="구조물별 상세도면을 추가하여 정밀 치수를 반영합니다",
                     font=ctk.CTkFont(size=14, weight="bold")).pack(padx=20, pady=(15, 5))
        ctk.CTkLabel(win, text="상세도면 DXF에서 높이, 폭, 계획고 등의 치수를 자동 인식합니다",
                     font=ctk.CTkFont(size=11), text_color="gray").pack(padx=20, pady=(0, 10))

        # 구조물 목록 프레임
        list_frame = ctk.CTkScrollableFrame(win, height=400)
        list_frame.pack(padx=15, pady=5, fill="both", expand=True)

        headers = ["구조물", "유형", "위치", "상세도면", "파싱 결과", ""]
        for ci, h in enumerate(headers):
            list_frame.grid_columnconfigure(ci, weight=1 if ci in (0, 4) else 0)
            ctk.CTkLabel(list_frame, text=h, font=ctk.CTkFont(size=11, weight="bold")
                         ).grid(row=0, column=ci, padx=5, sticky="w")

        row_widgets = {}

        for ri, (layer_name, info) in enumerate(self.structure_registry.items(), 1):
            name = info["name"]
            type_id = info["type_id"]
            cx, cy = info["centroid"]

            ctk.CTkLabel(list_frame, text=name[:20], font=ctk.CTkFont(size=11)
                         ).grid(row=ri, column=0, padx=5, pady=3, sticky="w")
            ctk.CTkLabel(list_frame, text=type_id, font=ctk.CTkFont(size=10), text_color="gray"
                         ).grid(row=ri, column=1, padx=5, pady=3, sticky="w")
            ctk.CTkLabel(list_frame, text=f"({cx:.0f}, {cy:.0f})", font=ctk.CTkFont(size=10), text_color="gray"
                         ).grid(row=ri, column=2, padx=5, pady=3, sticky="w")

            # 파일 경로 표시
            file_var = ctk.StringVar(value=info.get("detail_dxf") or "미등록")
            file_label = ctk.CTkLabel(list_frame, textvariable=file_var,
                                      font=ctk.CTkFont(size=10), text_color="#3498DB")
            file_label.grid(row=ri, column=3, padx=5, pady=3, sticky="w")

            # 파싱 결과 표시
            result_var = ctk.StringVar(value=self._format_detail_params(info.get("detail_params")))
            result_label = ctk.CTkLabel(list_frame, textvariable=result_var,
                                        font=ctk.CTkFont(size=10), text_color="#2ECC71")
            result_label.grid(row=ri, column=4, padx=5, pady=3, sticky="w")

            # 파일 선택 버튼
            def _make_browse(ln=layer_name, fv=file_var, rv=result_var):
                def _browse():
                    self._browse_detail_dxf(ln, fv, rv)
                return _browse

            ctk.CTkButton(list_frame, text="찾아보기", width=80,
                          command=_make_browse()
                          ).grid(row=ri, column=5, padx=5, pady=3)

            row_widgets[layer_name] = (file_var, result_var)

        # 하단 버튼
        btn_frame = ctk.CTkFrame(win, fg_color="transparent")
        btn_frame.pack(padx=20, pady=10, fill="x")
        ctk.CTkButton(btn_frame, text="닫기", width=80, fg_color="transparent",
                      border_width=1, command=win.destroy).pack(side="right", padx=5)
        ctk.CTkButton(btn_frame, text="모두 적용 & 3D 재생성", width=180,
                      command=lambda: self._apply_detail_and_close(win)
                      ).pack(side="right", padx=5)

        win.wait_window()

    def _browse_detail_dxf(self, layer_name, file_var, result_var):
        """구조물 하나에 대해 상세도면 DXF를 선택하고 파싱."""
        fpath = filedialog.askopenfilename(
            title=f"상세도면 선택: {self.structure_registry[layer_name]['name']}",
            filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")]
        )
        if not fpath:
            return

        file_var.set(os.path.basename(fpath))
        self.log(f"  상세도면 로드: {os.path.basename(fpath)} → {layer_name}")

        try:
            parser = DetailParser()
            result = parser.parse(fpath)

            if not result.dimensions:
                self.log("    치수 인식 결과 없음")
                result_var.set("치수 없음")
                return

            # 파싱 결과 → 구조물 파라미터로 변환
            params = dimensions_to_structure_params(result.dimensions)
            self.log(f"    파싱 완료: {params}")

            # 사용자 확인/수정 다이얼로그
            confirmed = self._open_dimension_confirm_dialog(
                layer_name, result, params
            )

            if confirmed is not None:
                self.structure_registry[layer_name]["detail_params"] = confirmed
                self.structure_registry[layer_name]["detail_dxf"] = fpath
                result_var.set(self._format_detail_params(confirmed))
                self.log(f"    치수 확정: {confirmed}")
            else:
                result_var.set("취소됨")

        except Exception as e:
            self.log(f"    상세도면 파싱 오류: {e}")
            result_var.set(f"오류: {e}")
            messagebox.showerror("파싱 오류", f"상세도면 분석 중 오류:\n{e}")

    def _open_dimension_confirm_dialog(self, layer_name, parse_result, params):
        """파싱된 치수를 사용자에게 보여주고 수정할 수 있는 다이얼로그.

        Returns:
            dict: 확정된 파라미터 딕셔너리. 취소 시 None.
        """
        info = self.structure_registry[layer_name]

        win = ctk.CTkToplevel(self)
        win.title(f"치수 확인: {info['name']}")
        win.geometry("650x500")
        win.grab_set()

        ctk.CTkLabel(win, text=f"구조물: {info['name']} ({info['type_id']})",
                     font=ctk.CTkFont(size=14, weight="bold")).pack(padx=20, pady=(15, 5))
        ctk.CTkLabel(win, text=f"상세도면에서 {len(parse_result.dimensions)}개 치수 인식됨  |  값을 수정하거나 빈 칸은 기본값 사용",
                     font=ctk.CTkFont(size=11), text_color="gray").pack(padx=20, pady=(0, 10))

        # 파싱 결과 상세 목록
        detail_frame = ctk.CTkScrollableFrame(win, height=150)
        detail_frame.pack(padx=15, pady=5, fill="x")

        ctk.CTkLabel(detail_frame, text="원본 텍스트", font=ctk.CTkFont(size=10, weight="bold")
                     ).grid(row=0, column=0, padx=5, sticky="w")
        ctk.CTkLabel(detail_frame, text="항목", font=ctk.CTkFont(size=10, weight="bold")
                     ).grid(row=0, column=1, padx=5, sticky="w")
        ctk.CTkLabel(detail_frame, text="인식값", font=ctk.CTkFont(size=10, weight="bold")
                     ).grid(row=0, column=2, padx=5, sticky="w")
        ctk.CTkLabel(detail_frame, text="신뢰도", font=ctk.CTkFont(size=10, weight="bold")
                     ).grid(row=0, column=3, padx=5, sticky="w")

        for di, dim in enumerate(parse_result.dimensions[:20], 1):
            ctk.CTkLabel(detail_frame, text=dim.raw_text[:25], font=ctk.CTkFont(size=10)
                         ).grid(row=di, column=0, padx=5, pady=1, sticky="w")
            ctk.CTkLabel(detail_frame, text=dim.param, font=ctk.CTkFont(size=10)
                         ).grid(row=di, column=1, padx=5, pady=1, sticky="w")
            ctk.CTkLabel(detail_frame, text=f"{dim.value:.3f} {dim.unit}", font=ctk.CTkFont(size=10)
                         ).grid(row=di, column=2, padx=5, pady=1, sticky="w")
            conf_color = "#2ECC71" if dim.confidence >= 0.9 else "#F1C40F" if dim.confidence >= 0.8 else "#E74C3C"
            ctk.CTkLabel(detail_frame, text=f"{dim.confidence:.0%}", font=ctk.CTkFont(size=10),
                         text_color=conf_color
                         ).grid(row=di, column=3, padx=5, pady=1, sticky="w")

        # 편집 가능한 파라미터 테이블
        ctk.CTkLabel(win, text="적용할 구조물 치수 (수정 가능)",
                     font=ctk.CTkFont(size=12, weight="bold")).pack(padx=20, pady=(15, 5))

        edit_frame = ctk.CTkFrame(win, fg_color="transparent")
        edit_frame.pack(padx=20, pady=5, fill="x")

        # 구조물 유형에 따라 표시할 파라미터
        param_labels = {
            "height": "높이 (m)",
            "width": "폭 (m)",
            "thickness": "두께 (m)",
            "elevation": "계획고 EL. (m)",
            "diameter": "관경 (m)",
            "slope_ratio": "사면 경사비",
            "length": "길이 (m)",
            "embedment": "근입깊이 (m)",
            "radius": "반경 (m)",
        }

        param_entries = {}
        col = 0
        for pname, plabel in param_labels.items():
            if pname in params or pname in ("height", "width", "elevation"):
                ctk.CTkLabel(edit_frame, text=plabel, font=ctk.CTkFont(size=11)
                             ).grid(row=0, column=col, padx=8, pady=2)
                val_str = f"{params[pname]:.3f}" if pname in params else ""
                entry = ctk.CTkEntry(edit_frame, width=90, placeholder_text="기본값")
                entry.grid(row=1, column=col, padx=8, pady=2)
                if val_str:
                    entry.insert(0, val_str)
                param_entries[pname] = entry
                col += 1

        # 결과 저장
        result_holder = [None]

        def on_ok():
            confirmed = {}
            for pname, entry in param_entries.items():
                val = entry.get().strip()
                if val:
                    with contextlib.suppress(ValueError):
                        confirmed[pname] = float(val)
            result_holder[0] = confirmed
            win.destroy()

        def on_cancel():
            win.destroy()

        btn_f = ctk.CTkFrame(win, fg_color="transparent")
        btn_f.pack(padx=20, pady=10, fill="x")
        ctk.CTkButton(btn_f, text="취소", width=80, fg_color="transparent",
                      border_width=1, command=on_cancel).pack(side="right", padx=5)
        ctk.CTkButton(btn_f, text="적용", width=140, command=on_ok).pack(side="right", padx=5)

        win.wait_window()
        return result_holder[0]

    def _apply_detail_and_close(self, win):
        """상세도면 파라미터를 구조물에 적용하고 3D 프리뷰 재생성."""
        # 상세 파라미터가 있는 구조물의 type_def를 갱신
        updated = 0
        for layer_name, info in self.structure_registry.items():
            dp = info.get("detail_params")
            if not dp:
                continue
            if layer_name not in self.layer_geometries:
                continue

            # layer_geometries의 type_def에 파싱된 치수를 병합
            merged = dict(self.layer_geometries[layer_name]["type_def"])
            merged.update(dp)
            self.layer_geometries[layer_name]["type_def"] = merged
            updated += 1
            self.log(f"  치수 적용: {layer_name} → {dp}")

        win.destroy()

        if updated > 0:
            self.log(f"  {updated}개 구조물 치수 업데이트 완료. 3D 프리뷰 재생성...")
            self.show_3d_preview(
                textured=bool(self.total_mesh),
                texture_obj=getattr(self, '_last_texture', None)
            )
        else:
            self.log("  적용할 상세 치수가 없습니다.")

    def _format_detail_params(self, params):
        """detail_params 딕셔너리를 간결한 요약 문자열로 변환."""
        if not params:
            return "미등록"
        parts = []
        labels = {"height": "H", "width": "W", "thickness": "T",
                  "elevation": "EL", "diameter": "D", "slope_ratio": "경사",
                  "length": "L", "embedment": "근입"}
        for k, v in params.items():
            lbl = labels.get(k, k)
            parts.append(f"{lbl}={v:.1f}")
        return ", ".join(parts)

    # --- Phase 4-2: Z 투영 + 3D 지오메트리 생성 ---
    def _project_xy_to_tin(self, xy_points):
        """2D 좌표 목록을 TIN 표면에 Z 투영 (scipy 보간 방식 — 빠름).

        xy_points: [(x, y), ...]  원본 좌표계 (origin 보정 전)
        returns: np.array([[x, y, z], ...])  origin 보정 후 로컬 좌표
        """
        from scipy.interpolate import LinearNDInterpolator

        if not self.tin_mesh or len(xy_points) == 0:
            return np.array([])

        pts_2d = np.array(xy_points, dtype=np.float64)

        # origin 보정 (TIN과 같은 로컬 좌표계로)
        pts_local = pts_2d - self.origin[:2]

        # TIN 메쉬에서 보간기 생성 (캐시)
        if not hasattr(self, '_tin_interpolator') or self._tin_interpolator is None:
            tin_pts = np.array(self.tin_mesh.points)
            self._tin_interpolator = LinearNDInterpolator(tin_pts[:, :2], tin_pts[:, 2])
            self._tin_z_fallback = float(np.median(tin_pts[:, 2]))

        # 보간으로 Z값 획득
        z_vals = self._tin_interpolator(pts_local)

        # NaN인 점 (TIN 범위 밖)은 중간값으로 대체
        nan_mask = np.isnan(z_vals)
        z_vals[nan_mask] = self._tin_z_fallback

        return np.column_stack([pts_local, z_vals])

    def _open_elevation_dialog(self):
        """계획선 레이어별 고도 적용 방식을 선택하는 팝업.

        4가지 mode:
          - "인근 지형 참조" (terrain): TIN 보간으로 도로 중심선 Z 자동 획득
          - "계획고 입력" (manual): 시점/종점 EL을 직접 입력 → 구간 선형보간
          - "절토" (cut): 인근 지형 기준 N m 아래로 도로면
          - "성토" (fill): 인근 지형 기준 N m 위로 도로면

        결과 self.layer_elevations[layer_name]:
        {"mode": "terrain"|"manual"|"cut"|"fill",
         "start_el": float, "end_el": float,     # manual
         "offset_m": float,                       # cut/fill 값 (양수)
         "transition_m": float}                   # TIN 전이 폭 (smoothstep blend zone, 기본 10m)
        """
        if not self.layer_geometries:
            return False

        # 계획선(도로/수로/면 오버레이)만 — 구조물은 상세 DXF에서 3D 빌드하므로 고도 설정 불필요
        # path_extrude: 도로/수로 중심선 → TIN에 반영
        # surface_overlay: 굴착/성토 영역 → TIN 위/아래 면 오버레이
        # wall_extrude(옹벽)/box_extrude(수문/취수탑/제수변실)는 structure_registry로 이관 → 제외
        target_layers = {}
        for ln, ld in self.layer_geometries.items():
            rm = ld["type_def"].get("render_mode", "")
            if rm in ("path_extrude", "surface_overlay"):
                target_layers[ln] = ld

        if not target_layers:
            self.layer_elevations = {}
            return True  # 대상 없으면 그냥 진행

        # --- 지도에 시점/종점 마커 표시 ---
        elev_markers = []
        try:
            src_crs = self.crs_option.get()
            transformer = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)

            for ln, ld in target_layers.items():
                for geom in ld["geometries"]:
                    if geom["type"] == "polyline" and len(geom["points"]) >= 2:
                        sp, ep = geom["points"][0], geom["points"][-1]
                        s_lon, s_lat = transformer.transform(sp[0], sp[1])
                        e_lon, e_lat = transformer.transform(ep[0], ep[1])

                        m1 = self.map_view.set_marker(s_lat, s_lon,
                            text=f"시:{ln[:8]}", marker_color_circle="red",
                            marker_color_outside="darkred")
                        m2 = self.map_view.set_marker(e_lat, e_lon,
                            text=f"종:{ln[:8]}", marker_color_circle="blue",
                            marker_color_outside="darkblue")
                        elev_markers.extend([m1, m2])
                        break  # 레이어당 첫 번째 polyline만
                    elif geom["type"] == "line":
                        sp, ep = geom["start"], geom["end"]
                        s_lon, s_lat = transformer.transform(sp[0], sp[1])
                        e_lon, e_lat = transformer.transform(ep[0], ep[1])

                        m1 = self.map_view.set_marker(s_lat, s_lon,
                            text=f"시:{ln[:8]}", marker_color_circle="red",
                            marker_color_outside="darkred")
                        m2 = self.map_view.set_marker(e_lat, e_lon,
                            text=f"종:{ln[:8]}", marker_color_circle="blue",
                            marker_color_outside="darkblue")
                        elev_markers.extend([m1, m2])
                        break
        except Exception as e:
            self.log(f"  마커 표시 오류: {e}")

        win = ctk.CTkToplevel(self)
        win.title("S-CANVAS: 계획선 고도 설정")
        win.geometry("1280x560")
        win.grab_set()

        ctk.CTkLabel(win, text="계획선 레이어별 고도 적용 방식을 선택하세요",
                     font=ctk.CTkFont(size=14, weight="bold")).pack(padx=20, pady=(15, 5))
        ctk.CTkLabel(win,
            text="인근지형: TIN 자동보간 | 계획고: 시/종점 EL | 절토/성토: 지형 대비 N m | 경사 V:H=1:? | 소단: N m마다 N m 폭 플랫폼 | 전이폭: terrain/manual용 smoothstep 폭",
            font=ctk.CTkFont(size=10), text_color="gray",
            wraplength=1240, justify="left").pack(padx=20, pady=(0, 10))

        scroll = ctk.CTkScrollableFrame(win, height=340)
        scroll.pack(padx=15, pady=5, fill="both", expand=True)
        for c in range(10):
            scroll.grid_columnconfigure(c, weight=1 if c == 0 else 0)

        # 헤더
        headers = ["레이어", "방식", "시 EL", "종 EL", "절·성(m)",
                   "V:H", "소단V(m)", "소단W(m)", "전이(m)", "시/종 좌표"]
        for ci, h in enumerate(headers):
            ctk.CTkLabel(scroll, text=h, font=ctk.CTkFont(size=11, weight="bold")
                         ).grid(row=0, column=ci, padx=3, sticky="w" if ci in (0, 9) else "")

        layer_widgets = {}
        for i, (ln, ld) in enumerate(target_layers.items(), 1):
            # 시점/종점 좌표 추출 (첫 번째 polyline 기준)
            coord_text = ""
            for geom in ld["geometries"]:
                if geom["type"] == "polyline" and len(geom["points"]) >= 2:
                    sp = geom["points"][0]
                    ep = geom["points"][-1]
                    coord_text = f"시({sp[0]:.0f},{sp[1]:.0f}) → 종({ep[0]:.0f},{ep[1]:.0f})"
                    break
                elif geom["type"] == "line":
                    sp = geom["start"]
                    ep = geom["end"]
                    coord_text = f"시({sp[0]:.0f},{sp[1]:.0f}) → 종({ep[0]:.0f},{ep[1]:.0f})"
                    break

            ctk.CTkLabel(scroll, text=f"{ln}", font=ctk.CTkFont(size=11),
                        anchor="w").grid(row=i, column=0, padx=3, pady=3, sticky="w")

            mode_var = ctk.StringVar(value="인근 지형 참조")
            mode_menu = ctk.CTkOptionMenu(scroll, variable=mode_var,
                values=["인근 지형 참조", "계획고 입력", "절토", "성토"], width=120)
            mode_menu.grid(row=i, column=1, padx=3, pady=3)

            start_var = ctk.StringVar(value="")
            start_entry = ctk.CTkEntry(scroll, textvariable=start_var, width=65,
                                        placeholder_text="자동")
            start_entry.grid(row=i, column=2, padx=3, pady=3)

            end_var = ctk.StringVar(value="")
            end_entry = ctk.CTkEntry(scroll, textvariable=end_var, width=65,
                                      placeholder_text="자동")
            end_entry.grid(row=i, column=3, padx=3, pady=3)

            offset_var = ctk.StringVar(value="")
            offset_entry = ctk.CTkEntry(scroll, textvariable=offset_var, width=65,
                                        placeholder_text="-")
            offset_entry.grid(row=i, column=4, padx=3, pady=3)

            vh_var = ctk.StringVar(value="0.5")
            vh_entry = ctk.CTkEntry(scroll, textvariable=vh_var, width=50,
                                     placeholder_text="0.5")
            vh_entry.grid(row=i, column=5, padx=3, pady=3)

            berm_v_var = ctk.StringVar(value="5")
            berm_v_entry = ctk.CTkEntry(scroll, textvariable=berm_v_var, width=60,
                                         placeholder_text="5")
            berm_v_entry.grid(row=i, column=6, padx=3, pady=3)

            berm_w_var = ctk.StringVar(value="1")
            berm_w_entry = ctk.CTkEntry(scroll, textvariable=berm_w_var, width=60,
                                         placeholder_text="1")
            berm_w_entry.grid(row=i, column=7, padx=3, pady=3)

            trans_var = ctk.StringVar(value="10")
            trans_entry = ctk.CTkEntry(scroll, textvariable=trans_var, width=55,
                                        placeholder_text="10m")
            trans_entry.grid(row=i, column=8, padx=3, pady=3)

            ctk.CTkLabel(scroll, text=coord_text, font=ctk.CTkFont(size=10),
                        text_color="gray").grid(row=i, column=9, padx=3, pady=3, sticky="w")

            def _on_mode(val, se=start_entry, ee=end_entry, oe=offset_entry):
                if val == "계획고 입력":
                    se.configure(placeholder_text="EL.")
                    ee.configure(placeholder_text="EL.")
                    oe.configure(placeholder_text="-")
                elif val in ("절토", "성토"):
                    se.configure(placeholder_text="-")
                    ee.configure(placeholder_text="-")
                    oe.configure(placeholder_text=f"{val}(m)")
                    se.delete(0, "end"); ee.delete(0, "end")
                else:
                    se.configure(placeholder_text="자동")
                    ee.configure(placeholder_text="자동")
                    oe.configure(placeholder_text="-")
                    se.delete(0, "end"); ee.delete(0, "end"); oe.delete(0, "end")
            mode_menu.configure(command=_on_mode)

            layer_widgets[ln] = (mode_var, start_var, end_var, offset_var,
                                 vh_var, berm_v_var, berm_w_var, trans_var)

        result = [False]

        def _remove_markers():
            for m in elev_markers:
                with contextlib.suppress(Exception):
                    m.delete()

        def on_ok():
            self.layer_elevations = {}
            for ln, widgets in layer_widgets.items():
                mode_var, start_var, end_var, offset_var, \
                    vh_var, berm_v_var, berm_w_var, trans_var = widgets
                label = mode_var.get()
                if "지형" in label: mode = "terrain"
                elif "계획고" in label: mode = "manual"
                elif "절토" in label: mode = "cut"
                elif "성토" in label: mode = "fill"
                else: mode = "terrain"

                def _fltget(v, default):
                    try: return float(v.get()) if v.get().strip() else default
                    except (ValueError, TypeError): return default

                start_el = _fltget(start_var, None) if mode == "manual" else None
                end_el = _fltget(end_var, None) if mode == "manual" else None
                offset_m = abs(_fltget(offset_var, 0.0) or 0.0)
                vh = _fltget(vh_var, 0.5) or 0.5
                berm_v = _fltget(berm_v_var, 5.0) or 5.0
                berm_w = _fltget(berm_w_var, 1.0) or 1.0
                transition_m = max(1.0, _fltget(trans_var, 10.0) or 10.0)

                self.layer_elevations[ln] = {
                    "mode": mode,
                    "start_el": start_el, "end_el": end_el,
                    "offset_m": offset_m,
                    "slope_vh": max(0.01, vh),
                    "berm_step_v": max(0.1, berm_v),
                    "berm_width_h": max(0.0, berm_w),
                    "transition_m": transition_m,
                }
            result[0] = True
            _remove_markers()
            win.destroy()

        def on_cancel():
            _remove_markers()
            win.destroy()

        bf = ctk.CTkFrame(win, fg_color="transparent")
        bf.pack(padx=20, pady=10, fill="x")
        ctk.CTkButton(bf, text="취소", width=80, fg_color="transparent", border_width=1,
                      command=on_cancel).pack(side="right", padx=5)
        ctk.CTkButton(bf, text="적용", width=140, command=on_ok).pack(side="right", padx=5)

        win.wait_window()
        return result[0]

    def _excavate_tin_for_structures(self):
        """구조물 footprint 영역의 TIN을 굴착/성토 반영하여 재구성.

        - 폴리곤: placement_transform.ref_tin의 4점 quad가 있으면 우선 사용
          (구조물의 실제 회전/크기 반영). 없으면 AABB bounds 폴백.
        - 폴리곤 내부는 평탄 pad(`pad_z = median(원Z 내부) - exc_depth`)로 flatten
          → 구조물 바닥이 평면이므로 TIN도 평면이어야 "물리적으로 딱 맞춤" 성립.
        - 외곽 전이구간(transition_w)에서 smoothstep으로 pad_z↔원Z 부드럽게 블렌드
          → 수직 절벽 제거, 자연스러운 절토 사면.
        - 폴리곤+전이구간에 격자 densification 점을 추가한 뒤 Delaunay 재계산
          → 전이 사면의 삼각망이 조밀해져 시각적으로 부드러움.

        양수(+) exc_depth = 원지반 대비 깎기, 음수(-) = 성토.
        """
        from scipy.interpolate import LinearNDInterpolator
        from scipy.spatial import Delaunay

        if not self.tin_mesh or not self.structure_registry:
            return 0

        # 스냅샷: 원본 TIN 점 (Z 보간의 기준 = 굴착 전 원지반)
        orig_pts = np.array(self.tin_mesh.points, dtype=np.float64)
        if len(orig_pts) < 4:
            return 0

        try:
            orig_interp = LinearNDInterpolator(orig_pts[:, :2], orig_pts[:, 2])
        except Exception as e:
            self.log(f"  TIN 보간기 생성 실패: {e}")
            return 0

        # 작업 사본
        work_pts = orig_pts.copy()
        all_new_points: list[np.ndarray] = []  # densification으로 추가되는 점
        modified_count = 0
        any_excavated = False

        for info in self.structure_registry.values():
            if not info.get("template_meshes"):
                continue
            exc_depth = float(info.get("excavation_depth", 0.0) or 0.0)
            if abs(exc_depth) < 0.001:
                continue

            # --- 폴리곤 확보 (TIN 로컬 좌표) ---
            poly_pts = self._get_structure_footprint_polygon(info)
            if poly_pts is None or len(poly_pts) < 3:
                self.log(f"    [{info['name']}] 폴리곤 없음 — 굴착 건너뜀")
                continue

            # 폴리곤 크기 기반 파라미터
            px_min, px_max = float(poly_pts[:, 0].min()), float(poly_pts[:, 0].max())
            py_min, py_max = float(poly_pts[:, 1].min()), float(poly_pts[:, 1].max())
            poly_w = max(px_max - px_min, 1e-3)
            poly_h = max(py_max - py_min, 1e-3)
            poly_size = max(poly_w, poly_h)

            # 전이구간 폭: 절토사면 경사 1:1.5 가정 (depth × 1.5), 최소 2m
            transition_w = max(abs(exc_depth) * 1.5, 2.0)

            # 기존 TIN 정점의 폴리곤 signed distance
            signed_d = _signed_distance_to_polygon(work_pts[:, :2], poly_pts)
            inside_mask = signed_d <= 0.0

            n_inside = int(np.sum(inside_mask))
            if n_inside == 0:
                # 폴리곤이 TIN 정점 없는 영역에 있음 — 중심 Z로 추정
                cx = (px_min + px_max) * 0.5
                cy = (py_min + py_max) * 0.5
                z_c = float(orig_interp(cx, cy))
                if np.isnan(z_c):
                    z_c = float(np.median(orig_pts[:, 2]))
                pad_z = z_c - exc_depth
            else:
                pad_z = float(np.median(work_pts[inside_mask, 2])) - exc_depth

            # --- 기존 정점 Z 수정 ---
            # 내부: pad_z로 flatten
            # 전이: smoothstep blend (pad_z → orig_z)
            in_band = signed_d < transition_w
            band_idx = np.where(in_band)[0]
            for i in band_idx:
                d = float(signed_d[i])
                if d <= 0.0:
                    work_pts[i, 2] = pad_z
                else:
                    t = d / transition_w  # 0..1
                    blend = t * t * (3.0 - 2.0 * t)  # smoothstep
                    orig_z = float(orig_pts[i, 2])
                    work_pts[i, 2] = pad_z * (1.0 - blend) + orig_z * blend
            n_affected = len(band_idx)

            # --- Densification: 폴리곤+전이구간에 격자 점 추가 ---
            # 간격: 구조물 크기의 1/18 정도, 최소 1m
            cell = max(min(poly_size / 18.0, transition_w / 4.0), 1.0)
            ex_xmin = px_min - transition_w - cell
            ex_xmax = px_max + transition_w + cell
            ex_ymin = py_min - transition_w - cell
            ex_ymax = py_max + transition_w + cell

            nx = max(int((ex_xmax - ex_xmin) / cell) + 1, 2)
            ny = max(int((ex_ymax - ex_ymin) / cell) + 1, 2)
            gx = np.linspace(ex_xmin, ex_xmax, nx)
            gy = np.linspace(ex_ymin, ex_ymax, ny)
            gxx, gyy = np.meshgrid(gx, gy)
            grid_xy = np.column_stack([gxx.ravel(), gyy.ravel()])

            # 격자점의 signed distance
            grid_d = _signed_distance_to_polygon(grid_xy, poly_pts)
            use_mask = grid_d < transition_w  # 굴착 영향권만
            grid_xy = grid_xy[use_mask]
            grid_d = grid_d[use_mask]

            if len(grid_xy) > 0:
                # 각 격자점의 원지형 Z 보간
                grid_orig_z = orig_interp(grid_xy)
                fallback_z = float(np.median(orig_pts[:, 2]))
                grid_orig_z = np.where(
                    np.isnan(grid_orig_z), fallback_z, grid_orig_z
                )

                # 굴착 Z 계산
                grid_new_z = np.empty(len(grid_xy), dtype=np.float64)
                for i, d in enumerate(grid_d):
                    if d <= 0.0:
                        grid_new_z[i] = pad_z
                    else:
                        t = float(d) / transition_w
                        blend = t * t * (3.0 - 2.0 * t)
                        grid_new_z[i] = pad_z * (1.0 - blend) + float(grid_orig_z[i]) * blend

                new_xyz = np.column_stack([grid_xy, grid_new_z])
                all_new_points.append(new_xyz)

            # --- 레지스트리에 pad_z 저장 (placement 단계에서 TIN 관통 방지용) ---
            info["_excavation_pad_z"] = pad_z

            modified_count += n_affected
            any_excavated = True
            self.log(
                f"    [{info['name']}] 굴착 {exc_depth:+.2f}m · "
                f"pad_z={pad_z:.2f}m · "
                f"기존정점 {n_affected}개 수정 + "
                f"densify {len(grid_xy) if len(all_new_points) else 0}점 추가 "
                f"(폴리곤 {poly_w:.1f}×{poly_h:.1f}m, 전이 {transition_w:.1f}m)"
            )

        if not any_excavated:
            return 0

        # --- Delaunay 재구성 ---
        if all_new_points:
            new_pts = np.concatenate(all_new_points, axis=0)
            combined = np.vstack([work_pts, new_pts])
        else:
            combined = work_pts

        # 중복 제거 (XY 동일 정점은 Delaunay 불안정)
        combined_xy = combined[:, :2]
        _, unique_idx = np.unique(
            np.round(combined_xy, 4), axis=0, return_index=True
        )
        combined = combined[np.sort(unique_idx)]

        try:
            tri = Delaunay(combined[:, :2])
            faces = np.column_stack(
                [np.full(len(tri.simplices), 3), tri.simplices]
            )
            new_mesh = pv.PolyData(combined, faces)
            new_mesh["Elevation"] = combined[:, 2]
            self.tin_mesh = new_mesh
            self._tin_interpolator = None
            self._overlay_cache_key = None
            self.log(
                f"  TIN 굴착/성토 완료: {modified_count}개 정점 수정, "
                f"{len(combined) - len(orig_pts)}개 densify점 추가 → "
                f"TIN 재삼각망 {len(combined)}정점"
            )
        except Exception as e:
            # Delaunay 실패 시 폴백: 기존 mesh의 points만 교체 (삼각망은 그대로)
            self.log(f"  Delaunay 재삼각망 실패 — in-place Z만 적용: {e}")
            self.tin_mesh.points = work_pts
            self.tin_mesh["Elevation"] = work_pts[:, 2]
            self._tin_interpolator = None
            self._overlay_cache_key = None

        return modified_count

    def _get_structure_footprint_polygon(self, info):
        """구조물 footprint 폴리곤을 TIN 로컬 좌표로 반환 (Nx2 ndarray).

        우선순위:
          1. placement_transform.ref_tin (4점 quad, 사용자 직접 지정)
          2. info['bounds'] AABB (fallback)
        """
        tr = info.get("placement_transform")
        if tr is not None:
            ref_tin = getattr(tr, "ref_tin", None)
            if ref_tin and len(ref_tin) >= 3:
                arr = np.asarray(ref_tin, dtype=np.float64)[:, :2]
                # ref_tin이 월드 좌표로 저장된 레거시 데이터 감지 → 로컬 변환
                tb = self.tin_mesh.bounds
                cx = float(arr[:, 0].mean())
                cy = float(arr[:, 1].mean())
                if not (tb.x_min - 5000 <= cx <= tb.x_max + 5000
                        and tb.y_min - 5000 <= cy <= tb.y_max + 5000):
                    arr = arr - np.array(
                        [float(self.origin[0]), float(self.origin[1])]
                    )
                return arr

        # AABB 폴백
        bounds = info.get("bounds")
        if not bounds:
            return None
        local = [
            (bounds[0] - self.origin[0], bounds[1] - self.origin[1]),
            (bounds[2] - self.origin[0], bounds[1] - self.origin[1]),
            (bounds[2] - self.origin[0], bounds[3] - self.origin[1]),
            (bounds[0] - self.origin[0], bounds[3] - self.origin[1]),
        ]
        return np.asarray(local, dtype=np.float64)

    @staticmethod
    def _resample_polyline(pts: np.ndarray, step: float = 2.0) -> np.ndarray:
        """2D polyline을 step 간격으로 re-sampling. pts: (N, 2)."""
        if len(pts) < 2:
            return pts.copy()
        result = [pts[0].copy()]
        for i in range(1, len(pts)):
            seg_vec = pts[i] - pts[i - 1]
            seg_len = float(np.linalg.norm(seg_vec))
            if seg_len < 1e-9:
                continue
            n_sub = max(1, int(np.ceil(seg_len / max(step, 0.1))))
            for j in range(1, n_sub + 1):
                t = j / n_sub
                result.append(pts[i - 1] + t * seg_vec)
        return np.array(result)

    @staticmethod
    def _slope_breakpoints(depth: float, half_w: float, vh: float,
                             berm_v: float, berm_w: float) -> list:
        """절토/성토 사면의 단절점들을 (cross_dist, v_rise) 쌍으로 반환.

        단절점은 삼각화가 유지해야 할 주요 위치:
          - 도로 edge (half_w, 0)
          - 각 사면 꼭대기 (= 소단 시작)
          - 각 소단 끝
          - 사면 toe (= depth 도달 지점, 약간 여유)

        V:H=1:vh 경사로 오르다가 berm_v마다 berm_w 폭 소단 삽입. 총 depth까지.
        """
        pts = [(half_w, 0.0)]  # 도로 edge
        if depth <= 0:
            return pts
        v = 0.0
        h = half_w
        while v < depth:
            seg_v = min(berm_v, depth - v)
            seg_h = seg_v * vh
            h += seg_h
            v += seg_v
            pts.append((h, v))   # 사면 꼭대기 / 소단 시작
            if v >= depth:
                break
            h += berm_w
            pts.append((h, v))   # 소단 끝 / 다음 사면 시작
        # 사면 toe 바깥쪽 약간 여유 (지형과의 자연스러운 연결을 위한 transition 점)
        pts.append((h + 0.3, v))
        return pts

    @staticmethod
    def _cut_slope_rise(h_dist: float, total_depth: float,
                          vh_ratio: float = 0.5,
                          berm_step_v: float = 5.0,
                          berm_width_h: float = 1.0) -> float:
        """수평 h_dist만큼 나가면서 토목 표준 사면으로 수직 상승(또는 하강)한 양.

        V:H = 1 : vh_ratio 의 사면으로 오르다가, 수직 berm_step_v마다 berm_width_h
        폭의 소단(수평 플랫폼)을 삽입. total_depth에서 캡.

        예) vh_ratio=0.5, berm_step_v=5, berm_width_h=1:
            수직 5m 오르려면 수평 2.5m → 소단 1m → 수직 5m (수평 2.5m) → 소단 1m → …
            수직 10m 사면의 총 수평거리 = 5 + 1 = 6m (소단 1개)

        Args:
            h_dist: 수평 거리 (도로 edge에서 얼마 나갔는지, ≥0)
            total_depth: 도달해야 할 총 수직 거리 (cut_depth 또는 fill_depth, ≥0)
            vh_ratio: V:H 비율 중 H값 (V=1 기준). 0.5면 1:0.5
            berm_step_v: 소단 간격 (수직 m)
            berm_width_h: 소단 폭 (수평 m)

        Returns:
            h_dist 지점의 수직 상승량 (0 ≤ ... ≤ total_depth)
        """
        if h_dist <= 0 or total_depth <= 0:
            return 0.0
        v = 0.0
        h = 0.0
        while v < total_depth:
            seg_v = min(berm_step_v, total_depth - v)
            seg_h = seg_v * vh_ratio
            if h + seg_h >= h_dist:
                # 현재 사면 세그먼트 위
                frac = (h_dist - h) / max(seg_h, 1e-9)
                return min(v + seg_v * frac, total_depth)
            h += seg_h
            v += seg_v
            if v >= total_depth:
                return total_depth
            # 소단 (수평 플랫폼)
            if h + berm_width_h >= h_dist:
                return v
            h += berm_width_h
        return total_depth

    def _retriangulate_for_cut_fill(self, orig_pts, original_z, interp, z_fallback,
                                      cut_fill_layers, elev_settings):
        """cut/fill 레이어에 대해 synthetic 정점 + 재-삼각화로 깨끗한 사면 생성."""
        from matplotlib.path import Path as MplPath
        from scipy.spatial import Delaunay

        synthetic_pts: list[tuple[float, float, float]] = []  # (x, y, z) local
        cut_zone_polys: list[np.ndarray] = []  # 각 road의 cut zone 폴리곤

        along_step = 2.0  # 도로 방향 2m 간격 sampling

        def _sample_terrain(x, y):
            z = float(interp([x, y]))
            if np.isnan(z):
                z = z_fallback
            return z

        for layer_name, layer_data in cut_fill_layers:
            type_def = layer_data["type_def"]
            width = float(type_def.get("width", 6.0))
            half_w = width / 2.0

            cfg = elev_settings.get(layer_name, {})
            mode = cfg.get("mode", "terrain")
            offset_m = float(cfg.get("offset_m", 0.0) or 0.0)
            vh = float(cfg.get("slope_vh", 0.5) or 0.5)
            berm_v = float(cfg.get("berm_step_v", 5.0) or 5.0)
            berm_w = float(cfg.get("berm_width_h", 1.0) or 1.0)

            # 사면 단절점 cross 위치 사전 계산 (depth가 달라도 재사용 기본 패턴)
            # 실제 depth는 per-along 다름 → 각 along마다 _slope_breakpoints 재호출

            for geom in layer_data["geometries"]:
                if geom["type"] != "polyline" or len(geom["points"]) < 2:
                    continue

                raw_pts = np.array(geom["points"], dtype=np.float64)
                local_pts = raw_pts - self.origin[:2]
                sampled = self._resample_polyline(local_pts, step=along_step)
                n_along = len(sampled)
                if n_along < 2:
                    continue

                # 각 along 점의 tangent → normal
                tangents = np.zeros_like(sampled)
                for i in range(n_along):
                    if i == 0:
                        t = sampled[1] - sampled[0]
                    elif i == n_along - 1:
                        t = sampled[-1] - sampled[-2]
                    else:
                        t = sampled[i + 1] - sampled[i - 1]
                    nrm = np.linalg.norm(t)
                    tangents[i] = t / max(nrm, 1e-9)

                # cut zone polygon 외곽선 (left toe forward + right toe reverse)
                left_toe_pts: list[list[float]] = []
                right_toe_pts: list[list[float]] = []

                # per-along loop
                for i in range(n_along):
                    x, y = sampled[i]
                    tangent = tangents[i]
                    normal = np.array([-tangent[1], tangent[0]])

                    terrain_z = _sample_terrain(x, y)
                    # cut: 지형보다 낮게(절토), fill: 지형보다 높게(성토)
                    road_z = terrain_z - offset_m if mode == "cut" else terrain_z + offset_m
                    # origin Z 보정
                    road_z -= self.origin[2]
                    # Note: original_z 배열은 이미 origin[2] 보정된 상태이므로
                    #       interp 결과도 local Z. road_z도 local.

                    # 중심선 정점
                    synthetic_pts.append((float(x), float(y), float(road_z)))

                    # Depth: 각 side에서 실제 지형 Z와의 차이 (단순화: 중심선 terrain 사용)
                    # local Z (cut/fill 모두 절대값으로 동일하게 계산)
                    depth_local_z = abs(terrain_z - (road_z + self.origin[2]))

                    bps = self._slope_breakpoints(
                        depth_local_z, half_w, vh, berm_v, berm_w,
                    )

                    # 각 side (-1, +1)에 대해
                    for side in (-1.0, 1.0):
                        for (cross, v_rise) in bps:
                            cx = float(x + side * normal[0] * cross)
                            cy = float(y + side * normal[1] * cross)
                            if mode == "cut":
                                cz = road_z + v_rise
                                # 지형 이상 cut 금지 → 지형으로 캡
                                terrain_here_abs = _sample_terrain(cx, cy)
                                terrain_here_local = terrain_here_abs - self.origin[2]
                                cz = min(cz, terrain_here_local)
                            else:  # fill
                                cz = road_z - v_rise
                                terrain_here_abs = _sample_terrain(cx, cy)
                                terrain_here_local = terrain_here_abs - self.origin[2]
                                cz = max(cz, terrain_here_local)
                            synthetic_pts.append((cx, cy, float(cz)))

                    # cut zone 경계: toe 끝
                    toe_cross = bps[-1][0] if bps else half_w
                    lx = float(x - normal[0] * toe_cross)
                    ly = float(y - normal[1] * toe_cross)
                    rx = float(x + normal[0] * toe_cross)
                    ry = float(y + normal[1] * toe_cross)
                    left_toe_pts.append([lx, ly])
                    right_toe_pts.append([rx, ry])

                # cut zone 폴리곤 닫기 (right forward + left reverse)
                if left_toe_pts and right_toe_pts:
                    zone = np.array(right_toe_pts + list(reversed(left_toe_pts)))
                    cut_zone_polys.append(zone)

        if not synthetic_pts:
            self.log("  TIN 변형 대상 없음 (cut/fill synthetic 생성 실패)")
            return

        # 원본 TIN 정점 중 cut zone 내부에 있는 것들 제거
        keep_mask = np.ones(len(orig_pts), dtype=bool)
        for zone in cut_zone_polys:
            if len(zone) < 3:
                continue
            try:
                path = MplPath(zone)
                inside = path.contains_points(orig_pts[:, :2])
                keep_mask &= ~inside
            except Exception:
                pass
        kept = orig_pts[keep_mask]

        # 통합
        synth_arr = np.array(synthetic_pts, dtype=np.float64)
        combined = np.vstack([kept, synth_arr])

        # 재-삼각화
        try:
            tri = Delaunay(combined[:, :2])
            faces = np.column_stack([np.full(len(tri.simplices), 3), tri.simplices])
            import pyvista as pv
            new_mesh = pv.PolyData(combined, faces)
            new_mesh["Elevation"] = combined[:, 2]
            self.tin_mesh = new_mesh
            self._tin_interpolator = None
            self.log(
                f"  TIN 재-삼각화: 원본 {len(orig_pts)} → 잔존 {len(kept)} + synthetic {len(synth_arr)} "
                f"= {len(combined)} 정점, {len(tri.simplices)} 삼각형 "
                f"(cut/fill {len(cut_fill_layers)}개 레이어)"
            )
        except Exception as e:
            self.log(f"  TIN 재-삼각화 실패: {e} — 원본 유지")

    def _deform_tin_for_plans(self):
        """도로/굴착 계획선에 맞게 TIN 지형을 변형.

        cut/fill 모드: **synthetic 정점 삽입 + cut zone 재-삼각화** (깨끗한 사면·소단)
        terrain/manual 모드: 기존 smoothstep 블렌드 (vertex displacement)

        cut/fill 재-삼각화 절차:
          1) 각 cut/fill road에 대해 along step 2m로 polyline re-sampling
          2) 각 along 점에서 수직 법선 방향으로 **사면 단절점**(도로 edge, 소단 edge,
             사면 toe) XY 좌표 + 해당 Z 값 생성 → synthetic 정점
          3) road 중심선 자체도 synthetic 정점으로 (평탄 road_z)
          4) 각 road의 **cut zone 폴리곤**(road ± 사면 총폭) 계산
          5) 원본 TIN 정점 중 어떤 cut zone 내부에 속하는 것들을 제거
          6) 잔존 TIN + synthetic 통합 후 Delaunay 재-삼각화로 교체
          7) 결과: 도로 평탄, 사면 경계 날카로운 소단, 지형 매끄러운 전이
        """
        from scipy.interpolate import LinearNDInterpolator
        from matplotlib.path import Path as MplPath
        from scipy.spatial import cKDTree

        if not self.layer_geometries or not self.tin_mesh:
            return

        pts = np.array(self.tin_mesh.points).copy()
        original_z = pts[:, 2].copy()  # 변형 전 지형 Z (보존 기준)

        interp = LinearNDInterpolator(pts[:, :2], original_z)
        z_fallback = float(np.median(original_z))
        tree = cKDTree(pts[:, :2])

        elev_settings = getattr(self, 'layer_elevations', {})

        # ─────────────────────────────────────────────────────────
        # cut/fill 모드 레이어가 있으면 **재-삼각화** 경로
        # ─────────────────────────────────────────────────────────
        cut_fill_layers = [
            (ln, ld) for ln, ld in self.layer_geometries.items()
            if elev_settings.get(ln, {}).get("mode") in ("cut", "fill")
            and ld["type_def"].get("render_mode") == "path_extrude"
        ]

        if cut_fill_layers:
            self._retriangulate_for_cut_fill(pts, original_z, interp, z_fallback,
                                               cut_fill_layers, elev_settings)
            return  # 재-삼각화 완료. terrain/manual는 별도 호출 필요 시 확장

        # ─────────────────────────────────────────────────────────
        # cut/fill 없으면 기존 smoothstep 블렌드 (terrain/manual/surface_overlay)
        # ─────────────────────────────────────────────────────────

        # per-vertex 누적 버퍼: 각 정점의 target Z 후보를 weight 가중 평균
        n_verts = len(pts)
        sum_wz = np.zeros(n_verts, dtype=np.float64)
        sum_w = np.zeros(n_verts, dtype=np.float64)

        def _smoothstep(u):
            u = max(0.0, min(1.0, u))
            return u * u * (3.0 - 2.0 * u)

        for layer_name, layer_data in self.layer_geometries.items():
            type_def = layer_data["type_def"]
            render_mode = type_def.get("render_mode", "line_only")
            if render_mode not in ("path_extrude", "surface_overlay"):
                continue

            z_offset_yaml = type_def.get("z_offset", 0.0)
            width = type_def.get("width", 6.0)

            elev_cfg = elev_settings.get(layer_name, {"mode": "terrain"})
            mode = elev_cfg.get("mode", "terrain")
            manual_start = elev_cfg.get("start_el")
            manual_end = elev_cfg.get("end_el")
            offset_m = float(elev_cfg.get("offset_m", 0.0) or 0.0)
            transition_m = float(elev_cfg.get("transition_m", 10.0) or 10.0)
            vh_ratio = float(elev_cfg.get("slope_vh", 0.5) or 0.5)
            berm_step_v = float(elev_cfg.get("berm_step_v", 5.0) or 5.0)
            berm_width_h = float(elev_cfg.get("berm_width_h", 1.0) or 1.0)

            is_cut_fill_mode = mode in ("cut", "fill")

            for geom in layer_data["geometries"]:
                try:
                    if render_mode == "path_extrude":
                        if geom["type"] != "polyline" or len(geom["points"]) < 2:
                            continue
                        raw_pts = np.array(geom["points"], dtype=np.float64)
                        local_pts = raw_pts - self.origin[:2]
                        n_pts = len(local_pts)

                        # 각 중심선 점의 target Z
                        centerline_z = np.zeros(n_pts)
                        if mode == "manual" and manual_start is not None and manual_end is not None:
                            z_start = manual_start - self.origin[2]
                            z_end = manual_end - self.origin[2]
                            dists = np.zeros(n_pts)
                            for pi in range(1, n_pts):
                                dists[pi] = dists[pi-1] + np.linalg.norm(local_pts[pi] - local_pts[pi-1])
                            total_dist = max(dists[-1], 1e-6)
                            centerline_z = z_start + (z_end - z_start) * (dists / total_dist)
                        else:
                            for pi in range(n_pts):
                                z_here = interp(local_pts[pi])
                                if np.isnan(z_here):
                                    z_here = z_fallback
                                centerline_z[pi] = float(z_here)
                            if mode == "cut":
                                centerline_z -= offset_m
                            elif mode == "fill":
                                centerline_z += offset_m
                            centerline_z += z_offset_yaml

                        half_w = width / 2.0
                        # 사면의 최대 수평 반경: cut/fill 모드는 사면 끝 + 약간 여유,
                        # terrain/manual은 기존 transition_m
                        if is_cut_fill_mode:
                            # 사면 예상 최대 수평: depth * vh_ratio + 소단 영향
                            est_max_depth = max(offset_m + 3.0, 3.0)  # 여유
                            n_berms = int(est_max_depth / max(berm_step_v, 1e-3))
                            slope_horiz_max = est_max_depth * vh_ratio + n_berms * berm_width_h
                            search_r_beyond = max(slope_horiz_max, transition_m) + 2.0
                        else:
                            search_r_beyond = transition_m + 2.0
                        total_w = half_w + search_r_beyond

                        for seg_i in range(n_pts - 1):
                            p1 = local_pts[seg_i]
                            p2 = local_pts[seg_i + 1]
                            seg_vec = p2 - p1
                            seg_len = float(np.linalg.norm(seg_vec))
                            if seg_len < 0.1:
                                continue
                            seg_dir = seg_vec / seg_len
                            normal = np.array([-seg_dir[1], seg_dir[0]])

                            mid = (p1 + p2) / 2
                            search_r = seg_len / 2 + total_w + 2
                            nearby = tree.query_ball_point(mid, search_r)

                            z1 = centerline_z[seg_i]
                            z2 = centerline_z[seg_i + 1]

                            for vi in nearby:
                                v = pts[vi, :2] - p1
                                along = float(np.dot(v, seg_dir))
                                if along < 0 or along > seg_len:
                                    continue
                                cross = abs(float(np.dot(v, normal)))
                                if cross > total_w:
                                    continue

                                t = along / seg_len
                                road_z = z1 * (1 - t) + z2 * t
                                terrain_z_here = original_z[vi]

                                if cross <= half_w:
                                    # 도로 표면: 완전 평탄화
                                    target_z = road_z
                                    w = 1.0
                                elif is_cut_fill_mode:
                                    # 토목 표준 사면으로 cut/fill
                                    h_from_edge = cross - half_w
                                    if mode == "cut" or (mode == "fill" and terrain_z_here < road_z):
                                        # cut: 도로보다 지형이 위 → 상승 사면
                                        depth = max(0.0, terrain_z_here - road_z)
                                        rise = self._cut_slope_rise(
                                            h_from_edge, depth,
                                            vh_ratio, berm_step_v, berm_width_h,
                                        )
                                        target_z = road_z + rise
                                        # 사면이 지형에 도달하면 지형 유지
                                        target_z = min(terrain_z_here, target_z)
                                    else:
                                        # fill: 지형보다 도로가 위 → 하강 사면
                                        depth = max(0.0, road_z - terrain_z_here)
                                        drop = self._cut_slope_rise(
                                            h_from_edge, depth,
                                            vh_ratio, berm_step_v, berm_width_h,
                                        )
                                        target_z = road_z - drop
                                        target_z = max(terrain_z_here, target_z)
                                    w = 1.0  # 사면 영역 완전 적용
                                else:
                                    # terrain/manual: smoothstep 블렌드
                                    u = (cross - half_w) / transition_m
                                    w = 1.0 - _smoothstep(u)
                                    target_z = road_z

                                sum_wz[vi] += w * target_z
                                sum_w[vi] += w

                    elif render_mode == "surface_overlay":
                        if geom["type"] != "polyline" or not geom.get("closed", False):
                            continue
                        raw_pts = np.array(geom["points"], dtype=np.float64)
                        local_pts = raw_pts - self.origin[:2]
                        poly = MplPath(local_pts)
                        inside_mask = poly.contains_points(pts[:, :2])
                        sign = -1 if mode == "cut" else (+1 if mode == "fill" else 0)
                        overlay_offset = sign * offset_m + z_offset_yaml
                        if abs(overlay_offset) < 1e-9:
                            continue
                        for vi in np.where(inside_mask)[0]:
                            target = original_z[vi] + overlay_offset
                            sum_wz[vi] += target
                            sum_w[vi] += 1.0

                except Exception as e:
                    self.log(f"    TIN 변형 오류 ({layer_name}): {e}")
                    continue

        influenced = sum_w > 1e-6
        n_influenced = int(np.sum(influenced))
        if n_influenced == 0:
            self.log("  TIN 변형 대상 없음")
            return

        target_z = np.where(influenced, sum_wz / np.maximum(sum_w, 1e-9), pts[:, 2])
        blend = np.clip(sum_w, 0.0, 1.0)
        pts[:, 2] = blend * target_z + (1.0 - blend) * pts[:, 2]

        self.tin_mesh.points = pts
        self.tin_mesh["Elevation"] = pts[:, 2]
        self._tin_interpolator = None
        self.log(f"  TIN 지형 변형 완료: {n_influenced:,}개 정점 조정됨")

    def _build_plan_overlay_meshes(self):
        """분류된 계획선을 3D 메쉬로 변환하여 overlay_meshes 리스트를 반환 (캐시됨).

        워크플로우 원칙:
          - path_extrude / surface_overlay: TIN 변형으로 이미 처리 → 여기선 스킵
          - 구조물 모드(wall/box/elevated/tube): 평면도에서는 '위치 표시용 outline'만
            (full 3D는 detail DXF 업로드 후 template_meshes가 담당)
          - line_only: 경계선만 표시

        Returns: [(pv.PolyData, color_hex, opacity), ...]
        """
        if not self.layer_geometries or not self.tin_mesh:
            return []

        # 캐시 확인 — (layer_geometries id, tin_mesh id) 기반
        cache_key = (id(self.layer_geometries), id(self.tin_mesh))
        if getattr(self, "_overlay_cache_key", None) == cache_key:
            cached = getattr(self, "_overlay_cache", None)
            if cached is not None:
                return cached

        tin_deformed_modes = {"path_extrude", "surface_overlay"}
        # 상세빌드 대상 모드 = overlay에서 스킵 (structure_registry가 bbox 마커로 표시)
        structure_modes = {"wall_extrude", "box_extrude", "elevated_path", "tube_path"}

        # TIN 바운드 기반 클립 박스 (원본 좌표계)
        # — INSERT explode 시 구조물 블록 내부 치수선/보조선이 도면 외곽 좌표를 가져
        #   overlay 뷰를 수백만 단위로 늘려 TIN이 작게 보이는 문제 방지
        tb = self.tin_mesh.bounds  # local coords
        pad_x = max((tb[1] - tb[0]) * 0.15, 100.0)
        pad_y = max((tb[3] - tb[2]) * 0.15, 100.0)
        clip_xmin_raw = tb[0] + self.origin[0] - pad_x
        clip_xmax_raw = tb[1] + self.origin[0] + pad_x
        clip_ymin_raw = tb[2] + self.origin[1] - pad_y
        clip_ymax_raw = tb[3] + self.origin[1] + pad_y
        self._diag(f"=== _build_plan_overlay_meshes: TIN 바운드 클립 "
                   f"X[{clip_xmin_raw:.0f}, {clip_xmax_raw:.0f}] "
                   f"Y[{clip_ymin_raw:.0f}, {clip_ymax_raw:.0f}] ===")

        def _in_bounds(xy_iter):
            return all(clip_xmin_raw <= x <= clip_xmax_raw and clip_ymin_raw <= y <= clip_ymax_raw for x, y in xy_iter)

        overlay_meshes = []
        skipped_oob = {}  # 레이어별 out-of-bounds 스킵 카운트

        for layer_name, layer_data in self.layer_geometries.items():
            type_def = layer_data["type_def"]
            geoms = layer_data["geometries"]

            render_mode = type_def.get("render_mode", "line_only")
            color = type_def.get("color", "#FF0000")
            opacity = type_def.get("opacity", 0.8)
            z_offset = type_def.get("z_offset", 0.0)

            if render_mode in tin_deformed_modes:
                continue  # TIN 변형으로 처리됨
            if render_mode in structure_modes:
                continue  # structure_registry bbox 마커가 담당

            # 상세도면에서 계획고가 지정된 경우 고정 Z 사용
            fixed_el = type_def.get("elevation")

            for geom in geoms:
                try:
                    mesh = None

                    if geom["type"] == "polyline" and len(geom["points"]) >= 2:
                        pts_2d = geom["points"]
                        if not _in_bounds(pts_2d):
                            skipped_oob[layer_name] = skipped_oob.get(layer_name, 0) + 1
                            continue
                        pts_3d = self._project_xy_to_tin(pts_2d)
                        if len(pts_3d) < 2:
                            continue

                        if fixed_el is not None:
                            pts_3d[:, 2] = fixed_el - self.origin[2]
                        else:
                            pts_3d[:, 2] += z_offset

                        mesh = self._make_line_mesh(pts_3d)

                    elif geom["type"] == "line":
                        pts_2d = [geom["start"], geom["end"]]
                        if not _in_bounds(pts_2d):
                            skipped_oob[layer_name] = skipped_oob.get(layer_name, 0) + 1
                            continue
                        pts_3d = self._project_xy_to_tin(pts_2d)
                        if len(pts_3d) >= 2:
                            if fixed_el is not None:
                                pts_3d[:, 2] = fixed_el - self.origin[2]
                            else:
                                pts_3d[:, 2] += z_offset
                            mesh = self._make_line_mesh(pts_3d)

                    if mesh is not None and mesh.n_points > 0:
                        overlay_meshes.append((mesh, color, opacity))

                except Exception as e:
                    self.log(f"    지오메트리 변환 실패 ({layer_name}/{render_mode}): {e}")
                    continue

        if skipped_oob:
            for ln, cnt in skipped_oob.items():
                self._diag(f"  [OOB-SKIP] {ln}: {cnt}개 geom 제외 (TIN 바운드 밖)")

        # 캐시 저장
        self._overlay_cache_key = cache_key
        self._overlay_cache = overlay_meshes
        return overlay_meshes

    def _make_surface_mesh(self, pts_3d):
        """폐합 폴리라인 → 평면 메쉬 (삼각화)"""
        if len(pts_3d) < 3:
            return None
        try:
            tri = Delaunay(pts_3d[:, :2])
            faces = np.column_stack([np.full(len(tri.simplices), 3), tri.simplices])
            return pv.PolyData(pts_3d, faces)
        except Exception:
            return None

    def _make_path_mesh(self, pts_3d, width):
        """선형 → 폭 있는 도로/수로 면"""
        if len(pts_3d) < 2:
            return None

        half_w = width / 2.0
        left_pts = []
        right_pts = []

        for i in range(len(pts_3d)):
            if i == 0:
                dx = pts_3d[1][0] - pts_3d[0][0]
                dy = pts_3d[1][1] - pts_3d[0][1]
            elif i == len(pts_3d) - 1:
                dx = pts_3d[-1][0] - pts_3d[-2][0]
                dy = pts_3d[-1][1] - pts_3d[-2][1]
            else:
                dx = pts_3d[i+1][0] - pts_3d[i-1][0]
                dy = pts_3d[i+1][1] - pts_3d[i-1][1]

            length = max(np.sqrt(dx*dx + dy*dy), 1e-6)
            nx, ny = -dy / length * half_w, dx / length * half_w

            p = pts_3d[i]
            left_pts.append([p[0] + nx, p[1] + ny, p[2]])
            right_pts.append([p[0] - nx, p[1] - ny, p[2]])

        # 좌+우 점을 합쳐 strip 메쉬 생성
        all_pts = np.array(left_pts + right_pts[::-1])
        n = len(all_pts)
        if n < 3:
            return None

        try:
            tri = Delaunay(all_pts[:, :2])
            faces = np.column_stack([np.full(len(tri.simplices), 3), tri.simplices])
            return pv.PolyData(all_pts, faces)
        except Exception:
            return None

    def _make_wall_mesh(self, pts_3d, height):
        """선형 → 수직 벽체"""
        if len(pts_3d) < 2:
            return None

        n = len(pts_3d)
        top_pts = pts_3d.copy()
        top_pts[:, 2] += height

        all_pts = np.vstack([pts_3d, top_pts])

        # 각 세그먼트를 두 삼각형으로 구성
        faces_list = []
        for i in range(n - 1):
            b0, b1 = i, i + 1
            t0, t1 = i + n, i + 1 + n
            faces_list.append([3, b0, b1, t0])
            faces_list.append([3, b1, t1, t0])

        faces = np.array(faces_list).flatten()
        return pv.PolyData(all_pts, faces)

    def _make_box_mesh(self, pts_3d, height):
        """폐합 → 박스 (바닥면 + 옆면 + 윗면)"""
        wall = self._make_wall_mesh(pts_3d, height)
        bottom = self._make_surface_mesh(pts_3d)

        top_pts = pts_3d.copy()
        top_pts[:, 2] += height
        top = self._make_surface_mesh(top_pts)

        parts = [m for m in [wall, bottom, top] if m is not None]
        if not parts:
            return None

        combined = parts[0]
        for m in parts[1:]:
            combined = combined.merge(m)
        return combined

    def _make_line_mesh(self, pts_3d):
        """점 목록 → 선 메쉬 (PyVista PolyLine)"""
        if len(pts_3d) < 2:
            return None
        n = len(pts_3d)
        lines = np.zeros(n + 1, dtype=int)
        lines[0] = n
        lines[1:] = np.arange(n)
        return pv.PolyData(pts_3d, lines=lines)

    def _make_elevated_path_mesh(self, pts_3d, width, height):
        """교량: 지형 위 일정 높이로 들어올린 도로면 + 하부 기둥"""
        if len(pts_3d) < 2:
            return None

        # 상판: 지형 + height 위치에 도로면 생성
        elevated_pts = pts_3d.copy()
        elevated_pts[:, 2] += height
        deck = self._make_path_mesh(elevated_pts, width)

        # 기둥: 각 점에서 지형 → 상판까지 수직 벽체 (양쪽 끝 + 중간점)
        parts = [deck] if deck else []
        pillar_indices = [0, len(pts_3d) - 1]
        if len(pts_3d) > 4:
            pillar_indices.append(len(pts_3d) // 2)

        pillar_w = min(width * 0.15, 1.5)
        for idx in pillar_indices:
            base = pts_3d[idx].copy()
            top = base.copy()
            top[2] += height
            pillar_pts = np.array([
                [base[0] - pillar_w, base[1], base[2]],
                [base[0] + pillar_w, base[1], base[2]],
                [base[0] + pillar_w, base[1], top[2]],
                [base[0] - pillar_w, base[1], top[2]],
            ])
            faces = np.array([3, 0, 1, 2, 3, 0, 2, 3])
            parts.append(pv.PolyData(pillar_pts, faces))

        if not parts:
            return None
        combined = parts[0]
        for m in parts[1:]:
            combined = combined.merge(m)
        return combined

    def _make_tube_mesh(self, pts_3d, diameter):
        """관로: 중심선을 따라 원형 단면 튜브 생성"""
        if len(pts_3d) < 2:
            return None

        try:
            n = len(pts_3d)
            lines = np.zeros(n + 1, dtype=int)
            lines[0] = n
            lines[1:] = np.arange(n)
            centerline = pv.PolyData(pts_3d, lines=lines)
            tube = centerline.tube(radius=diameter / 2.0, n_sides=12)
            return tube
        except Exception:
            return self._make_line_mesh(pts_3d)

    def _on_engine_changed(self, engine_name):
        """렌더링 엔진 변경 시 UI 업데이트"""
        if "Vertex" in engine_name:
            self.stab_label.configure(text="GCP Project ID:")
            self.stab_entry.configure(placeholder_text="예: my-project-12345", show="")
            self.loc_label.grid()
            self.loc_entry.grid()
        elif "API Key" in engine_name:
            self.stab_label.configure(text="Gemini API Key:")
            self.stab_entry.configure(placeholder_text="aistudio.google.com에서 발급", show="*")
            self.loc_label.grid_remove()
            self.loc_entry.grid_remove()
        else:
            self.stab_label.configure(text="Stability API Key:")
            self.stab_entry.configure(placeholder_text="platform.stability.ai에서 발급", show="*")
            self.loc_label.grid_remove()
            self.loc_entry.grid_remove()

    # --- 재질 텍스처 합성 시스템 ---
    def _composite_material_textures(self, satellite_img,
                                     bbox_min_x=None, bbox_min_y=None,
                                     bbox_max_x=None, bbox_max_y=None):
        """위성 이미지 위에 계획선 영역의 재질 텍스처를 합성.

        도로 → 아스팔트 (진회색 + 미세 노이즈)
        사면 → 토사 (연갈색)
        굴착 → 적갈색 흙
        성토 → 짙은 갈색
        건물 → 콘크리트 (밝은 회색)

        **중요**: 이 함수에 넘기는 bbox는 **satellite_img가 실제로 커버하는
        투영 좌표 범위**와 일치해야 한다. 기본 5% 버퍼를 가정하고 하드코딩하면,
        DEM 확장(예: 1000m 버퍼)으로 훨씬 넓게 다운로드된 이미지에 대해 도로·
        사면이 안쪽에 압축되어 **확대된 것처럼** 그려진다.
        """
        if not self.layer_geometries or not hasattr(self, 'projected_bounds'):
            return satellite_img

        img = satellite_img.copy().convert("RGBA")
        img_w, img_h = img.size

        pb = self.projected_bounds  # [min_x, min_y, max_x, max_y]
        # 인자가 주어지지 않은 경우 기본 5% 여유 (legacy 호출 호환)
        if bbox_min_x is None:
            bw_ = pb[2] - pb[0]
            bh_ = pb[3] - pb[1]
            bbox_min_x = pb[0] - bw_ * 0.05
            bbox_max_x = pb[2] + bw_ * 0.05
            bbox_min_y = pb[1] - bh_ * 0.05
            bbox_max_y = pb[3] + bh_ * 0.05

        # 재질 색상 맵
        material_colors = {
            "path_extrude": {
                "road": (60, 60, 65, 220),       # 아스팔트
                "slope": (160, 130, 90, 180),     # 토사 사면
            },
            "surface_overlay": {
                "fill": (140, 110, 75, 160),      # 굴착/성토 흙
            },
            "wall_extrude": {
                "fill": (150, 150, 155, 200),     # 콘크리트
            },
            "box_extrude": {
                "fill": (180, 180, 185, 200),     # 건물 콘크리트
            },
        }

        def world_to_pixel(wx, wy):
            """원본 좌표계 → 이미지 픽셀 좌표"""
            px = (wx - bbox_min_x) / (bbox_max_x - bbox_min_x) * img_w
            # Y축 반전 (이미지 좌표는 위→아래, 지도 좌표는 아래→위)
            py = (1.0 - (wy - bbox_min_y) / (bbox_max_y - bbox_min_y)) * img_h
            return (int(px), int(py))

        overlay = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
        draw = ImageDraw.Draw(overlay)

        drawn_count = 0

        for layer_data in self.layer_geometries.values():
            type_def = layer_data["type_def"]
            render_mode = type_def.get("render_mode", "line_only")
            width = type_def.get("width", 6.0)
            slope_ratio = type_def.get("slope_ratio", 1.5)

            colors = material_colors.get(render_mode)
            if not colors:
                continue

            for geom in layer_data["geometries"]:
                try:
                    if geom["type"] == "polyline" and len(geom["points"]) >= 2:
                        pts_world = geom["points"]

                        if render_mode == "path_extrude":
                            # 도로: 중심선 기준으로 도로면 + 사면 그리기
                            half_w = width / 2.0
                            slope_w = half_w * slope_ratio
                            total_w = half_w + slope_w

                            # 사면 (바깥쪽) 먼저 그리기
                            slope_pixels = self._offset_polyline_to_pixels(
                                pts_world, total_w, world_to_pixel)
                            if slope_pixels:
                                draw.polygon(slope_pixels, fill=colors["slope"])

                            # 도로면 (안쪽)
                            road_pixels = self._offset_polyline_to_pixels(
                                pts_world, half_w, world_to_pixel)
                            if road_pixels:
                                draw.polygon(road_pixels, fill=colors["road"])

                            drawn_count += 1

                        elif render_mode in ("surface_overlay", "wall_extrude", "box_extrude"):
                            if geom.get("closed", False) and len(pts_world) >= 3:
                                pixels = [world_to_pixel(p[0], p[1]) for p in pts_world]
                                draw.polygon(pixels, fill=colors["fill"])
                                drawn_count += 1

                    elif geom["type"] == "line":
                        if render_mode == "path_extrude":
                            pts_world = [geom["start"], geom["end"]]
                            half_w = width / 2.0
                            road_pixels = self._offset_polyline_to_pixels(
                                pts_world, half_w, world_to_pixel)
                            if road_pixels:
                                draw.polygon(road_pixels, fill=colors.get("road", (60, 60, 65, 220)))
                            drawn_count += 1

                except Exception:
                    continue

        if drawn_count > 0:
            # 아스팔트 노이즈 추가 (도로면에 미세한 질감)
            noise_layer = self._generate_asphalt_noise(img_w, img_h)
            overlay = Image.alpha_composite(overlay, noise_layer)

            # 약간 블러로 경계 자연스럽게
            overlay = overlay.filter(ImageFilter.GaussianBlur(radius=1.5))

            # 위성 이미지와 합성
            result = Image.alpha_composite(img, overlay)
            self.log(f"    재질 합성: {drawn_count}개 요소 적용됨")
            return result.convert("RGB")

        return satellite_img

    def _offset_polyline_to_pixels(self, pts_world, half_width, world_to_pixel):
        """폴리라인을 양쪽으로 오프셋하여 폐합 폴리곤 픽셀 좌표 반환"""
        import math

        pts = np.array(pts_world, dtype=np.float64)
        n = len(pts)
        if n < 2:
            return None

        left_pts = []
        right_pts = []

        for i in range(n):
            if i == 0:
                dx, dy = pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]
            elif i == n - 1:
                dx, dy = pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]
            else:
                dx, dy = pts[i+1][0] - pts[i-1][0], pts[i+1][1] - pts[i-1][1]

            length = max(math.sqrt(dx*dx + dy*dy), 1e-6)
            nx, ny = -dy / length * half_width, dx / length * half_width

            left_pts.append(world_to_pixel(pts[i][0] + nx, pts[i][1] + ny))
            right_pts.append(world_to_pixel(pts[i][0] - nx, pts[i][1] - ny))

        # 좌 → 우(역순) 으로 폐합 폴리곤
        polygon = left_pts + right_pts[::-1]

        # 유효한 폴리곤인지 확인
        if len(polygon) < 3:
            return None

        return polygon

    def _generate_asphalt_noise(self, width, height):
        """아스팔트 표면의 미세한 노이즈 패턴 생성"""
        # 랜덤 노이즈로 아스팔트 질감
        noise = np.random.randint(0, 15, (height, width), dtype=np.uint8)
        noise_img = Image.fromarray(noise)

        # RGBA로 변환 (매우 약한 흰색 노이즈)
        noise_rgba = Image.new("RGBA", (width, height), (0, 0, 0, 0))
        noise_data = np.array(noise_rgba)
        noise_arr = np.array(noise_img)

        # 노이즈를 알파 채널에만 적용 (밝은 반점)
        noise_data[:, :, 0] = 200  # R
        noise_data[:, :, 1] = 200  # G
        noise_data[:, :, 2] = 200  # B
        noise_data[:, :, 3] = noise_arr // 3  # 매우 약한 알파

        return Image.fromarray(noise_data)

    def _on_tile_source_changed(self, source_name):
        """타일 소스 변경 시 지도 뷰어 타일 서버도 갱신"""
        template = self.tile_servers.get(source_name, "")

        # Vworld: API 키 치환
        if "vworld_key" in template:
            vk = self.vworld_api_key.get()
            if not vk:
                messagebox.showinfo("Vworld API Key",
                    "Vworld 지도를 사용하려면 API Key가 필요합니다.\n"
                    "사이드바에 Vworld API Key를 입력해주세요.\n"
                    "(vworld.kr 에서 무료 가입 후 발급)")
                return
            template = template.replace("{vworld_key}", vk)

        # tkintermapview용 URL (서브도메인 고정)
        viewer_url = template.replace("{s}", "0")
        self.map_view.set_tile_server(viewer_url)
        self.log(f"타일 소스 변경: {source_name}")

    # --- Step 1: TIN 생성 로직 ---
    def btn_tin_callback(self):
        file_path = filedialog.askopenfilename(
            title="DXF 파일 선택 (등고선 + 계획도)",
            filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")]
        )
        if not file_path: return

        self.dxf_path = file_path
        self.log(f">>> [Step 1] DXF 로드: {os.path.basename(file_path)}")
        self.set_status("DXF 분석 중...", "#F1C40F")

        # 진단 로그 초기화 (세션 시작)
        self._diag(f"=== Step 1 시작: {file_path} ===", reset=True)

        try:
            # DXF 문서 로드 및 저장
            self.dxf_doc = ezdxf.readfile(file_path)

            # 레이어 목록 로그
            layer_names = sorted(set(l.dxf.name for l in self.dxf_doc.layers))
            self.log(f"  레이어 {len(layer_names)}개 발견: {', '.join(layer_names[:10])}{'...' if len(layer_names) > 10 else ''}")
            self._diag(f"DXF 레이어 총 {len(layer_names)}개: {layer_names}")

            # 레이어 분류 팝업 (Phase 4)
            self.log("  레이어 분류 UI 표시 중...")
            classified = self._open_layer_classifier()

            if not classified:
                self.log("  레이어 분류 취소. 전체를 지형으로 처리합니다.")
                self.layer_mapping = {ln: "terrain" for ln in layer_names}
                self._diag("분류 취소됨 → 전체를 terrain으로 fallback")

            # 지형 레이어만 필터링하여 TIN 생성
            terrain_layers = [ln for ln, tid in self.layer_mapping.items() if tid == "terrain"]
            self.log(f"  지형 레이어: {terrain_layers}")

            self.tin_mesh = self.create_tin_from_dxf(file_path, terrain_layers)
            # TIN 재생성 시 기존 확장/텍스처 캐시 무효화
            self.total_mesh = None
            self.tin_extension_mesh = None
            self.tin_extension_textured = None
            # core 선택도 리셋 — 새 TIN 에서는 좌표/측점 분포가 달라지므로 재선택 필요
            self.tin_core_bbox = None
            self._tin_core_original_points = None

            if self.tin_mesh:
                z_range = self.tin_mesh.bounds[5] - self.tin_mesh.bounds[4]
                self.log(f"TIN 생성 완료. (정점: {self.tin_mesh.n_points:,}개, 편차: {z_range:.2f}m)")
                self._diag(f"TIN: 정점 {self.tin_mesh.n_points}, Z편차 {z_range:.2f}m")

                # 계획선 지오메트리 추출 (Phase 4)
                n_plan = self._extract_layer_geometries()
                total_geoms = sum(len(v["geometries"]) for v in self.layer_geometries.values()) if self.layer_geometries else 0
                if n_plan:
                    self.log(f"  계획선 추출: {n_plan}개 레이어, {total_geoms}개 요소")

                # 구조물 위치 레지스트리 구축 (항상 호출 — 진단 로그 완결성 확보)
                self._populate_structure_registry()

                # 사용자가 구조물 레이어를 지정했는데 레지스트리가 비어있으면 경고
                structure_layers_selected = [
                    (ln, tid) for ln, tid in self.layer_mapping.items()
                    if tid not in ("terrain", "ignore")
                ]
                if structure_layers_selected and not self.structure_registry:
                    reasons = []
                    for ln, tid in structure_layers_selected:
                        if ln not in self.layer_geometries:
                            reasons.append(f"  • {ln} ({tid}): 지원 엔티티 없음 (빈 레이어 또는 TEXT/DIM만 존재)")
                        else:
                            rmode = self.structure_types.get(tid, {}).get("render_mode", "?")
                            reasons.append(f"  • {ln} ({tid}, render_mode={rmode}): 지형변형으로 처리됨 (별도 상세빌드 대상 아님)")
                    msg = (
                        f"{len(structure_layers_selected)}개 구조물 레이어가 지정되었으나 "
                        f"'구조물 상세 3D 빌드' 대상이 0개입니다.\n\n" + "\n".join(reasons[:8]) +
                        f"\n\n상세 진단: {self.diag_log_path.resolve()}"
                    )
                    self._diag("⚠ 구조물 레이어 지정 있음 + 레지스트리 비어있음 → 사용자 경고 표시")
                    messagebox.showwarning("구조물 등록 경고", msg)

                if n_plan:
                    # 고도 설정 팝업 → TIN 변형
                    self.log("  계획선 고도 설정 중...")
                    if self._open_elevation_dialog():
                        self._deform_tin_for_plans()

                self.update_map_view_to_mesh()
                reg_n = len(self.structure_registry)
                self.set_status(f"TIN 생성 완료 · 구조물 등록 {reg_n}개", "#2ECC71")
                self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)

                self.show_3d_preview(textured=False)
        except Exception as e:
            self.log(f"오류: {e!s}")
            self.set_status("오류 발생", "#E74C3C")
            messagebox.showerror("오류", f"TIN 생성 중 문제가 발생했습니다:\n{e}")

    def create_tin_from_dxf(self, filepath, terrain_layers=None):
        """DXF에서 지형 레이어만 필터링하여 TIN 생성.
        terrain_layers가 None이면 모든 레이어 사용 (이전 동작).
        """
        doc = self.dxf_doc if self.dxf_doc else ezdxf.readfile(filepath)
        msp = doc.modelspace()
        points = []
        entity_counts = {}

        def _in_terrain(entity):
            if terrain_layers is None:
                return True
            return entity.dxf.layer in terrain_layers

        def _count(etype):
            entity_counts[etype] = entity_counts.get(etype, 0) + 1

        # 1. LWPOLYLINE
        for entity in msp.query('LWPOLYLINE'):
            if not _in_terrain(entity): continue
            _count('LWPOLYLINE')
            z = entity.dxf.elevation if entity.dxf.hasattr('elevation') else 0.0
            points.extend([p[0], p[1], z] for p in entity.get_points())
        # 2. LINE — _count side-effect로 inner extend
        for entity in msp.query('LINE'):
            if not _in_terrain(entity): continue
            _count('LINE')
            points.append([entity.dxf.start.x, entity.dxf.start.y, entity.dxf.start.z])
            points.append([entity.dxf.end.x, entity.dxf.end.y, entity.dxf.end.z])
        # 3. POLYLINE: 3D 폴리선일 경우 각 vertex의 Z값 반영
        for entity in msp.query('POLYLINE'):
            if not _in_terrain(entity): continue
            _count('POLYLINE')
            for vertex in entity.vertices:
                loc = vertex.dxf.location
                points.append([loc.x, loc.y, loc.z])
        # 4. POINT
        for entity in msp.query('POINT'):
            if not _in_terrain(entity): continue
            _count('POINT')
            points.append([entity.dxf.location.x, entity.dxf.location.y, entity.dxf.location.z])
        # 5. SPLINE (추가)
        for entity in msp.query('SPLINE'):
            if not _in_terrain(entity): continue
            _count('SPLINE')
            with contextlib.suppress(Exception):
                points.extend([pt[0], pt[1], pt[2] if len(pt) > 2 else 0.0]
                              for pt in entity.control_points)
        # 6. 3DFACE (추가)
        for entity in msp.query('3DFACE'):
            if not _in_terrain(entity): continue
            _count('3DFACE')
            for attr in ['vtx0', 'vtx1', 'vtx2', 'vtx3']:
                try:
                    v = getattr(entity.dxf, attr)
                    points.append([v.x, v.y, v.z])
                except Exception:
                    pass

        # 디버그: 지형 레이어에서 발견된 엔티티 타입별 개수 로그
        if terrain_layers:
            self.log(f"  지형 레이어 엔티티: {entity_counts if entity_counts else '없음'}")

            # 지형 레이어에 어떤 엔티티가 있는지 전수 조사
            if not entity_counts:
                all_types = {}
                for entity in msp:
                    if entity.dxf.layer in terrain_layers:
                        et = entity.dxftype()
                        all_types[et] = all_types.get(et, 0) + 1
                self.log(f"  지형 레이어 전체 엔티티 타입: {all_types if all_types else '엔티티 없음'}")

                if all_types:
                    self.log("  ⚠ 지원되지 않는 엔티티 타입입니다. 다른 레이어를 '지형'으로 지정해보세요.")
                    # HATCH가 있으면 안내
                    if 'HATCH' in all_types:
                        self.log(f"  ⚠ HATCH({all_types['HATCH']}개) 발견 — 등고선이 HATCH가 아닌 LWPOLYLINE/POLYLINE 레이어에 있을 수 있습니다.")

        if not points:
            # 상세 에러 메시지
            if terrain_layers:
                raise ValueError(
                    f"지형 레이어 {terrain_layers}에서 좌표 데이터를 찾지 못했습니다.\n"
                    f"발견된 엔티티 타입: {entity_counts}\n"
                    f"레이어 분류에서 등고선이 포함된 올바른 레이어를 '지형'으로 지정해 주세요.\n"
                    f"팁: 등고선은 보통 LWPOLYLINE 또는 POLYLINE 타입입니다."
                )
            raise ValueError("DXF에서 좌표 데이터를 찾지 못했습니다.")

        pts = np.array(points)
        pts = np.unique(pts, axis=0)

        # XY 중복 정리 — 같은 (X,Y)에 Z가 여러 개면 **최저 Z만 유지**.
        # 원인: 등고선(LWPOLYLINE) + 스포트/3D 폴리선이 동일 지점에 서로 다른 Z로 찍히거나
        #      도로 계획면과 원지형 등고선이 같은 XY에 겹쳐 있음. Delaunay가 어떤 Z를 택하든
        #      인접 삼각형과 Z 점프가 발생해 **수직 벽**으로 렌더됨(특히 위성 텍스처 합성 후).
        # 최저 Z를 택하는 이유: 원지형(지면) 우선 + 계획고가 그 위에 쌓이는 구조이므로
        #      바닥 면을 유지해야 벽이 안 생김. 필요하면 계획고는 structure_registry/overlay로 따로 반영.
        try:
            # 6자리 반올림으로 수치 오차를 흡수한 key 생성
            keys_xy = np.round(pts[:, :2], 3)
            # lexsort로 같은 XY 묶음 안에서 Z 오름차순 정렬 → 각 그룹 첫 번째가 최저 Z
            order = np.lexsort((pts[:, 2], keys_xy[:, 1], keys_xy[:, 0]))
            pts_sorted = pts[order]
            keys_sorted = keys_xy[order]
            # 같은 XY 그룹의 첫 행만 유지
            diff_mask = np.ones(len(pts_sorted), dtype=bool)
            diff_mask[1:] = np.any(keys_sorted[1:] != keys_sorted[:-1], axis=1)
            pts_dedup = pts_sorted[diff_mask]
            n_collapsed = len(pts) - len(pts_dedup)
            if n_collapsed > 0:
                self.log(f"  동일 XY 중복 점 {n_collapsed}개 통합 (최저 Z 유지) — "
                         f"Z 불일치로 생기던 수직 벽 제거")
            pts = pts_dedup
        except Exception as _de:
            self.log(f"  XY 중복 정리 경고: {_de}")

        # 원본 좌표계 바운딩 박스 계산 및 저장
        self.projected_bounds = [np.min(pts[:, 0]), np.min(pts[:, 1]), np.max(pts[:, 0]), np.max(pts[:, 1])]

        # ========================================================================
        # [TIN 삼각망 조밀화 — Phase A: 경계 gap densify, Phase B: 내부 긴 edge densify]
        #   측점(측량)은 정확하니 원본 100% 보존. DEM은 보조적으로만 사용:
        #     - Phase A: bbox 4변에서 인접 측점 gap > 50m → 그 사이에 DEM 샘플 점 추가.
        #     - Phase B: 임시 Delaunay 수행 → edge_max > 50m 삼각형 중심에 DEM 샘플 추가.
        #   결과: 삼각망 간격이 50m 이내로 조밀 → 경계 sliver·내부 긴 edge로 인한
        #          수직 벽이 시각적으로 소멸 + DEM 확장 시 seam도 매끄러움.
        # ========================================================================
        try:
            if DEM_EXTENDER_AVAILABLE:
                _gap_thresh = 50.0        # Phase A: 경계 gap 기준
                _long_edge_thresh = 50.0  # Phase B: 내부 edge 기준
                x0_abs = float(self.projected_bounds[0]); x1_abs = float(self.projected_bounds[2])
                y0_abs = float(self.projected_bounds[1]); y1_abs = float(self.projected_bounds[3])
                bbox_tol_abs = max(x1_abs - x0_abs, y1_abs - y0_abs) * 1e-4 + 1e-3

                # DEM 타일/변환기를 Phase A·B 공통으로 1회 준비 (cache 재사용)
                src_crs = self.crs_option.get()
                to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
                margin = 100.0
                cx_arr = np.array([x0_abs - margin, x1_abs + margin, x0_abs - margin, x1_abs + margin])
                cy_arr = np.array([y0_abs - margin, y0_abs - margin, y1_abs + margin, y1_abs + margin])
                cx_lon, cx_lat = to_wgs.transform(cx_arr, cy_arr)
                min_lat, max_lat = float(np.min(cx_lat)), float(np.max(cx_lat))
                min_lon, max_lon = float(np.min(cx_lon)), float(np.max(cx_lon))
                elev_grid, grid_bounds = fetch_terrarium_grid(
                    min_lat, min_lon, max_lat, max_lon,
                    zoom=13, cache_dir=str(cache_dir("dem")), log_fn=self.log,
                )
                # vertical datum offset: 측점 전체 XY의 DEM vs TIN 차이 median
                s_lons, s_lats = to_wgs.transform(pts[:, 0], pts[:, 1])
                s_dem_z = _sample_grid_bilinear(
                    elev_grid, grid_bounds,
                    np.asarray(s_lats), np.asarray(s_lons))
                fin = np.isfinite(s_dem_z)
                offset_v = float(np.median(s_dem_z[fin] - pts[fin, 2])) if fin.any() else 0.0
                # **통일 offset** — 이후 _fill_tin_bbox_gap_with_dem / build_extended_terrain_ring
                #  이 값을 그대로 재사용해 bbox 내/외 DEM Z를 **동일 datum**으로 정렬.
                #  서로 다른 offset 을 3번 계산하면 bbox 에서 Z 단차가 발생해 네모박스 크리스
                #  가 보임. 통일하면 seam 이 자연스럽게 이어짐.
                self._dem_datum_offset = offset_v
                self._dem_elev_grid = elev_grid
                self._dem_grid_bounds = grid_bounds
                self.log(f"  [TIN densify] DEM datum offset={offset_v:+.2f}m "
                         f"(self._dem_datum_offset에 저장 → Step 1.5에서 재사용)")
                # 좌표계 진단 — 사용자 검증용
                try:
                    _mid_x = 0.5 * (x0_abs + x1_abs); _mid_y = 0.5 * (y0_abs + y1_abs)
                    _to_p = pyproj.Transformer.from_crs("EPSG:4326", src_crs, always_xy=True)
                    _lon_m, _lat_m = to_wgs.transform(_mid_x, _mid_y)
                    _rx, _ry = _to_p.transform(_lon_m, _lat_m)
                    _rt = float(((_rx - _mid_x) ** 2 + (_ry - _mid_y) ** 2) ** 0.5)
                    self.log(f"  [CRS 검증] DXF={src_crs} → WGS84(DEM 샘플) → {src_crs}. "
                             f"per-point 점변환이라 **XY 평면 warp/resample 왜곡 없음**. "
                             f"왕복 오차 {_rt:.4f}m (0에 가까움 = 변환 정확).")
                except Exception:
                    pass

                def _dem_sample_minus_offset(xy_abs):
                    _lons, _lats = to_wgs.transform(xy_abs[:, 0], xy_abs[:, 1])
                    _z = _sample_grid_bilinear(
                        elev_grid, grid_bounds,
                        np.asarray(_lats), np.asarray(_lons))
                    if np.any(np.isnan(_z)):
                        _m = float(np.nanmedian(_z))
                        _z = np.where(np.isnan(_z), _m, _z)
                    return _z - offset_v

                # --- Phase A: bbox 4변 gap densify ---
                edges_spec = [
                    ('bottom', np.abs(pts[:, 1] - y0_abs) < bbox_tol_abs, 0, 1, y0_abs),
                    ('top',    np.abs(pts[:, 1] - y1_abs) < bbox_tol_abs, 0, 1, y1_abs),
                    ('left',   np.abs(pts[:, 0] - x0_abs) < bbox_tol_abs, 1, 0, x0_abs),
                    ('right',  np.abs(pts[:, 0] - x1_abs) < bbox_tol_abs, 1, 0, x1_abs),
                ]
                new_xy_list = []
                for label, emask, sort_col, fixed_col, fixed_val in edges_spec:
                    n_on = int(emask.sum())
                    if n_on >= 2:
                        idx = np.where(emask)[0]
                        order = np.argsort(pts[idx, sort_col])
                        main_coord = pts[idx[order], sort_col]
                        gaps = np.diff(main_coord)
                        for k, gap in enumerate(gaps):
                            if gap > _gap_thresh:
                                n_add = int(np.ceil(gap / _gap_thresh)) - 1
                                mid_vals = np.linspace(main_coord[k], main_coord[k+1], n_add + 2)[1:-1]
                                for mv in mid_vals:
                                    pt2 = [0.0, 0.0]
                                    pt2[sort_col] = float(mv)
                                    pt2[fixed_col] = float(fixed_val)
                                    new_xy_list.append(pt2)
                    else:
                        span = (x1_abs - x0_abs) if sort_col == 0 else (y1_abs - y0_abs)
                        if span > _gap_thresh:
                            n_add = int(np.ceil(span / _gap_thresh)) - 1
                            base0 = x0_abs if sort_col == 0 else y0_abs
                            base1 = x1_abs if sort_col == 0 else y1_abs
                            mid_vals = np.linspace(base0, base1, n_add + 2)[1:-1]
                            for mv in mid_vals:
                                pt2 = [0.0, 0.0]
                                pt2[sort_col] = float(mv)
                                pt2[fixed_col] = float(fixed_val)
                                new_xy_list.append(pt2)

                n_phase_a = len(new_xy_list)
                if n_phase_a > 0:
                    new_xy = np.asarray(new_xy_list, dtype=np.float64)
                    new_z = _dem_sample_minus_offset(new_xy)
                    pts = np.vstack([pts, np.column_stack([new_xy, new_z])])
                    self.log(
                        f"  [Phase A] bbox gap densify: {n_phase_a}개 추가 "
                        f"(간격>{_gap_thresh:.0f}m)")
                else:
                    self.log(f"  [Phase A] bbox gap densify: skip (모두 ≤ {_gap_thresh:.0f}m)")

                # --- Phase C: hull 바깥 × bbox 내부 grid densify (10m → 1m 점진) ---
                # 10m 격자부터 시작, 각 step마다 현재 hull을 재계산해 아직 비어있는
                # 구간에 격자점 추가. bbox가 좁거나 잔여 빈 영역이 10m 격자로 안
                # 메워지면 step을 1m씩 줄여(9m, 8m, …, 1m) 재시도. 목표: bbox 꽉 채움.
                try:
                    from scipy.spatial import ConvexHull as _ConvexHull, cKDTree as _cKDTreeC
                    from matplotlib.path import Path as _MplPath
                    total_phase_c = 0
                    steps_log = []
                    for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
                        try:
                            hull_c = _ConvexHull(pts[:, :2])
                        except Exception:
                            break
                        hull_poly_xy = pts[hull_c.vertices, :2]
                        hull_path_c = _MplPath(hull_poly_xy, closed=True)
                        gx = np.arange(x0_abs, x1_abs + _step * 0.5, _step)
                        gy = np.arange(y0_abs, y1_abs + _step * 0.5, _step)
                        ggx, ggy = np.meshgrid(gx, gy)
                        grid_xy_c = np.column_stack([ggx.ravel(), ggy.ravel()])
                        inside_bbox = (
                            (grid_xy_c[:, 0] >= x0_abs - 1e-6)
                            & (grid_xy_c[:, 0] <= x1_abs + 1e-6)
                            & (grid_xy_c[:, 1] >= y0_abs - 1e-6)
                            & (grid_xy_c[:, 1] <= y1_abs + 1e-6)
                        )
                        grid_xy_c = grid_xy_c[inside_bbox]
                        if len(grid_xy_c) == 0:
                            continue
                        inside_hull = hull_path_c.contains_points(grid_xy_c)
                        outside_hull_xy = grid_xy_c[~inside_hull]
                        if len(outside_hull_xy) == 0:
                            continue
                        # 기존 점과 너무 가까운 격자점(≤ step×0.4) 제외 — 중복 방지
                        tree_ex = _cKDTreeC(pts[:, :2])
                        d_ex, _ = tree_ex.query(outside_hull_xy, k=1)
                        new_only_xy = outside_hull_xy[d_ex > _step * 0.4]
                        if len(new_only_xy) == 0:
                            continue
                        new_z_c = _dem_sample_minus_offset(new_only_xy)
                        pts = np.vstack([pts, np.column_stack([new_only_xy, new_z_c])])
                        total_phase_c += len(new_only_xy)
                        steps_log.append(f"{_step:.0f}m:{len(new_only_xy)}")
                    if total_phase_c > 0:
                        self.log(
                            f"  [Phase C] hull 바깥 × bbox 내부 점진 densify: "
                            f"총 {total_phase_c}개 추가 [{', '.join(steps_log)}] — bbox 꽉 채움")
                    else:
                        self.log("  [Phase C] hull이 이미 bbox 덮음 — skip")
                except Exception as _ce:
                    self.log(f"  [Phase C] 경고: {_ce}")

                # --- Phase B: 내부 긴 edge densify (임시 Delaunay → centroid 추가) ---
                tri_tmp = Delaunay(pts[:, :2])
                sx_tmp = tri_tmp.simplices
                p0t = pts[sx_tmp[:, 0], :2]
                p1t = pts[sx_tmp[:, 1], :2]
                p2t = pts[sx_tmp[:, 2], :2]
                e_max_tmp = np.maximum(
                    np.maximum(np.linalg.norm(p0t - p1t, axis=1),
                               np.linalg.norm(p1t - p2t, axis=1)),
                    np.linalg.norm(p2t - p0t, axis=1))
                long_mask = e_max_tmp > _long_edge_thresh
                n_phase_b = int(long_mask.sum())
                if n_phase_b > 0:
                    cc_xy = (p0t[long_mask] + p1t[long_mask] + p2t[long_mask]) / 3.0
                    cc_z = _dem_sample_minus_offset(cc_xy)
                    pts = np.vstack([pts, np.column_stack([cc_xy, cc_z])])
                    self.log(
                        f"  [Phase B] 내부 긴 edge densify: {n_phase_b}개 삼각형 중심 추가 "
                        f"(edge>{_long_edge_thresh:.0f}m) — 삼각망 조밀화")
                else:
                    self.log("  [Phase B] 긴 edge 없음 — skip")
        except Exception as _gde:
            self.log(f"  TIN DEM densify 경고: {_gde}")

        # Zero-Basing (시각화용) — densify로 추가된 점도 포함
        self.origin = np.min(pts, axis=0)
        pts -= self.origin

        self.log(f"원점 보정: {self.origin}")

        # ========================================================================
        # [bbox 4변 종잇장 처리 — 제거됨 (2026-04-23)]
        #   이전: 변 정점 Z를 win=5 moving-avg 3pass로 평활 + 30m 안쪽 feather 블렌드.
        #   문제: 평활된 변 Z vs 30m 안쪽 자연 DEM Z의 경계에 얇은 **30m 네모박스**
        #        크리스(crease) 가 시각적으로 남음 (error.png). 그리고 Step 1.5의
        #        _reinterpolate_tin_boundary_with_dem이 또 다른 feather_m(~200m)에서
        #        Laplacian 평활을 하므로, 두 평활 링이 서로 다른 거리에서 내부 박스
        #        처럼 보임.
        #   해결: 여기서 인위적 평활을 하지 않는다. Phase A가 이미 bbox 변에 DEM
        #        샘플 점을 50m 간격으로 채웠고, Phase C가 1m까지 bbox 내부를 DEM으로
        #        채우므로 변/내부 Z 모두 **동일한 DEM 기준**이라 자연스럽게 이어짐.
        #        수직 벽 방지는 뒤의 v6 slope_ratio 컷이 담당.
        # ========================================================================
        self.log("  TIN bbox 종잇장 평활 skip — Phase A/C의 DEM 일관성으로 자연 이어짐")

        tri = Delaunay(pts[:, :2])
        simplices = tri.simplices

        # ========================================================================
        # [경계 벽 컷 v6] — slope_ratio 기반. 절대 edge 기준 v5 폐기.
        #   사용자 요구(2026-04-23): 처음 TIN 만들 때부터 bbox 내부에 구멍이
        #   **없어야 함**, 동시에 벽도 없어야 함, 4변까지 꽉 찬 TIN이 되어야 함.
        #
        #   v5 문제: `edge > median × 4.0` 절대 기준으로 컷 → 정상 산지 경사면
        #   (z_span 크지만 xy_edge도 커서 slope_ratio 작음)이 함께 삭제돼
        #   bbox 내부에 듬성듬성 구멍이 났다 (error.png).
        #
        #   memory/feedback_wall_root_cause.md Rule 2 준수:
        #     "벽 판정은 slope_ratio = z_span / max_xy_edge 기준으로만.
        #      절대 Z/edge 기준은 정상 경사면까지 지워 구멍을 낸다."
        #
        #   v6 방침:
        #     1. Phase A(50m bbox edge) + Phase C(10→1m grid) + Phase B 완료 후
        #        convex hull ≈ bbox 상태 → Delaunay가 bbox 4변까지 꽉 참.
        #     2. 이 상태에서 컷 기준은 **진짜 벽만** 제거:
        #          `slope_ratio = z_span / max_xy_edge > 1.5` (≈56°)
        #        AND `z_span > 5m`
        #     3. bbox 4변 접촉 삼각형에만 적용. 내부는 불가침.
        #     4. 결과: 내부 구멍 없음 + 경계 수직 벽만 제거 + 4변 꽉 참.
        # ========================================================================
        x_min_z = 0.0; y_min_z = 0.0
        x_max_z = float(pts[:, 0].max()) if len(pts) else 0.0
        y_max_z = float(pts[:, 1].max()) if len(pts) else 0.0
        bbox_tol = max(x_max_z, y_max_z) * 1e-4 + 1e-3

        def _touches_bbox_vid(vid_array):
            xs = pts[vid_array, 0]
            ys = pts[vid_array, 1]
            return (
                (np.abs(xs - x_min_z) < bbox_tol)
                | (np.abs(xs - x_max_z) < bbox_tol)
                | (np.abs(ys - y_min_z) < bbox_tol)
                | (np.abs(ys - y_max_z) < bbox_tol)
            )

        if len(simplices) > 0:
            p0 = pts[simplices[:, 0], :2]
            p1 = pts[simplices[:, 1], :2]
            p2 = pts[simplices[:, 2], :2]
            e_max = np.maximum(
                np.maximum(np.linalg.norm(p0 - p1, axis=1),
                           np.linalg.norm(p1 - p2, axis=1)),
                np.linalg.norm(p2 - p0, axis=1))
            z0 = pts[simplices[:, 0], 2]
            z1 = pts[simplices[:, 1], 2]
            z2 = pts[simplices[:, 2], 2]
            z_span = np.maximum(np.maximum(z0, z1), z2) - np.minimum(np.minimum(z0, z1), z2)
            slope_ratio = z_span / np.maximum(e_max, 1e-6)

            touches = (_touches_bbox_vid(simplices[:, 0])
                       | _touches_bbox_vid(simplices[:, 1])
                       | _touches_bbox_vid(simplices[:, 2]))
            drop = touches & (slope_ratio > 1.5) & (z_span > 5.0)
            n_drop = int(drop.sum())
            if n_drop > 0:
                simplices = simplices[~drop]
                self.log(
                    f"  TIN 경계 벽 컷 v6: {n_drop}개 제거 "
                    f"(bbox 4변 접촉 + slope_ratio>1.5(≈56°) + z_span>5m) "
                    f"— 정상 경사면 100% 보존, 수직 벽만 제거")
            else:
                self.log(
                    "  TIN 경계 벽 컷 v6: 제거 대상 없음 "
                    "(4변 모든 접촉 삼각형 slope_ratio≤1.5 또는 z_span≤5m) "
                    "— bbox 4변까지 꽉 찬 TIN")

        faces = np.column_stack([np.full(len(simplices), 3), simplices])
        mesh = pv.PolyData(pts, faces)
        mesh["Elevation"] = pts[:, 2]
        self._tin_interpolator = None
        return mesh

    def update_map_view_to_mesh(self):
        """TIN 메쉬의 원래 좌표를 위경도로 변환하여 지도 중심 이동"""
        try:
            src_crs = self.crs_option.get()
            transformer = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)

            # 중심점 계산
            cx = (self.projected_bounds[0] + self.projected_bounds[2]) / 2
            cy = (self.projected_bounds[1] + self.projected_bounds[3]) / 2

            lon, lat = transformer.transform(cx, cy)
            self.map_view.set_position(lat, lon)
            self.map_view.set_zoom(15)
            self.log(f"지도 위치 동기화: Lat {lat:.5f}, Lon {lon:.5f}")
        except Exception as e:
            self.log(f"지도 업데이트 실패: {e}")

    # --- [TIN 이용 범위] 정밀 TIN 구역 선택 ---
    def btn_select_core_range_callback(self):
        """Toplevel 탑뷰 창에서 좌상단·우하단 2클릭 또는 드래그박스로
        **정밀 TIN 구역(core bbox)** 을 선택. 선택 후 Step 1.5 가 3-zone
        (core 100% TIN / transition smoothstep / 바깥 DEM) 로 동작.

        사용 이유: 도면 전체가 아니라 **사용자가 정확히 필요한 일부 영역만**
        원본 측점 그대로 유지하고, 나머지는 DEM 으로 부드럽게 확장해 경계/외곽의
        측점 희박 구간에서 생기던 튀는 Z·fin 을 원천 차단.
        """
        if self.tin_mesh is None or self.projected_bounds is None:
            messagebox.showwarning("주의", "먼저 Step 1 (TIN 생성) 을 실행하세요.")
            return
        try:
            import tkinter as _tk
            from matplotlib.figure import Figure
            from matplotlib.backends.backend_tkagg import (
                FigureCanvasTkAgg, NavigationToolbar2Tk)
            from matplotlib.patches import Rectangle as _MplRect
        except ImportError as _e:
            messagebox.showerror("오류", f"UI 모듈 로드 실패: {_e}")
            return

        origin = np.asarray(self.origin, dtype=np.float64)
        pts_zero = np.asarray(self.tin_mesh.points, dtype=np.float64)
        pts_abs = pts_zero + origin
        x0p, y0p, x1p, y1p = [float(v) for v in self.projected_bounds]

        win = ctk.CTkToplevel(self)
        win.title("🎯 TIN 이용 범위 선택")
        win.geometry("1100x920")
        win.minsize(900, 640)
        win.transient(self)
        win.grab_set()

        info_frame = ctk.CTkFrame(win)
        info_frame.pack(side="top", fill="x", padx=10, pady=6)
        instr_lbl = ctk.CTkLabel(
            info_frame,
            text="🖱  마우스 좌클릭 드래그로 정밀 TIN 사각형을 그리세요 (놓으면 확정).",
            font=ctk.CTkFont(size=14, weight="bold"))
        instr_lbl.pack(side="top", anchor="w", padx=6, pady=(4, 2))
        stat_lbl = ctk.CTkLabel(info_frame, text="선택 없음", font=ctk.CTkFont(size=12))
        stat_lbl.pack(side="top", anchor="w", padx=6, pady=(0, 4))

        # blend_width 슬라이더
        slide_frame = ctk.CTkFrame(win)
        slide_frame.pack(side="top", fill="x", padx=10, pady=(0, 6))
        ctk.CTkLabel(slide_frame, text="전이대 폭(blend_width_m):").pack(side="left", padx=6)
        blend_var = _tk.DoubleVar(value=float(self.tin_blend_width_m))
        blend_val_lbl = ctk.CTkLabel(slide_frame, text=f"{blend_var.get():.0f} m",
                                      width=60)
        blend_val_lbl.pack(side="left", padx=6)
        def _on_blend_change(v):
            blend_val_lbl.configure(text=f"{float(v):.0f} m")
            _redraw_blend()
        blend_slider = ctk.CTkSlider(
            slide_frame, from_=20, to=300, number_of_steps=28,
            variable=blend_var, command=_on_blend_change)
        blend_slider.pack(side="left", fill="x", expand=True, padx=6)

        fig = Figure(figsize=(10, 7), dpi=100)
        ax = fig.add_subplot(111)
        ax.set_aspect("equal")
        sample = pts_abs if len(pts_abs) <= 30000 else pts_abs[
            np.random.RandomState(0).choice(len(pts_abs), 30000, replace=False)]
        sc = ax.scatter(sample[:, 0], sample[:, 1], c=sample[:, 2],
                        cmap=_TIN_EARTH_CMAP, s=2, alpha=0.85)
        fig.colorbar(sc, ax=ax, label="Elevation (m)")
        # 도면 bbox 표시
        ax.add_patch(_MplRect((x0p, y0p), x1p - x0p, y1p - y0p,
                              fill=False, edgecolor="black", linewidth=1.2,
                              linestyle="--", label="도면 bbox"))
        ax.set_xlabel("X (EPSG:5187 m)")
        ax.set_ylabel("Y (EPSG:5187 m)")
        ax.set_title("TIN elevation — 마우스 좌클릭 드래그로 사각형 영역 선택")

        # 하단 버튼 프레임을 캔버스보다 먼저 pack — 창이 좁아도 제출 버튼이 가려지지 않음.
        # pack 순서가 뒤쪽일수록 canvas(expand=True)가 먼저 공간을 잡아 하단이 잘려 보임.
        submit_frame = ctk.CTkFrame(win, fg_color="#0b1b10")
        submit_frame.pack(side="bottom", fill="x", padx=0, pady=0)
        btn_row = ctk.CTkFrame(win)
        btn_row.pack(side="bottom", fill="x", padx=10, pady=(6, 2))

        canvas = FigureCanvasTkAgg(fig, master=win)
        canvas.draw()
        # 툴바는 btn_row 바로 위(side=bottom, pack_toolbar=False 로 수동 배치)
        tb = NavigationToolbar2Tk(canvas, win, pack_toolbar=False)
        tb.update()
        tb.pack(side="bottom", fill="x")
        # 캔버스는 마지막에 — 남은 중앙 공간을 expand=True 로 채움
        canvas.get_tk_widget().pack(side="top", fill="both", expand=True, padx=10, pady=(0, 4))

        # 선택 상태
        state = {"core_rect": None, "blend_rect": None, "bbox": None}
        if self.tin_core_bbox is not None:
            state["bbox"] = tuple(self.tin_core_bbox)

        def _redraw_blend():
            if state["blend_rect"] is not None:
                state["blend_rect"].remove()
                state["blend_rect"] = None
            if state["bbox"] is None:
                canvas.draw_idle()
                return
            bw = float(blend_var.get())
            bx0, by0, bx1, by1 = state["bbox"]
            state["blend_rect"] = _MplRect(
                (bx0 - bw, by0 - bw), (bx1 - bx0) + 2 * bw, (by1 - by0) + 2 * bw,
                fill=False, edgecolor="#FFA726", linewidth=1.2, linestyle=":",
                label=f"전이대 +{bw:.0f}m")
            ax.add_patch(state["blend_rect"])
            canvas.draw_idle()

        def _draw_bbox():
            if state["core_rect"] is not None:
                state["core_rect"].remove()
                state["core_rect"] = None
            if state["bbox"] is None:
                return
            bx0, by0, bx1, by1 = state["bbox"]
            state["core_rect"] = _MplRect(
                (bx0, by0), bx1 - bx0, by1 - by0,
                fill=False, edgecolor="#E74C3C", linewidth=2.2, label="정밀 TIN core")
            ax.add_patch(state["core_rect"])
            # 통계 갱신
            in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1)
                       & (pts_abs[:, 1] >= by0) & (pts_abs[:, 1] <= by1))
            n_core = int(in_core.sum())
            cw, ch = bx1 - bx0, by1 - by0
            if n_core > 0:
                zc = pts_abs[in_core, 2]
                stat_lbl.configure(text=(
                    f"Core bbox: X=[{bx0:.1f}, {bx1:.1f}]  Y=[{by0:.1f}, {by1:.1f}] "
                    f"({cw:.0f}×{ch:.0f}m = {cw*ch/1e6:.3f} km²)  "
                    f"측점 {n_core:,}개  Z={zc.min():.1f}~{zc.max():.1f}m"))
            else:
                stat_lbl.configure(text="⚠ 선택 영역에 측점이 없습니다 — 다시 선택하세요.")
            _redraw_blend()
            canvas.draw_idle()

        # --- 실시간 드래그 핸들러 (press → motion → release) -------------
        drag = {"active": False, "start": None, "live_rect": None}

        def _clear_live():
            if drag["live_rect"] is not None:
                with contextlib.suppress(Exception):
                    drag["live_rect"].remove()
                drag["live_rect"] = None

        def _on_press(event):
            if event.inaxes != ax or event.button != 1:
                return
            if tb.mode:  # pan/zoom 모드면 무시
                return
            drag["active"] = True
            drag["start"] = (float(event.xdata), float(event.ydata))
            _clear_live()
            # 기존 확정 사각형도 제거 (새로 그리는 중)
            if state["core_rect"] is not None:
                state["core_rect"].remove()
                state["core_rect"] = None
            if state["blend_rect"] is not None:
                state["blend_rect"].remove()
                state["blend_rect"] = None
            state["bbox"] = None
            stat_lbl.configure(text="드래그 중…")
            canvas.draw_idle()

        def _on_motion(event):
            if not drag["active"] or event.inaxes != ax:
                return
            if event.xdata is None or event.ydata is None:
                return
            xa, ya = drag["start"]
            xb, yb = float(event.xdata), float(event.ydata)
            bx0, by0 = min(xa, xb), min(ya, yb)
            bx1, by1 = max(xa, xb), max(ya, yb)
            _clear_live()
            drag["live_rect"] = _MplRect(
                (bx0, by0), bx1 - bx0, by1 - by0,
                fill=True, facecolor="#E74C3C", alpha=0.18,
                edgecolor="#E74C3C", linewidth=2.0)
            ax.add_patch(drag["live_rect"])
            stat_lbl.configure(text=(
                f"드래그 중  {bx1-bx0:.0f}×{by1-by0:.0f} m  "
                f"X=[{bx0:.1f}, {bx1:.1f}] Y=[{by0:.1f}, {by1:.1f}]"))
            canvas.draw_idle()

        def _on_release(event):
            if not drag["active"]:
                return
            drag["active"] = False
            if event.inaxes != ax or event.xdata is None or event.ydata is None:
                _clear_live()
                stat_lbl.configure(text="드래그가 취소되었습니다. 다시 시도하세요.")
                canvas.draw_idle()
                return
            xa, ya = drag["start"]
            xb, yb = float(event.xdata), float(event.ydata)
            bx0, by0 = min(xa, xb), min(ya, yb)
            bx1, by1 = max(xa, xb), max(ya, yb)
            # 너무 작은 클릭-수준 드래그는 무시
            if (bx1 - bx0) < 1.0 or (by1 - by0) < 1.0:
                _clear_live()
                stat_lbl.configure(text="드래그 범위가 너무 작습니다. 다시 시도하세요.")
                canvas.draw_idle()
                return
            _clear_live()
            state["bbox"] = (bx0, by0, bx1, by1)
            _draw_bbox()

        canvas.mpl_connect("button_press_event", _on_press)
        canvas.mpl_connect("motion_notify_event", _on_motion)
        canvas.mpl_connect("button_release_event", _on_release)

        # 초기 bbox 미리 그리기 (이전 선택 복원)
        if state["bbox"] is not None:
            _draw_bbox()

        # --- 버튼 영역 (submit_frame / btn_row 프레임은 캔버스 위에서 이미 pack 됨) ---

        def _on_reset():
            state["bbox"] = None
            if state["core_rect"] is not None:
                state["core_rect"].remove()
                state["core_rect"] = None
            if state["blend_rect"] is not None:
                state["blend_rect"].remove()
                state["blend_rect"] = None
            _clear_live()
            stat_lbl.configure(text="선택 없음 — 다시 드래그하세요.")
            canvas.draw_idle()

        def _on_use_all():
            self.tin_core_bbox = None
            self.log("  [TIN 이용 범위] core_bbox 해제 — 전체 TIN 사용 (legacy 경로)")
            messagebox.showinfo("알림", "core 해제됨. Step 1.5 는 전체 TIN 으로 확장됩니다.")
            win.destroy()

        def _on_confirm():
            if state["bbox"] is None:
                messagebox.showwarning("주의", "먼저 영역을 선택하세요.")
                return
            bx0, by0, bx1, by1 = state["bbox"]
            # TIN bbox 로 클램프
            bx0 = max(bx0, x0p); by0 = max(by0, y0p)
            bx1 = min(bx1, x1p); by1 = min(by1, y1p)
            if (bx1 - bx0) < 20.0 or (by1 - by0) < 20.0:
                messagebox.showwarning("주의", "선택 영역이 너무 작습니다 (<20m).")
                return
            in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1)
                       & (pts_abs[:, 1] >= by0) & (pts_abs[:, 1] <= by1))
            if int(in_core.sum()) < 3:
                messagebox.showwarning("주의", "선택 영역에 측점이 부족합니다 (<3).")
                return
            # bbox 를 클램프된 값으로 최종 갱신 + 화면 반영
            state["bbox"] = (bx0, by0, bx1, by1)
            _draw_bbox()
            bw = float(blend_var.get())
            # blend_width 가 core 의 1/4 초과면 자동 제한
            max_bw = min(bx1 - bx0, by1 - by0) * 0.25
            if bw > max_bw:
                self.log(f"  [TIN 이용 범위] blend_width_m {bw:.0f} > core/4 "
                         f"({max_bw:.0f}) — 자동 제한")
                bw = max_bw
            self.tin_core_bbox = (bx0, by0, bx1, by1)
            self.tin_blend_width_m = float(bw)
            # 확정 시점의 원본 Z 백업 (core 복원·재적용 대비)
            self._tin_core_original_points = np.asarray(self.tin_mesh.points,
                                                         dtype=np.float64).copy()
            self.log(f"  [TIN 이용 범위] core=({bx0:.1f},{by0:.1f})~({bx1:.1f},{by1:.1f}), "
                     f"blend_width={bw:.0f}m 확정. Step 1.5에서 3-zone 블렌드 적용.")
            messagebox.showinfo("완료",
                f"정밀 TIN core 설정됨\n"
                f"• core: {bx1-bx0:.0f}×{by1-by0:.0f}m\n"
                f"• 전이대: ±{bw:.0f}m\n"
                f"이제 Step 1.5 를 실행하세요.")
            win.destroy()

        ctk.CTkButton(btn_row, text="🔄 재선택", command=_on_reset,
                      fg_color="#555", height=32).pack(side="left", padx=6)
        ctk.CTkButton(btn_row, text="전체 사용 (core 해제)", command=_on_use_all,
                      fg_color="#7F8C8D", height=32).pack(side="left", padx=6)
        ctk.CTkButton(btn_row, text="닫기",
                      command=win.destroy,
                      fg_color="#555", height=32).pack(side="right", padx=6)

        # 큰 초록 제출 바 (최하단 · 가장 강조)
        submit_btn = ctk.CTkButton(
            submit_frame,
            text="✅  선택 결과 제출 (이 범위를 정밀 TIN core 로 확정)",
            command=_on_confirm,
            height=56,
            fg_color="#2ECC71", hover_color="#27AE60",
            text_color="white",
            font=ctk.CTkFont(size=16, weight="bold"))
        submit_btn.pack(side="top", fill="x", padx=10, pady=10)
        # Enter 키로도 제출
        win.bind("<Return>", lambda _e: _on_confirm())
        win.bind("<Escape>", lambda _e: win.destroy())

    def _apply_core_precision_zone(self):
        """TIN 을 3-zone 구조로 재구성.

        - Core (self.tin_core_bbox 내부): 원본 측점 Z **그대로 유지**.
        - Transition (core 경계로부터 tin_blend_width_m 이내 외부): smoothstep
          가중치로 TIN Z ↔ DEM-aligned Z 블렌드 (경계 0% DEM, 폭 밖 100% DEM).
        - DEM zone (전이대 바깥): 완전히 DEM-aligned Z (self._dem_datum_offset 사용).

        XY 는 변경 안 함 (Delaunay 재계산 불필요). Z 만 덮어씀.
        self._tin_core_original_points 이 있으면 이를 기준으로 블렌드 (멱등성 보장).
        """
        if self.tin_core_bbox is None or self.tin_mesh is None:
            return
        elev_grid = getattr(self, "_dem_elev_grid", None)
        grid_bounds = getattr(self, "_dem_grid_bounds", None)
        if elev_grid is None or grid_bounds is None:
            self.log("  [3-zone] DEM 격자 없음 — TIN densify 를 먼저 실행해야 함. skip")
            return
        datum_ov = float(getattr(self, "_dem_datum_offset", 0.0) or 0.0)
        blend_w = float(self.tin_blend_width_m)
        origin = np.asarray(self.origin, dtype=np.float64)

        # 원본 Z 복원 (멱등) 또는 현재 Z 사용
        if self._tin_core_original_points is not None and \
                len(self._tin_core_original_points) == self.tin_mesh.n_points:
            base_zero = self._tin_core_original_points.copy()
        else:
            base_zero = np.asarray(self.tin_mesh.points, dtype=np.float64).copy()
            self._tin_core_original_points = base_zero.copy()

        pts_abs = base_zero + origin
        cx0, cy0, cx1, cy1 = [float(v) for v in self.tin_core_bbox]

        # L∞ (Chebyshev) signed distance to core bbox: 내부 음수, 외부 양수.
        # 코너 주변은 L2 distance 로 보정해 대각 방향도 smooth — _signed_distance_to_polygon 재사용.
        core_poly = np.array([
            [cx0, cy0], [cx1, cy0], [cx1, cy1], [cx0, cy1]
        ], dtype=np.float64)
        d = _signed_distance_to_polygon(pts_abs[:, :2], core_poly)  # 음수=core 내부

        # DEM 샘플 (CRS 변환 + bilinear)
        src_crs = self.crs_option.get()
        to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
        lons, lats = to_wgs.transform(pts_abs[:, 0], pts_abs[:, 1])
        z_dem_raw = _sample_grid_bilinear(
            elev_grid, grid_bounds,
            np.asarray(lats), np.asarray(lons))
        fin = np.isfinite(z_dem_raw)
        if not fin.all():
            med = float(np.nanmedian(z_dem_raw)) if fin.any() else 0.0
            z_dem_raw = np.where(fin, z_dem_raw, med)
        z_dem_aligned = z_dem_raw - datum_ov

        z_tin = pts_abs[:, 2]

        # smoothstep weight: d <= 0 (core) -> 0,  d >= blend_w -> 1
        t = np.clip(d / max(blend_w, 1e-6), 0.0, 1.0)
        w_dem = t * t * (3.0 - 2.0 * t)
        w_dem = np.where(d <= 0.0, 0.0, w_dem)  # core 내부 100% TIN
        z_new = (1.0 - w_dem) * z_tin + w_dem * z_dem_aligned

        # 갱신
        pts_new_zero = np.column_stack([base_zero[:, 0], base_zero[:, 1],
                                         z_new - origin[2]])
        self.tin_mesh.points = pts_new_zero
        self.tin_mesh["Elevation"] = pts_new_zero[:, 2]
        self._tin_interpolator = None

        n_core = int((d <= 0).sum())
        n_tran = int(((d > 0) & (d < blend_w)).sum())
        n_dem = int((d >= blend_w).sum())
        # core는 Z 불변 (dz_core=0); transition/outside만 평균 |ΔZ| 계산
        dz_tran = float(np.mean(np.abs(z_new[(d > 0) & (d < blend_w)] -
                                        z_tin[(d > 0) & (d < blend_w)]))) \
                  if n_tran > 0 else 0.0
        dz_out = float(np.mean(np.abs(z_new[d >= blend_w] - z_tin[d >= blend_w]))) \
                 if n_dem > 0 else 0.0
        self.log(
            f"  [3-zone] core {n_core:,}(Z 불변), transition {n_tran:,}(|ΔZ|평균={dz_tran:.2f}m), "
            f"DEM zone {n_dem:,}(|ΔZ|평균={dz_out:.2f}m) — blend={blend_w:.0f}m, "
            f"datum offset={datum_ov:+.2f}m")

    # --- Step 1.5: DEM으로 TIN 확장 + 경계 재보간 ---
    def btn_extend_tin_with_dem_callback(self):
        """Step 1에서 만든 TIN을 DEM으로 둘러싸고, **원본 도면 경계와 DEM이
        겹치는 구간은 DEM을 참값으로 보간**해 seam을 제거.

        프로세스:
            1. `build_extended_terrain_ring`으로 도넛형 DEM 확장 메시 생성
               (= `self.tin_extension_mesh`). `dem_extender`의 경계 ramp-down/hull
               override 로직은 그대로 사용.
            2. 원본 TIN 정점 중 bbox 경계에서 feather_m 이내인 정점의 Z를
               DEM 메시의 최근접 정점 Z로 smoothstep 블렌드(경계=100% DEM,
               feather_m 안쪽=100% TIN).
            3. Step 2(위성지도 결합)는 이 확장 결과를 재사용하며 추가 확장
               작업을 하지 않는다(중복 제거).
        """
        if not DEM_EXTENDER_AVAILABLE:
            messagebox.showerror("오류", "dem_extender 모듈을 사용할 수 없습니다.")
            return
        if not self.tin_mesh:
            messagebox.showwarning("주의", "먼저 TIN을 생성하세요 (Step 1).")
            return
        try:
            dem_buffer_m = max(0.0, float(self.dem_buffer_var.get() or "1000"))
        except ValueError:
            dem_buffer_m = 1000.0
        feather_m = max(150.0, dem_buffer_m * 0.2)
        src_crs = self.crs_option.get()

        self.set_status("DEM으로 TIN 확장 중...", "#F1C40F")
        self.log(f">>> [Step 1.5] DEM으로 TIN 확장 (buffer={dem_buffer_m:.0f}m, feather={feather_m:.0f}m)...")

        # [1.5-CORE] "TIN 이용 범위" 가 설정되어 있으면 **3-zone 블렌드** 선행.
        #  이후 링 확장의 inner 경계를 **core+blend** 로 설정해, core 는 원본 100%
        #  측점, transition 은 smoothstep TIN↔DEM, 그 바깥은 DEM 링으로 자연 연결.
        use_core = self.tin_core_bbox is not None
        if use_core:
            try:
                self._apply_core_precision_zone()
            except Exception as _ce:
                self.log(f"  [Step 1.5-CORE] 3-zone 블렌드 실패: {_ce} — legacy 경로 폴백")
                use_core = False

        # [1.5-a] bbox 내부 빈 공간을 DEM으로 먼저 채움 → TIN이 bbox로 꽉 참.
        #  core 모드에서는 skip — 전이대가 이미 DEM-aligned Z 를 채웠고, core 밖
        #  TIN 측점은 원본 XY 를 유지한 채 Z 만 DEM 으로 대체됐으므로 추가 densify 불필요.
        if use_core:
            self.log("  [Step 1.5-a] core 모드 — bbox gap 채움 skip (3-zone 블렌드로 대체)")
        else:
            try:
                self._fill_tin_bbox_gap_with_dem()
            except Exception as _fe:
                self.log(f"  [Step 1.5-a 빈공간 채움] 경고: {_fe}")

        try:
            tin_xyz = np.asarray(self.tin_mesh.points, dtype=np.float64)
            # **통일 offset + 격자 재사용** — create_tin_from_dxf에서 계산한
            # `self._dem_datum_offset`와 `self._dem_elev_grid/bounds`가 있으면
            # 그대로 넘겨 bbox seam에서 Z 단차 0을 보장. 없으면 override=None으로
            # 기존 경로(경계 근처 자동 보정).
            datum_ov = getattr(self, "_dem_datum_offset", None)
            elev_ov = getattr(self, "_dem_elev_grid", None)
            bounds_ov = getattr(self, "_dem_grid_bounds", None)
            if datum_ov is not None:
                self.log(f"  [Step 1.5-b] datum offset 재사용 {datum_ov:+.2f}m "
                         f"+ DEM 격자 재사용 → **동일 datum 보장**")
            # core 모드: 링 inner = core_bbox + blend_width (전이대 바깥)
            #  core → transition(TIN 안에서 smoothstep) → ring(DEM) 이 하나의 연속 표면.
            if use_core:
                cx0, cy0, cx1, cy1 = self.tin_core_bbox
                bw = float(self.tin_blend_width_m)
                ring_inner_bounds = (cx0 - bw, cy0 - bw, cx1 + bw, cy1 + bw)
                self.log(f"  [Step 1.5-b] core 모드: 링 inner = core+blend "
                         f"({(cx1-cx0)+2*bw:.0f}×{(cy1-cy0)+2*bw:.0f}m), "
                         f"외곽 buffer={dem_buffer_m:.0f}m")
            else:
                ring_inner_bounds = self.projected_bounds
            result = build_extended_terrain_ring(
                projected_bounds=ring_inner_bounds,
                origin=np.asarray(self.origin, dtype=np.float64),
                src_crs=src_crs,
                buffer_m=dem_buffer_m,
                tin_xyz_zerobased=tin_xyz,
                feather_m=feather_m,
                datum_offset_override=datum_ov,
                elev_grid_override=elev_ov,
                grid_bounds_override=bounds_ov,
                log_fn=self.log,
            )
            self.tin_extension_mesh = result.mesh
            self.tin_extension_textured = None  # draping에서 재생성
            self._dem_extend_info = result.info
            self.log(f"  [DEM 확장] {result.n_points}개 정점, {result.n_faces}개 삼각형")
        except Exception as e:
            self.log(f"  [Step 1.5] DEM 확장 실패: {e}")
            self.set_status("DEM 확장 실패", "#E74C3C")
            messagebox.showerror("오류", f"DEM 확장 실패:\n{e}")
            return

        try:
            self._reinterpolate_tin_boundary_with_dem(result.mesh, feather_m)
        except Exception as e:
            self.log(f"  [경계 재보간] 경고: {e}")

        # UV 매핑/텍스처 초기화 — 다음 Step 2에서 재생성
        self.total_mesh = None
        self.set_status("Step 1.5 완료 — 위성지도 결합 준비", "#2ECC71")
        self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
        self.show_3d_preview(textured=False)

    def _fill_tin_bbox_gap_with_dem(self):
        """TIN bbox 내부에서 현재 convex hull 바깥인 빈 공간을 DEM 샘플 점으로
        채우고 Delaunay 재계산. 실행 후 tin_mesh의 hull이 bbox와 일치 →
        평면 탑뷰에서 사각형이 꽉 찬 TIN. Step 1.5에서 외곽 확장 **전**에 호출.

        알고리즘 (10m → 1m 점진):
            for step in [10, 9, …, 1]:
                - 현재 pts의 hull 재계산
                - bbox 전체 step 격자 → hull 바깥·bbox 내부만 필터
                - 기존 pts와 step×0.4 이내 거리는 중복 간주해 제외
                - 남은 점 Z를 DEM에서 샘플 → append
        """
        if not self.tin_mesh or not DEM_EXTENDER_AVAILABLE:
            return
        if not getattr(self, "projected_bounds", None):
            return
        from scipy.spatial import ConvexHull as _ConvexHullF, cKDTree as _cKDTreeF
        from matplotlib.path import Path as _MplPathF

        origin = np.asarray(self.origin, dtype=np.float64)
        pts_zero = np.asarray(self.tin_mesh.points, dtype=np.float64).copy()
        pts_abs = pts_zero + origin
        x0_abs, y0_abs, x1_abs, y1_abs = [float(v) for v in self.projected_bounds]

        # DEM 타일 1회 준비 — **create_tin에서 받은 격자/offset 재사용** 우선.
        # 재사용 시 (1) 네트워크/IO 절약 (2) datum 일관성 → bbox seam 단차 0.
        src_crs = self.crs_option.get()
        to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
        cached_grid = getattr(self, "_dem_elev_grid", None)
        cached_bounds = getattr(self, "_dem_grid_bounds", None)
        cached_offset = getattr(self, "_dem_datum_offset", None)
        if cached_grid is not None and cached_bounds is not None and cached_offset is not None:
            elev_grid = cached_grid
            grid_bounds = cached_bounds
            offset_v = float(cached_offset)
            self.log(f"  [Step 1.5-a 빈공간 채움] DEM 격자+offset 재사용 "
                     f"(offset={offset_v:+.2f}m, {elev_grid.shape[1]}x{elev_grid.shape[0]}) "
                     f"— create_tin과 **동일 datum**")
        else:
            margin = 100.0
            cx_arr = np.array([x0_abs - margin, x1_abs + margin, x0_abs - margin, x1_abs + margin])
            cy_arr = np.array([y0_abs - margin, y0_abs - margin, y1_abs + margin, y1_abs + margin])
            cx_lon, cx_lat = to_wgs.transform(cx_arr, cy_arr)
            elev_grid, grid_bounds = fetch_terrarium_grid(
                float(np.min(cx_lat)), float(np.min(cx_lon)),
                float(np.max(cx_lat)), float(np.max(cx_lon)),
                zoom=13, cache_dir=str(cache_dir("dem")), log_fn=self.log,
            )
            # datum offset (기존 측점 vs DEM) — fallback 경로
            s_lons, s_lats = to_wgs.transform(pts_abs[:, 0], pts_abs[:, 1])
            s_dem_z = _sample_grid_bilinear(
                elev_grid, grid_bounds,
                np.asarray(s_lats), np.asarray(s_lons))
            fin = np.isfinite(s_dem_z)
            offset_v = float(np.median(s_dem_z[fin] - pts_abs[fin, 2])) if fin.any() else 0.0
            self.log(f"  [Step 1.5-a 빈공간 채움] DEM datum offset={offset_v:+.2f}m (fallback)")

        def _sample_offset(xy_abs):
            _lons, _lats = to_wgs.transform(xy_abs[:, 0], xy_abs[:, 1])
            _z = _sample_grid_bilinear(
                elev_grid, grid_bounds,
                np.asarray(_lats), np.asarray(_lons))
            if np.any(np.isnan(_z)):
                _m = float(np.nanmedian(_z))
                _z = np.where(np.isnan(_z), _m, _z)
            return _z - offset_v

        # 점진 densify
        current_abs = pts_abs.copy()
        total_added = 0
        steps_log = []
        for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
            try:
                hull = _ConvexHullF(current_abs[:, :2])
            except Exception:
                break
            hull_path = _MplPathF(current_abs[hull.vertices, :2], closed=True)
            gx = np.arange(x0_abs, x1_abs + _step * 0.5, _step)
            gy = np.arange(y0_abs, y1_abs + _step * 0.5, _step)
            ggx, ggy = np.meshgrid(gx, gy)
            grid_xy = np.column_stack([ggx.ravel(), ggy.ravel()])
            in_bb = (
                (grid_xy[:, 0] >= x0_abs - 1e-6) & (grid_xy[:, 0] <= x1_abs + 1e-6)
                & (grid_xy[:, 1] >= y0_abs - 1e-6) & (grid_xy[:, 1] <= y1_abs + 1e-6)
            )
            grid_xy = grid_xy[in_bb]
            if len(grid_xy) == 0:
                continue
            in_hull = hull_path.contains_points(grid_xy)
            out_hull_xy = grid_xy[~in_hull]
            if len(out_hull_xy) == 0:
                continue
            tree_ex = _cKDTreeF(current_abs[:, :2])
            d_ex, _ = tree_ex.query(out_hull_xy, k=1)
            new_xy = out_hull_xy[d_ex > _step * 0.4]
            if len(new_xy) == 0:
                continue
            new_z = _sample_offset(new_xy)
            current_abs = np.vstack([current_abs, np.column_stack([new_xy, new_z])])
            total_added += len(new_xy)
            steps_log.append(f"{_step:.0f}m:{len(new_xy)}")

        if total_added == 0:
            self.log("  [Step 1.5-a 빈공간 채움] hull이 이미 bbox 덮음 — skip")
            return

        # Delaunay 재계산 (zero-based) + v6 벽 컷 (slope_ratio 기반)
        new_zero = current_abs.copy()
        new_zero[:, 0] -= origin[0]
        new_zero[:, 1] -= origin[1]
        new_zero[:, 2] -= origin[2]
        tri_f = Delaunay(new_zero[:, :2])
        simplices_f = tri_f.simplices

        x_min_z = 0.0; y_min_z = 0.0
        x_max_z = float(new_zero[:, 0].max())
        y_max_z = float(new_zero[:, 1].max())
        bbox_tol_z = max(x_max_z, y_max_z) * 1e-4 + 1e-3

        def _touches_vid(vids):
            xs = new_zero[vids, 0]; ys = new_zero[vids, 1]
            return ((np.abs(xs - x_min_z) < bbox_tol_z)
                    | (np.abs(xs - x_max_z) < bbox_tol_z)
                    | (np.abs(ys - y_min_z) < bbox_tol_z)
                    | (np.abs(ys - y_max_z) < bbox_tol_z))
        p0 = new_zero[simplices_f[:, 0], :2]
        p1 = new_zero[simplices_f[:, 1], :2]
        p2 = new_zero[simplices_f[:, 2], :2]
        e_max = np.maximum(
            np.maximum(np.linalg.norm(p0 - p1, axis=1),
                       np.linalg.norm(p1 - p2, axis=1)),
            np.linalg.norm(p2 - p0, axis=1))
        z0 = new_zero[simplices_f[:, 0], 2]
        z1 = new_zero[simplices_f[:, 1], 2]
        z2 = new_zero[simplices_f[:, 2], 2]
        z_span = np.maximum(np.maximum(z0, z1), z2) - np.minimum(np.minimum(z0, z1), z2)
        slope_ratio = z_span / np.maximum(e_max, 1e-6)
        touches = (_touches_vid(simplices_f[:, 0])
                   | _touches_vid(simplices_f[:, 1])
                   | _touches_vid(simplices_f[:, 2]))
        # 벽 판정: slope_ratio>3.0(≈72°) AND z_span>20m AND e_max>5m.
        # 이전(1.5/5m)은 실제 급사면(산지 60~70°) 까지 잘라 접합점 구멍 원인이었음.
        # 이번에는 자연 급사면 보존, 진짜 수직 벽(e_max도 작은 sliver)만 제거.
        drop = touches & (slope_ratio > 3.0) & (z_span > 20.0) & (e_max > 5.0)
        if drop.any():
            simplices_f = simplices_f[~drop]
            self.log(f"  [Step 1.5-a 빈공간 채움] 벽 컷 v7 {int(drop.sum())}개 제거 "
                     f"(slope_ratio>3.0(≈72°), z_span>20m, e_max>5m) — 자연 급사면 보존")
        else:
            self.log("  [Step 1.5-a 빈공간 채움] 벽 컷 v7: 대상 없음 — 모든 삼각형 보존")

        faces = np.column_stack([np.full(len(simplices_f), 3), simplices_f])
        self.tin_mesh = pv.PolyData(new_zero, faces)
        self.tin_mesh["Elevation"] = new_zero[:, 2]
        self._tin_interpolator = None
        self.log(
            f"  [Step 1.5-a 빈공간 채움] {total_added}개 DEM 점 추가 "
            f"[{', '.join(steps_log)}] → TIN 재생성 "
            f"(정점 {self.tin_mesh.n_points}, 삼각형 {len(simplices_f)})")

    def _reinterpolate_tin_boundary_with_dem(self, dem_mesh, feather_m):
        """원본 TIN 경계 근처 정점 Z를 DEM 값으로 부드럽게 수렴시킴.

        2단계로 **내부 네모박스 경계선(= 평활·자연 DEM 경계) 제거**:
          1) DEM smoothstep 블렌드 — 경계 0 → DEM 100%, feather_m → TIN 100%.
             smoothstep은 C1 연속이라 feather_m 경계에서 자연스러운 미분 0 전이.
          2) bbox 4변 정확히 위 정점은 DEM 100% 강제(부분 블렌드에 원본 TIN Z가
             섞여 튀는 것을 원천 차단).

        이전(v5 이전)의 Laplacian smoothing 4pass는 **제거**.
        이유: feather 영역만 이웃 가중평균으로 평탄화하면, feather 바깥 자연 DEM
        내부와의 경계(= feather_m 거리)에 Z 분산 차이가 남아 **네모박스 경계선**
        으로 보이는 현상이 사용자 error.png에서 확인됨. smoothstep 블렌드만으로
        C1 연속이 확보되므로 Laplacian 추가 평활은 오히려 해가 됨.
        """
        if dem_mesh is None or dem_mesh.n_points == 0:
            self.log("  [경계 재보간] DEM mesh 없음 — skip")
            return
        ext_pts = np.asarray(dem_mesh.points, dtype=np.float64)
        tin_pts = np.asarray(self.tin_mesh.points, dtype=np.float64).copy()
        if len(tin_pts) == 0:
            return
        xmin = float(tin_pts[:, 0].min()); xmax = float(tin_pts[:, 0].max())
        ymin = float(tin_pts[:, 1].min()); ymax = float(tin_pts[:, 1].max())
        bbox_tol = max(xmax - xmin, ymax - ymin) * 1e-4 + 1e-3
        dist_edge = np.minimum.reduce([
            tin_pts[:, 0] - xmin,
            xmax - tin_pts[:, 0],
            tin_pts[:, 1] - ymin,
            ymax - tin_pts[:, 1],
        ])
        near_edge = dist_edge < feather_m
        on_bbox = dist_edge < bbox_tol
        n_near = int(near_edge.sum())
        n_bbox = int(on_bbox.sum())
        if n_near == 0:
            self.log("  [경계 재보간] 경계 근처 정점 없음 — skip")
            return
        from scipy.spatial import cKDTree

        # (1) DEM 샘플 + smoothstep 블렌드
        dem_tree = cKDTree(ext_pts[:, :2])
        near_idx = np.where(near_edge)[0]
        _, idxs = dem_tree.query(tin_pts[near_idx, :2], k=1)
        dem_z_near = ext_pts[idxs, 2]
        t = np.clip(dist_edge[near_idx] / feather_m, 0.0, 1.0)
        w_dem = 1.0 - (t * t * (3.0 - 2.0 * t))
        z_before = tin_pts[near_idx, 2].copy()
        tin_pts[near_idx, 2] = (1.0 - w_dem) * z_before + w_dem * dem_z_near

        # (2) bbox 정확히 위 정점은 DEM 100% 강제 (spike 원천 차단)
        if n_bbox > 0:
            bbox_idx = np.where(on_bbox)[0]
            _, b_idxs = dem_tree.query(tin_pts[bbox_idx, :2], k=1)
            tin_pts[bbox_idx, 2] = ext_pts[b_idxs, 2]

        delta = tin_pts[near_idx, 2] - z_before
        self.tin_mesh.points = tin_pts
        self.tin_mesh["Elevation"] = tin_pts[:, 2]
        self._tin_interpolator = None
        self.log(
            f"  [경계 재보간] {n_near}개 정점 smoothstep DEM 블렌드 "
            f"(bbox 고정 {n_bbox}개, feather={feather_m:.0f}m, "
            f"Δ평균={float(np.mean(delta)):+.2f}m, Δmax={float(np.max(np.abs(delta))):.2f}m) "
            f"— Laplacian 제거 (내부 네모박스 경계선 원인)")

    # --- Step 2: 위성지도 결합 (Draping) ---
    def btn_draping_callback(self):
        if not self.tin_mesh:
            messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.")
            return

        self.set_status("위성 이미지 다운로드 중...", "#F1C40F")
        source_name = self.tile_source_option.get()
        self.log(f">>> [Step 2] 위성 타일 다운로드 ({source_name})...")

        try:
            # 1. 바운딩 박스를 WGS84 위경도로 변환
            src_crs = self.crs_option.get()
            transformer_wgs84 = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)

            # 타일 범위 기준 bbox — tin_extension_mesh가 있으면 확장 범위(Step 1.5 결과)
            # 를, 없으면 원본 도면 범위를 사용. Step 1.5에서 이미 DEM 확장을 했다면
            # 여기서 추가 확장 안 함(사용자 요구: 중복 제거).
            ox = float(self.origin[0]); oy = float(self.origin[1])
            if self.tin_extension_mesh is not None:
                eb = self.tin_extension_mesh.bounds  # zero-based
                min_x_p = float(eb[0]) + ox; max_x_p = float(eb[1]) + ox
                min_y_p = float(eb[2]) + oy; max_y_p = float(eb[3]) + oy
                self.log(f"  확장 TIN 범위 사용: {max_x_p-min_x_p:.0f}×{max_y_p-min_y_p:.0f}m")
            else:
                min_x_p, min_y_p, max_x_p, max_y_p = self.projected_bounds

            # 뷰 버퍼 % (사용자 지정, 기본 5%)
            try:
                buf_pct = max(0.0, float(self.buffer_percent_var.get() or "5"))
            except ValueError:
                buf_pct = 5.0
            bw, bh = max_x_p - min_x_p, max_y_p - min_y_p
            buf_x_m = bw * buf_pct / 100.0
            buf_y_m = bh * buf_pct / 100.0
            min_x_p -= buf_x_m; max_x_p += buf_x_m
            min_y_p -= buf_y_m; max_y_p += buf_y_m
            self.log(f"  위성 타일 버퍼: {buf_pct:.1f}% ({buf_x_m:.0f}/{buf_y_m:.0f}m)")

            min_lon, min_lat = transformer_wgs84.transform(min_x_p, min_y_p)
            max_lon, max_lat = transformer_wgs84.transform(max_x_p, max_y_p)

            self.log(f"BBOX(WGS84): Lon [{min_lon:.6f}, {max_lon:.6f}], Lat [{min_lat:.6f}, {max_lat:.6f}]")

            # 2. XYZ 타일 다운로드
            tile_url_template = self.tile_servers[source_name]
            # Vworld: API 키 치환
            if "{vworld_key}" in tile_url_template:
                vk = self.vworld_api_key.get()
                if not vk:
                    raise ValueError("Vworld 타일 사용 시 API Key가 필요합니다. 사이드바에 입력해주세요.")
                tile_url_template = tile_url_template.replace("{vworld_key}", vk)
            satellite_img = self._download_xyz_tiles(tile_url_template, min_lat, min_lon, max_lat, max_lon)

            img_path = "satellite_temp.png"
            satellite_img.save(img_path)
            self.log(f"위성 이미지 합성 완료 ({satellite_img.width}x{satellite_img.height}px).")

            # 재질 텍스처 합성 (도로→아스팔트, 사면→토사, 굴착→흙)
            # **실제 다운로드 bbox**(버퍼 확장 반영)를 넘겨 도로가 원본 도면 치수대로
            # 그려지게 한다. 이전에는 5% 고정 bbox가 쓰여 DEM 1000m 버퍼 시 도로가
            # 안쪽에 압축되어 2~3배 확대돼 보였음.
            if self.layer_geometries:
                self.log("  재질 텍스처 합성 중...")
                satellite_img = self._composite_material_textures(
                    satellite_img,
                    bbox_min_x=min_x_p, bbox_min_y=min_y_p,
                    bbox_max_x=max_x_p, bbox_max_y=max_y_p,
                )
                satellite_img.save(img_path)
                self.log(f"  재질 합성 완료 (bbox 동기화: {max_x_p-min_x_p:.0f}×{max_y_p-min_y_p:.0f}m).")

            # 2.5 DEM 확장은 Step 1.5(btn_extend_tin_with_dem_callback)에서 이미
            # 수행 — 여기서는 중복 실행하지 않는다. self.tin_extension_mesh가 있으면
            # 그대로 재사용해 텍스처만 입힌다(사용자 요구: 중복 제거).
            self.tin_extension_textured = None

            # 3. PyVista Texture Mapping (Draping)
            texture = pv.read_texture(img_path)

            # 확장 메시가 있으면 **실제 다운로드 bbox**(버퍼 포함)로 UV 매핑 →
            # 위성 픽셀이 두 메시의 정확한 world 위치에 정렬됨. 두 메시 합친 bbox는
            # DEM 링의 outer 에지 = 이 다운로드 bbox와 같으므로 등가.
            if self.tin_extension_mesh is not None:
                ox = float(self.origin[0]); oy = float(self.origin[1])
                uv_x0 = min_x_p - ox; uv_x1 = max_x_p - ox
                uv_y0 = min_y_p - oy; uv_y1 = max_y_p - oy
                # z는 UV 평면 높이만 — 메시 bounds 중 아무 z 사용
                z_any = self.tin_mesh.bounds[4]
                origin_uv = (uv_x0, uv_y0, z_any)
                point_u = (uv_x1, uv_y0, z_any)
                point_v = (uv_x0, uv_y1, z_any)
                self.total_mesh = self.tin_mesh.texture_map_to_plane(
                    origin=origin_uv, point_u=point_u, point_v=point_v, inplace=False)
                self.tin_extension_textured = self.tin_extension_mesh.texture_map_to_plane(
                    origin=origin_uv, point_u=point_u, point_v=point_v, inplace=False)
                # **Seam-free 렌더 용** — show_3d_preview / _capture_from_camera 에서
                #   두 메시 merge 후 extract_surface 를 거치면 TCoords 가 소실되므로
                #   동일 UV 파라미터로 재적용하기 위해 저장.
                self._uv_mapping_params = (origin_uv, point_u, point_v)
                self.log("텍스처 UV 매핑 완료 (TIN + 외곽 DEM 확장, 다운로드 bbox 정렬).")
            else:
                mesh_bounds = self.tin_mesh.bounds
                self.total_mesh = self.tin_mesh.texture_map_to_plane(
                    origin=(mesh_bounds[0], mesh_bounds[2], mesh_bounds[4]),
                    point_u=(mesh_bounds[1], mesh_bounds[2], mesh_bounds[4]),
                    point_v=(mesh_bounds[0], mesh_bounds[3], mesh_bounds[4]),
                    inplace=False
                )
                # 확장 메시 없음 → UV 재적용 불필요. 이전 값 비활성.
                self._uv_mapping_params = None
                self.log("텍스처 UV 매핑 완료.")

            self.set_status("위성지도 결합 완료", "#2ECC71")
            self.btn_step3.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)

            self.show_3d_preview(textured=True, texture_obj=texture)

        except Exception as e:
            self.log(f"결합 실패: {e}")
            self.set_status("결합 실패", "#E74C3C")
            messagebox.showerror("오류", f"위성지도 결합 중 오류 발생:\n{e}")

    def _download_xyz_tiles(self, url_template, min_lat, min_lon, max_lat, max_lon, zoom=17):
        """XYZ 타일 다운로드·합성 — 실제 로직은 tile_downloader 모듈."""
        from tile_downloader import download_xyz_tiles
        return download_xyz_tiles(
            url_template, min_lat, min_lon, max_lat, max_lon,
            zoom=zoom, log_fn=self.log,
        )

    def _reopen_3d_preview(self):
        """현재 TIN/텍스처/구조물 상태로 3D 뷰어 재호출.

        PyVista 창을 닫아도 메쉬 데이터(self.tin_mesh, self.total_mesh)는
        메모리에 유지되므로 언제든 재열기 가능. 구조물이 새로 빌드되면
        자동 갱신되도록 최신 상태로 다시 그림.
        """
        if not self.tin_mesh:
            messagebox.showinfo("안내", "먼저 Step 1 (TIN 생성)을 완료해 주세요.")
            return
        # total_mesh(텍스처)가 있으면 위성 합성본 우선 표시
        has_texture = self.total_mesh is not None
        self.show_3d_preview(textured=has_texture)

    def show_3d_preview(self, textured=False, texture_obj=None):
        if not self.tin_mesh: return

        p = pv.Plotter(title="S-CANVAS 3D Canvas")
        p.set_background("#1e1e1e")

        target_mesh = self.total_mesh if textured and self.total_mesh else self.tin_mesh

        # DEM 외곽 확장 메시 (도넛) — TIN 뒤쪽 배경 지형
        ext_mesh = None
        if textured and self.tin_extension_textured is not None:
            ext_mesh = self.tin_extension_textured
        elif self.tin_extension_mesh is not None:
            ext_mesh = self.tin_extension_mesh

        # **Seam-free 렌더링**: TIN + DEM ring 두 메시가 별도 PolyData 라
        #   각자 내부에서만 normal 이 평균돼 경계에서 쉐이딩 불연속(= error.png
        #   의 사각 선)이 보인다. merge(merge_points=True) 로 공유 경계 정점을
        #   weld → 위상적으로 단일 연속 표면. compute_normals(feature_angle=180)
        #   로 모든 edge 를 smooth 처리 → seam normal 평활. Z 는 이미
        #   _reinterpolate_tin_boundary_with_dem 에서 bbox 정점까지 DEM 매칭 돼
        #   있어 tolerance=0.01m 로 안전하게 weld 가능.
        unified_mesh = None
        if ext_mesh is not None:
            try:
                merged = target_mesh.merge(ext_mesh, merge_points=True, tolerance=0.01)
                if not isinstance(merged, pv.PolyData):
                    merged = merged.extract_surface()
                # **UV 재적용** — merge/extract_surface 가 TCoords 를 떨어뜨리므로
                #   텍스처 모드에서는 draping 때 저장한 동일 파라미터로 다시 매핑.
                #   texture_map_to_plane 이 새 메시를 만들어 반환하므로 compute_normals
                #   는 그 다음(inplace=True) 에 호출해야 normal 도 같은 메시에 붙음.
                uv_params = getattr(self, "_uv_mapping_params", None)
                if textured and texture_obj and uv_params is not None:
                    o_uv, p_u, p_v = uv_params
                    merged = merged.texture_map_to_plane(
                        origin=o_uv, point_u=p_u, point_v=p_v, inplace=False)
                merged.compute_normals(
                    feature_angle=180.0, auto_orient_normals=True,
                    consistent_normals=True, inplace=True,
                )
                unified_mesh = merged
                n_weld = target_mesh.n_points + ext_mesh.n_points - merged.n_points
                uv_note = " + UV 재적용" if (textured and texture_obj and uv_params) else ""
                self.log(
                    f"  [Render] seam-free 통합: TIN({target_mesh.n_points}) + "
                    f"DEM({ext_mesh.n_points}) → weld 후 {merged.n_points}점 "
                    f"(공유정점 {n_weld}개 용접, feature_angle=180 normal 평활{uv_note})"
                )
            except Exception as e:
                self.log(f"  [Render] 메시 통합 실패, 2-mesh 폴백: {e}")
                unified_mesh = None

        if unified_mesh is not None:
            if textured and texture_obj:
                p.add_mesh(unified_mesh, texture=texture_obj,
                           show_edges=self.wireframe_var.get(), edge_color="white")
            else:
                p.add_mesh(unified_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
                           show_edges=self.wireframe_var.get(), edge_color="white",
                           scalar_bar_args={'title': 'Elevation (m)'})
        else:
            # legacy 2-mesh 렌더 — 확장 메시가 없거나 통합 실패 시
            if textured and texture_obj:
                p.add_mesh(target_mesh, texture=texture_obj,
                           show_edges=self.wireframe_var.get(), edge_color="white")
            else:
                p.add_mesh(target_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
                           show_edges=self.wireframe_var.get(), edge_color="white",
                           scalar_bar_args={'title': 'Elevation (m)'})
            if ext_mesh is not None:
                try:
                    if textured and texture_obj:
                        p.add_mesh(ext_mesh, texture=texture_obj,
                                   show_edges=False, lighting=True)
                    else:
                        p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
                                   show_edges=False, lighting=True,
                                   show_scalar_bar=False)
                except Exception as e:
                    self.log(f"  [DEM] 확장 메시 렌더 실패: {e}")

        # Phase 4: 계획선 오버레이 + 구조물 마커/3D 추가
        self._add_overlay_to_plotter(p)

        p.add_axes()
        # 확장 메시가 있으면 축·카메라를 **합집합** bounds로 잡아 확장 영역이
        # 프레임 밖으로 잘리지 않게 한다.
        tb = self.tin_mesh.bounds
        if ext_mesh is not None:
            eb = ext_mesh.bounds
            scene_bounds = (
                min(tb[0], eb[0]), max(tb[1], eb[1]),
                min(tb[2], eb[2]), max(tb[3], eb[3]),
                min(tb[4], eb[4]), max(tb[5], eb[5]),
            )
        else:
            scene_bounds = tb
        try:
            p.show_grid(bounds=scene_bounds, color="gray",
                        xlabel="X (m)", ylabel="Y (m)", zlabel="Elevation (m)")
        except Exception:
            p.show_grid(color="gray")
        p.enable_eye_dome_lighting()
        p.view_isometric()
        with contextlib.suppress(Exception):
            p.reset_camera(bounds=scene_bounds)
        p.show()

    def _add_overlay_to_plotter(self, plotter):
        """플로터에 지형 오버레이 (도로/면 오버레이/경계선) + 빌드된 3D 구조물만 추가.

        Step 1 TIN 뷰 원칙: 구조물은 신경쓰지 않음. 구조물 위치 참조는 detail DXF가
        빌드되어 template_meshes가 존재할 때만 _add_template_structures_to_plotter가 담당.
        """
        # === 1) 계획선 오버레이 (도로/면 오버레이/경계선만) ===
        if self.layer_geometries:
            self.log(f"  오버레이 생성 시작 ({len(self.layer_geometries)}개 레이어)...")
            overlays = self._build_plan_overlay_meshes()

            added = 0
            for mesh, color, opacity in overlays:
                try:
                    if mesh.n_lines > 0 and mesh.n_cells == mesh.n_lines:
                        plotter.add_mesh(mesh, color=color, line_width=3, opacity=opacity)
                    else:
                        plotter.add_mesh(mesh, color=color, opacity=opacity,
                                       show_edges=False, lighting=True)
                    added += 1
                except Exception as e:
                    self.log(f"    오버레이 메쉬 추가 실패: {e}")

            self.log(f"  오버레이: {added}/{len(overlays)}개 메쉬 추가됨")

        # === 2) 템플릿 빌드된 구조물 3D 메쉬만 추가 (detail DXF 업로드 후 상태) ===
        self._add_template_structures_to_plotter(plotter)

    def _add_template_structures_to_plotter(self, plotter):
        """구조물 레지스트리의 템플릿 빌드 메쉬들을 지형 위 해당 위치에 배치."""
        if not STRUCTURE_TEMPLATES_AVAILABLE:
            return
        if not getattr(self, "structure_registry", None):
            return

        built_structures = [
            (ln, info) for ln, info in self.structure_registry.items()
            if info.get("template_meshes")
        ]
        if not built_structures:
            return

        self.log(f"  구조물 3D 배치: {len(built_structures)}개 구조물 변환 중...")

        total_added = 0
        import numpy as _np

        # 진단용 TIN 요약
        tb0 = self.tin_mesh.bounds
        self.log(f"  TIN bounds (로컬): x=[{tb0.x_min:.1f},{tb0.x_max:.1f}] "
                 f"y=[{tb0.y_min:.1f},{tb0.y_max:.1f}] "
                 f"z=[{tb0.z_min:.1f},{tb0.z_max:.1f}]")
        self.log(f"  origin(월드): ({self.origin[0]:.1f}, {self.origin[1]:.1f}, "
                 f"{self.origin[2]:.1f})")

        for layer_name, info in built_structures:
            try:
                raw_meshes = info["template_meshes"]
                centroid = info["centroid"]
                rotation = info.get("orientation_deg", 0.0)

                # 원본 메쉬 aggregate bounds (진단용)
                all_pts_raw = _np.concatenate(
                    [_np.asarray(m.points) for m, _, _ in raw_meshes], axis=0
                )
                r_xmin, r_ymin, r_zmin = all_pts_raw.min(axis=0)
                r_xmax, r_ymax, r_zmax = all_pts_raw.max(axis=0)
                self.log(f"  [{info['name']}] raw mesh bounds: "
                         f"x=[{r_xmin:.1f},{r_xmax:.1f}] "
                         f"y=[{r_ymin:.1f},{r_ymax:.1f}] "
                         f"z=[{r_zmin:.1f},{r_zmax:.1f}] "
                         f"(메쉬 {len(raw_meshes)}개)")

                # 지형 Z 맞춤: 구조물 bottom_el을 지형 표면으로 이동
                struct_bottom_el = self._extract_structure_bottom_el(info)

                # geo-referencing(placement_transform)이 있으면 TIN 4점에 배치
                tr = info.get("placement_transform")
                scale_mode = info.get("placement_scale_mode", "none")
                if (tr is not None
                        and getattr(tr, "ref_tin", None)
                        and len(tr.ref_tin) >= 4):
                    ref_arr = _np.asarray(tr.ref_tin, dtype=float)
                    q_cx = float(ref_arr[:, 0].mean())
                    q_cy = float(ref_arr[:, 1].mean())
                    # ref_tin은 GeoReferencingDialog에서 이미 origin 차감된 TIN 로컬
                    # 단, 레거시(world 좌표로 저장된 것)를 감지하면 자동 변환
                    tb_chk = self.tin_mesh.bounds
                    is_local = (
                        tb_chk.x_min - 5000 <= q_cx <= tb_chk.x_max + 5000 and
                        tb_chk.y_min - 5000 <= q_cy <= tb_chk.y_max + 5000
                    )
                    if not is_local:
                        # 레거시 world 좌표 → 로컬로 변환
                        ref_arr = ref_arr - _np.array(
                            [float(self.origin[0]), float(self.origin[1])]
                        )
                        q_cx = float(ref_arr[:, 0].mean())
                        q_cy = float(ref_arr[:, 1].mean())
                        self.log(f"    └ geo-ref (legacy world → local 변환): "
                                 f"quad center=({q_cx:.1f}, {q_cy:.1f})")
                    else:
                        self.log(f"    └ geo-ref: quad center (TIN 로컬)="
                                 f"({q_cx:.1f}, {q_cy:.1f}) · "
                                 f"scale_mode={scale_mode} · tr.scale={tr.scale:.4f}")
                    pad_z = info.get("_excavation_pad_z")
                    # detail quad (ref_plan) — mesh 방향 정렬용
                    detail_q = getattr(tr, "ref_plan", None) or []
                    # 파서가 제공하는 plan_frame_angle (mesh +X가 detail +X로부터
                    # 이 각도만큼 회전되어 있으면 상쇄)
                    plan_frame_angle = 0.0
                    params_obj = info.get("template_params")
                    if params_obj is not None:
                        plan_frame_angle = float(
                            getattr(params_obj, "plan_frame_angle_deg", 0.0) or 0.0
                        )
                    placed = fit_meshes_to_quad(
                        meshes=raw_meshes,
                        quad_world_pts=ref_arr.tolist(),
                        terrain_mesh=self.tin_mesh,
                        terrain_origin=_np.zeros(3),  # ref_tin 이미 로컬
                        structure_bottom_el=struct_bottom_el,
                        z_mode="terrain",
                        scale_mode=scale_mode,
                        skip_ground=True,
                        skip_terrain=True,
                        pad_surface_z=pad_z,  # 굴착 pad Z (있으면 보간 대체)
                        detail_quad_pts=detail_q,  # mesh 방향 정렬
                        plan_frame_angle_deg=plan_frame_angle,
                    )
                else:
                    self.log(f"    └ 폴백: centroid={centroid} rot={rotation:.1f}° "
                             f"(placement_transform 없음)")
                    pad_z = info.get("_excavation_pad_z")
                    placed = apply_placement(
                        meshes=raw_meshes,
                        plan_centroid=centroid,
                        rotation_deg=rotation,
                        z_mode="terrain",
                        terrain_mesh=self.tin_mesh,
                        terrain_origin=self.origin,
                        structure_bottom_el=struct_bottom_el,
                        skip_ground=True,
                        skip_terrain=True,
                        pad_surface_z=pad_z,
                    )

                # placed 메쉬 aggregate bounds (진단용)
                if placed:
                    all_pts_p = _np.concatenate(
                        [_np.asarray(m.points) for m, _, _ in placed], axis=0
                    )
                    p_xmin, p_ymin, p_zmin = all_pts_p.min(axis=0)
                    p_xmax, p_ymax, p_zmax = all_pts_p.max(axis=0)
                    self.log(f"    └ placed bounds: "
                             f"x=[{p_xmin:.1f},{p_xmax:.1f}] "
                             f"y=[{p_ymin:.1f},{p_ymax:.1f}] "
                             f"z=[{p_zmin:.1f},{p_zmax:.1f}]")
                else:
                    self.log("    └ placed: 0개 (필터로 모두 제외됨)")

                # TIN 바운드 기준으로 sanity check — 엄청 벗어난 메쉬는 경고 후 제외
                tin_b = self.tin_mesh.bounds
                tin_w = float(tin_b.x_max - tin_b.x_min)
                tin_h = float(tin_b.y_max - tin_b.y_min)
                tin_z_range = float(tin_b.z_max - tin_b.z_min)
                tin_cx = float((tin_b.x_min + tin_b.x_max) / 2)
                tin_cy = float((tin_b.y_min + tin_b.y_max) / 2)
                tin_cz = float((tin_b.z_min + tin_b.z_max) / 2)

                for mesh, color, opacity in placed:
                    try:
                        b = mesh.bounds
                        # 메쉬가 TIN 중심으로부터 너무 멀리/너무 크면 제외
                        mx = max(abs(b.x_min - tin_cx), abs(b.x_max - tin_cx))
                        my = max(abs(b.y_min - tin_cy), abs(b.y_max - tin_cy))
                        mz = max(abs(b.z_min - tin_cz), abs(b.z_max - tin_cz))
                        if (mx > max(tin_w * 5, 1000) or
                                my > max(tin_h * 5, 1000) or
                                mz > max(tin_z_range * 5, 1000)):
                            self.log(f"    [{info['name']}] 메쉬 이상치 제외: "
                                     f"bounds=({b.x_min:.1f},{b.y_min:.1f},"
                                     f"{b.z_min:.1f})-({b.x_max:.1f},"
                                     f"{b.y_max:.1f},{b.z_max:.1f})")
                            continue
                        plotter.add_mesh(mesh, color=color, opacity=opacity,
                                          show_edges=False, smooth_shading=True,
                                          lighting=True)
                        total_added += 1
                    except Exception:
                        continue

                self.log(f"    [{info['name']}] {len(placed)}개 메쉬 배치 완료")
            except Exception as e:
                self.log(f"    [{info.get('name', layer_name)}] 배치 실패: {e}")
                continue

        self.log(f"  구조물 3D 총 {total_added}개 메쉬 추가됨")

    def _extract_structure_bottom_el(self, info):
        """레지스트리 정보에서 구조물 바닥 EL을 추출."""
        params = info.get("template_params")
        if params is None:
            return 0.0

        # StructureParams 딕셔너리에서 bottom_el 계열 키 찾기
        p_dict = getattr(params, "params", {})
        for key in ["body_bottom_el", "bottom_el", "base_el",
                    "el_upstream_bed", "el_gate_sill"]:
            if key in p_dict:
                return float(p_dict[key])
        return 0.0

    def _compute_capture_size(self, max_long_side=1536):
        """캡처 (out_w, out_h) 계산 — 우선순위: 명시 화면비 → 뷰어 창 크기 → 정사각.

        - `extraction_aspect_ratio`(rw, rh) 가 설정돼 있으면 그 비율로 lock.
        - 없으면 `_saved_window_size` 기반(자유 모드).
        - 둘 다 없으면 정사각형 폴백.
        - long-side 를 max_long_side 로 캡, 짧은쪽은 비율 보존 후 8배수 정렬.
        """
        aspect = getattr(self, "extraction_aspect_ratio", None)
        if aspect and len(aspect) == 2 and aspect[0] > 0 and aspect[1] > 0:
            rw, rh = float(aspect[0]), float(aspect[1])
            if rw >= rh:
                out_w = max_long_side
                out_h = max(8, round(max_long_side * rh / rw / 8.0) * 8)
            else:
                out_h = max_long_side
                out_w = max(8, round(max_long_side * rw / rh / 8.0) * 8)
            return int(out_w), int(out_h)
        ws = getattr(self, "_saved_window_size", None)
        if not ws or len(ws) != 2 or ws[0] <= 0 or ws[1] <= 0:
            return max_long_side, max_long_side
        w, h = float(ws[0]), float(ws[1])
        if w >= h:
            out_w = max_long_side
            out_h = max(8, round(max_long_side * h / w / 8.0) * 8)
        else:
            out_h = max_long_side
            out_w = max(8, round(max_long_side * w / h / 8.0) * 8)
        return int(out_w), int(out_h)

    # --- Step 3: 뷰포인트 선택 + 제어맵 추출 ---
    def btn_control_map_callback(self):
        if not self.tin_mesh:
            messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.")
            return
        if not self.total_mesh:
            messagebox.showwarning("주의", "먼저 위성지도 결합(Step 2)을 수행해야 합니다.")
            return

        self.log(">>> [Step 3] 뷰포인트 선택 모드 진입...")
        self.log("  ◆ 마우스로 원하는 각도를 잡으세요 (Google Earth처럼)")
        self.log("  ◆ 좌클릭+드래그: 회전 | 휠: 줌 | 우클릭+드래그: 이동")
        self.log("  ◆ 하단 화면비 버튼 클릭 → 창 크기/캡처 비율 즉시 잠금")
        self.log("  ◆ 원하는 뷰가 잡히면 Enter 키(또는 q)를 누르거나 창을 닫으세요")
        self.set_status("뷰포인트를 선택하세요 (Enter로 확정)", "#F1C40F")

        try:
            # 인터랙티브 3D 뷰어 열기 — 사용자가 자유롭게 회전/줌
            self._saved_camera = self._open_interactive_viewer()

            if self._saved_camera is None:
                self.log("  뷰포인트 선택 취소됨.")
                self.set_status("뷰포인트 미선택", "#E74C3C")
                return

            # 선택된 카메라 위치 로그 (focal/up은 카메라 복원 시 다시 읽음)
            cam_pos = self._saved_camera[0]
            self.log(f"  카메라 위치 확정: pos={[f'{v:.0f}' for v in cam_pos]}")

            # 확정된 뷰로 제어맵 추출 — 화면비 락이 있으면 그 비율, 없으면 뷰어 창
            self.log("  확정된 뷰로 캡처 시작...")
            out_w, out_h = self._compute_capture_size(1536)
            ar = self.extraction_aspect_ratio
            ar_label = f"비율 {ar[0]}:{ar[1]}" if ar else f"뷰어 창 {self._saved_window_size or '미저장'}"
            self.log(f"  캡처 해상도: {out_w}x{out_h} ({ar_label} 기반)")

            # 1. 위성 텍스처 3D 캡처
            self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
            self.capture_image.save("capture_textured.png")
            self.log(f"  캡처 완료: {self.capture_image.size}")

            # 2. Depth Map
            self.log("  Depth Map 추출 중...")
            self.depth_map = self._capture_depth_from_camera(out_w, out_h)
            self.depth_map.save("depth_map.png")
            self.log("  Depth Map 완료.")

            # 3. Lineart Map
            self.log("  Lineart Map 추출 중...")
            self.lineart_map = self._capture_lineart_from_camera(out_w, out_h)
            self.lineart_map.save("lineart_map.png")
            self.log("  Lineart Map 완료.")

            # 4. 가이드 이미지 합성
            self.guide_image = self._compose_guide_image(
                self.capture_image, self.depth_map, self.lineart_map
            )
            self.guide_image.save("guide_composite.png")

            self.set_status("제어맵 추출 완료", "#2ECC71")
            self.btn_step4.configure(fg_color=["#3a7ebf", "#1f538d"])
            self.log("  저장 완료: capture_textured.png, depth_map.png, lineart_map.png, guide_composite.png")

            messagebox.showinfo("완료",
                "뷰포인트 확정 + 제어맵 4종 추출 완료!\n\n"
                "이제 Step 4(AI 렌더링)를 실행하세요.")

        except Exception as e:
            self.log(f"제어맵 추출 실패: {e}")
            self.set_status("추출 실패", "#E74C3C")
            messagebox.showerror("오류", f"제어맵 추출 중 오류:\n{e}")

    def _open_interactive_viewer(self):
        """인터랙티브 PyVista 뷰어. 카메라 앙각/방위각을 HUD로 실시간 표시."""
        import math

        p = pv.Plotter(title="S-CANVAS: 뷰포인트 선택 (마우스로 회전/줌 → q로 확정)")
        p.set_background("#1a1a2e")

        target = self.total_mesh if self.total_mesh else self.tin_mesh
        tex = None
        if self.total_mesh and os.path.exists("satellite_temp.png"):
            tex = pv.read_texture("satellite_temp.png")
            p.add_mesh(target, texture=tex, show_edges=self.wireframe_var.get(), edge_color="#444444")
        else:
            p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
                       show_edges=self.wireframe_var.get(), edge_color="#444444")

        # DEM 외곽 확장 메시 — 뷰포인트 선택/캡처/AI에 **같은 장면**을 쓰기 위해 같이 렌더
        ext_mesh_view = self.tin_extension_textured or self.tin_extension_mesh
        if ext_mesh_view is not None:
            try:
                if tex is not None and self.tin_extension_textured is not None:
                    p.add_mesh(ext_mesh_view, texture=tex, show_edges=False, lighting=True)
                else:
                    p.add_mesh(ext_mesh_view, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
                               show_edges=False, lighting=True, show_scalar_bar=False)
            except Exception as e:
                self.log(f"  [뷰어] 확장 메시 추가 경고: {e}")

        p.add_axes()
        p.show_grid(color="gray")
        p.enable_eye_dome_lighting()

        # Phase 4: 계획선 오버레이 추가
        self._add_overlay_to_plotter(p)

        # 카메라 계산 기준: TIN + 확장 메시 **합집합 bounds** → 전체가 뷰에 들어오게
        tb = target.bounds
        if ext_mesh_view is not None:
            eb = ext_mesh_view.bounds
            bounds = [
                min(tb[0], eb[0]), max(tb[1], eb[1]),
                min(tb[2], eb[2]), max(tb[3], eb[3]),
                min(tb[4], eb[4]), max(tb[5], eb[5]),
            ]
        else:
            bounds = list(tb)
        cx = (bounds[0] + bounds[1]) / 2
        cy = (bounds[2] + bounds[3]) / 2
        cz = (bounds[4] + bounds[5]) / 2
        diag = ((bounds[1]-bounds[0])**2 + (bounds[3]-bounds[2])**2)**0.5
        # Step 3 캡처가 같은 frame을 쓰도록 보관
        self._capture_bounds = tuple(bounds)

        # 초기 뷰: isometric — 사용자 지정 버퍼 %만큼 프레이밍 여유를 줌
        try:
            buf_pct = max(0.0, float(self.buffer_percent_var.get() or "5"))
        except (ValueError, AttributeError):
            buf_pct = 5.0
        elev, azim = 45.0, 225.0
        # 기준 거리 1.3x diag(거의 꽉 참) + buf_pct 만큼 여유
        dist_mult = 1.3 + buf_pct / 100.0
        dist = diag * dist_mult
        cam_x = cx + dist * math.cos(math.radians(elev)) * math.sin(math.radians(azim))
        cam_y = cy + dist * math.cos(math.radians(elev)) * math.cos(math.radians(azim))
        cam_z = cz + dist * math.sin(math.radians(elev))
        p.camera_position = [(cam_x, cam_y, cam_z), (cx, cy, cz), (0, 0, 1)]

        # HUD: 카메라 정보 텍스트 (좌상단) — actor handle 보관 안 함 (현재 동적 갱신 X)
        p.add_text(
            "마우스로 회전/줌 → Enter로 확정 (q도 가능)\n"
            "앙각: 45°  방위: 225°(SW)  줌: 1.0x  비율: 자유",
            position="upper_left", font_size=10, color="white", name="camera_hud"
        )

        # 카메라 저장 컨테이너
        captured_cam = [None]
        captured_win = [None]   # 인터랙티브 뷰어의 마지막 창 크기 (w, h)
        center = [cx, cy, cz]

        dirs_map = {0:"N",45:"NE",90:"E",135:"SE",180:"S",225:"SW",270:"W",315:"NW",360:"N"}

        def _calc_camera_angles():
            """현재 카메라에서 앙각/방위각/줌 계산"""
            try:
                cam = p.camera_position
                if not cam:
                    return 45, 225, 1.0
                pos = cam[0]
                dx = pos[0] - center[0]
                dy = pos[1] - center[1]
                dz = pos[2] - center[2]
                dist_xy = math.sqrt(dx*dx + dy*dy)
                dist_3d = math.sqrt(dx*dx + dy*dy + dz*dz)

                elev_deg = math.degrees(math.atan2(dz, max(dist_xy, 1e-6)))
                azim_deg = math.degrees(math.atan2(dx, max(dy, 1e-6))) % 360
                zoom_ratio = diag * 1.5 / max(dist_3d, 1e-6)

                return elev_deg, azim_deg, zoom_ratio
            except Exception:
                return 45, 225, 1.0

        def _update_hud_and_save(*args):
            """카메라 정보 갱신 + 카메라 위치/창 크기 저장"""
            try:
                cam = p.camera_position
                if cam:
                    captured_cam[0] = (tuple(cam[0]), tuple(cam[1]), tuple(cam[2]))
                # 창 크기도 매 이벤트마다 갱신 — 사용자가 창을 리사이즈해도 추적
                try:
                    ws = p.window_size
                    if ws is not None and len(ws) == 2 and ws[0] > 0 and ws[1] > 0:
                        captured_win[0] = (int(ws[0]), int(ws[1]))
                except Exception:
                    pass

                elev_deg, azim_deg, zoom_ratio = _calc_camera_angles()
                closest_dir = min(dirs_map.keys(), key=lambda k: abs(k - azim_deg % 360))
                dir_label = dirs_map[closest_dir]

                cur_ratio = self.extraction_aspect_ratio
                ratio_label = f"{cur_ratio[0]}:{cur_ratio[1]}" if cur_ratio else "자유"
                hud_text = (
                    f"마우스로 회전/줌 → Enter로 확정 (q도 가능)\n"
                    f"앙각: {elev_deg:.0f}°  방위: {azim_deg:.0f}°({dir_label})  "
                    f"줌: {zoom_ratio:.1f}x  비율: {ratio_label}"
                )
                p.add_text(hud_text, position="upper_left", font_size=10,
                          color="white", name="camera_hud")
            except Exception:
                pass

        p.iren.add_observer("ExitEvent", lambda *a: _update_hud_and_save())
        p.iren.add_observer("KeyPressEvent", _update_hud_and_save)
        p.iren.add_observer("InteractionEvent", _update_hud_and_save)

        # ─── 화면비 락 버튼 (사진 편집기 스타일) ──────────────────────────────
        # PIL 로 버튼 이미지(배경+테두리+텍스트)를 그려서 vtkTexturedActor2D 로
        # 화면 하단에 띄우고, 클릭 시 self.extraction_aspect_ratio 변경 + 창 크기 조정.
        # vtkTextActor 만 쓰면 "텍스트만 떠서" 버튼처럼 안 보이는 문제 해결.
        from PIL import Image as _PILImage, ImageDraw as _PILDraw, ImageFont as _PILFont
        try:
            import vtk as _vtk
        except Exception:
            _vtk = None

        RATIOS = [
            ("자유", None), ("1:1", (1,1)), ("9:16", (9,16)), ("16:9", (16,9)),
            ("4:5", (4,5)), ("5:4", (5,4)), ("3:4", (3,4)), ("4:3", (4,3)),
            ("2:3", (2,3)), ("3:2", (3,2)), ("5:7", (5,7)), ("7:5", (7,5)),
            ("1:2", (1,2)), ("2:1", (2,1)),
        ]
        NUM_BTN = len(RATIOS)
        # 픽셀 단위 버튼 사이즈 — 가독성 우선
        BTN_PX_W, BTN_PX_H, BTN_PX_GAP, BTN_PX_BOTTOM = 78, 32, 4, 14

        # 한글 지원 폰트 로드 (Windows 기본). 실패 시 PIL 기본 폰트 폴백.
        def _load_button_font():
            for fp in [
                "C:/Windows/Fonts/malgun.ttf",
                "C:/Windows/Fonts/malgunbd.ttf",
                "C:/Windows/Fonts/gulim.ttc",
            ]:
                try:
                    return _PILFont.truetype(fp, 14)
                except Exception:
                    continue
            return _PILFont.load_default()

        _btn_font = _load_button_font()

        def _make_button_image(label, active):
            """버튼 한 개를 PIL 이미지로 렌더 → numpy 배열 반환."""
            img = _PILImage.new("RGBA", (BTN_PX_W, BTN_PX_H), (0, 0, 0, 0))
            d = _PILDraw.Draw(img)
            # 배경
            bg = (255, 215, 0, 235) if active else (50, 50, 70, 220)
            border = (255, 255, 255, 255) if active else (180, 180, 200, 255)
            text_color = (30, 20, 0, 255) if active else (240, 240, 240, 255)
            d.rounded_rectangle(
                [(0, 0), (BTN_PX_W - 1, BTN_PX_H - 1)],
                radius=6, fill=bg, outline=border, width=2,
            )
            # 가운데 텍스트
            try:
                tb = d.textbbox((0, 0), label, font=_btn_font)
                tw, th = tb[2] - tb[0], tb[3] - tb[1]
                tx = (BTN_PX_W - tw) // 2 - tb[0]
                ty = (BTN_PX_H - th) // 2 - tb[1]
            except Exception:
                tw, th = d.textsize(label, font=_btn_font)
                tx = (BTN_PX_W - tw) // 2
                ty = (BTN_PX_H - th) // 2
            d.text((tx, ty), label, fill=text_color, font=_btn_font)
            return np.array(img)

        def _np_to_vtk_image(arr):
            """RGBA numpy → vtkImageData (vtkTexturedButtonRepresentation2D 용)."""
            if _vtk is None:
                return None
            h, w = arr.shape[:2]
            img = _vtk.vtkImageData()
            img.SetDimensions(w, h, 1)
            img.AllocateScalars(_vtk.VTK_UNSIGNED_CHAR, 4)
            # 위아래 뒤집기 (PIL 은 top-down, VTK 은 bottom-up)
            flipped = np.ascontiguousarray(arr[::-1])
            try:
                from vtkmodules.util import numpy_support as _ns
                vtk_arr = _ns.numpy_to_vtk(
                    flipped.reshape(-1, 4), deep=True,
                    array_type=_vtk.VTK_UNSIGNED_CHAR)
                vtk_arr.SetNumberOfComponents(4)
                img.GetPointData().SetScalars(vtk_arr)
            except Exception:
                # 폴백: 픽셀 단위 SetScalarComponentFromFloat
                for yy in range(h):
                    for xx in range(w):
                        r, g, b, a = flipped[yy, xx]
                        img.SetScalarComponentFromFloat(xx, yy, 0, 0, r)
                        img.SetScalarComponentFromFloat(xx, yy, 0, 1, g)
                        img.SetScalarComponentFromFloat(xx, yy, 0, 2, b)
                        img.SetScalarComponentFromFloat(xx, yy, 0, 3, a)
            return img

        # 버튼 행을 화면 너비에 맞게 가운데 정렬 (픽셀 단위, 좌하단 원점)
        ren_win = p.iren.GetRenderWindow() if hasattr(p.iren, 'GetRenderWindow') else \
                  getattr(p.iren, 'interactor', p.iren).GetRenderWindow()

        def _layout_buttons():
            try:
                ws = p.window_size
                win_w = int(ws[0]) if ws and ws[0] > 0 else 1280
            except Exception:
                win_w = 1280
            total_w = NUM_BTN * BTN_PX_W + (NUM_BTN - 1) * BTN_PX_GAP
            start_x = max(4, (win_w - total_w) // 2)
            return [
                (start_x + i * (BTN_PX_W + BTN_PX_GAP), BTN_PX_BOTTOM)
                for i in range(NUM_BTN)
            ]

        # 각 버튼 = vtkButtonWidget + TexturedButtonRepresentation2D
        self._aspect_buttons = []  # [(widget, rep, label, ratio)]
        button_widgets = []

        if _vtk is not None:
            for i, (label, ratio) in enumerate(RATIOS):
                is_active = (ratio == self.extraction_aspect_ratio)
                rep = _vtk.vtkTexturedButtonRepresentation2D()
                rep.SetNumberOfStates(1)
                vtk_img = _np_to_vtk_image(_make_button_image(label, is_active))
                if vtk_img is not None:
                    rep.SetButtonTexture(0, vtk_img)
                # placement (픽셀)
                bds = [0, BTN_PX_W, 0, BTN_PX_H, 0, 0]
                rep.SetPlaceFactor(1.0)
                rep.PlaceWidget(bds)

                widget = _vtk.vtkButtonWidget()
                widget.SetInteractor(p.iren.interactor if hasattr(p.iren, 'interactor') else p.iren)
                widget.SetRepresentation(rep)

                # 클로저 — 클릭 시 비율 적용
                def _make_callback(_label=label, _ratio=ratio):
                    def _cb(obj, evt):
                        self.extraction_aspect_ratio = _ratio
                        with contextlib.suppress(Exception):
                            self.log(f"  화면비 변경: {_label}")
                        if _ratio is not None:
                            rw, rh = _ratio
                            try:
                                cur_w, _cur_h = p.window_size
                                cur_w = int(cur_w)
                                new_h = max(360, round(cur_w * rh / rw))
                                p.window_size = (cur_w, new_h)
                            except Exception:
                                try:
                                    cur_w = int(p.window_size[0])
                                    new_h = max(360, round(cur_w * rh / rw))
                                    ren_win.SetSize(cur_w, new_h)
                                except Exception:
                                    pass
                        _refresh_buttons()
                        _reposition_buttons()
                        _update_hud_and_save()
                        with contextlib.suppress(Exception):
                            p.render()
                    return _cb

                widget.AddObserver("StateChangedEvent", _make_callback())
                widget.On()
                button_widgets.append(widget)
                self._aspect_buttons.append((widget, rep, label, ratio))

        # 위치 재계산 — 창 크기에 맞춰 다시 배치
        def _reposition_buttons():
            if _vtk is None:
                return
            positions = _layout_buttons()
            for (widget, rep, _label, _ratio), (px, py) in zip(self._aspect_buttons, positions, strict=False):
                try:
                    bds = [px, px + BTN_PX_W, py, py + BTN_PX_H, 0, 0]
                    rep.SetPlaceFactor(1.0)
                    rep.PlaceWidget(bds)
                except Exception:
                    pass

        def _refresh_buttons():
            """활성 버튼 텍스처를 갱신 (활성=금색 강조)."""
            if _vtk is None:
                return
            for (widget, rep, label, ratio) in self._aspect_buttons:
                is_active = (ratio == self.extraction_aspect_ratio)
                try:
                    vtk_img = _np_to_vtk_image(_make_button_image(label, is_active))
                    if vtk_img is not None:
                        rep.SetButtonTexture(0, vtk_img)
                except Exception:
                    pass

        # 초기 배치
        _reposition_buttons()

        # 창 크기 변경 시 버튼 재배치
        with contextlib.suppress(Exception):
            ren_win.AddObserver("ModifiedEvent", lambda *a: _reposition_buttons())

        # ─── Enter 키 → 확정 (q는 VTK 기본 동작으로 폴백 유지) ───────────────
        def _on_enter():
            with contextlib.suppress(Exception):
                _update_hud_and_save()
            try:
                p.iren.terminate_app()
            except Exception:
                with contextlib.suppress(Exception):
                    p.close()

        try:
            p.add_key_event("Return", _on_enter)
            p.add_key_event("KP_Enter", _on_enter)
        except Exception:
            pass

        # 인터랙티브 표시 (블로킹)
        p.show()

        # show() 후 폴백
        if captured_cam[0] is None:
            try:
                cam = p.camera_position
                if cam:
                    captured_cam[0] = (tuple(cam[0]), tuple(cam[1]), tuple(cam[2]))
            except Exception:
                pass
        if captured_win[0] is None:
            try:
                ws = p.window_size
                if ws is not None and len(ws) == 2 and ws[0] > 0 and ws[1] > 0:
                    captured_win[0] = (int(ws[0]), int(ws[1]))
            except Exception:
                pass

        # 사용자 창 크기 → app 상태에 영구 저장 (Step 3/4 캡처에서 화면비로 사용)
        self._saved_window_size = captured_win[0]

        # 최종 카메라 각도 로그
        if captured_cam[0]:
            elev_deg, azim_deg = 45, 225
            try:
                pos = captured_cam[0][0]
                dx = pos[0] - cx
                dy = pos[1] - cy
                dz = pos[2] - cz
                dist_xy = math.sqrt(dx*dx + dy*dy)
                elev_deg = math.degrees(math.atan2(dz, max(dist_xy, 1e-6)))
                azim_deg = math.degrees(math.atan2(dx, max(dy, 1e-6))) % 360
            except Exception:
                pass
            self.camera_elevation.set(elev_deg)
            self.camera_azimuth.set(azim_deg)

        return captured_cam[0]

    def _capture_from_camera(self, width, height, textured=True):
        """저장된 카메라 위치로 offscreen 캡처 (계획선 오버레이 + DEM 외곽 확장 포함).

        AI 렌더링용 제어맵(capture_textured.png)은 DXF 범위뿐 아니라
        `tin_extension_mesh`(DEM+위성으로 확장된 외곽 링)도 함께 포함해야 한다.
        이전에는 `self.total_mesh`만 add_mesh되어, 사용자가 지정한 bbox 너머는
        흰 배경으로 비어 있었다 → 조감도 결과도 그 범위에만 그려짐.
        """
        p = pv.Plotter(off_screen=True, window_size=[width, height])
        p.set_background("white")

        target = self.total_mesh if textured and self.total_mesh else self.tin_mesh
        tex = None
        if textured and os.path.exists("satellite_temp.png"):
            tex = pv.read_texture("satellite_temp.png")

        # DEM 외곽 확장 메시 (TIN 뒤쪽 배경 지형) — 캡처에도 포함
        ext_mesh = None
        if textured and self.tin_extension_textured is not None:
            ext_mesh = self.tin_extension_textured
        elif self.tin_extension_mesh is not None:
            ext_mesh = self.tin_extension_mesh

        # **Seam-free 통합** — show_3d_preview 와 동일 파이프라인. AI 프롬프트용
        #   캡처 이미지에도 사각 경계선이 보이면 AI가 그 선을 실제 지형 피처로
        #   오인할 수 있으므로, 인터랙티브 뷰어와 동일하게 weld+smooth 적용.
        unified = None
        if ext_mesh is not None:
            try:
                merged = target.merge(ext_mesh, merge_points=True, tolerance=0.01)
                if not isinstance(merged, pv.PolyData):
                    merged = merged.extract_surface()
                # UV 재적용 — show_3d_preview 와 동일 이유(TCoords 소실 방지).
                uv_params = getattr(self, "_uv_mapping_params", None)
                if textured and tex is not None and uv_params is not None:
                    o_uv, p_u, p_v = uv_params
                    merged = merged.texture_map_to_plane(
                        origin=o_uv, point_u=p_u, point_v=p_v, inplace=False)
                merged.compute_normals(
                    feature_angle=180.0, auto_orient_normals=True,
                    consistent_normals=True, inplace=True,
                )
                unified = merged
            except Exception as e:
                self.log(f"  [capture] 메시 통합 실패, 2-mesh 폴백: {e}")
                unified = None

        if unified is not None:
            if textured and tex is not None:
                p.add_mesh(unified, texture=tex)
            else:
                p.add_mesh(unified, scalars="Elevation", cmap=_TIN_EARTH_CMAP)
        else:
            if textured and tex is not None:
                p.add_mesh(target, texture=tex)
            else:
                p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP)
            if ext_mesh is not None:
                try:
                    if textured and tex is not None:
                        p.add_mesh(ext_mesh, texture=tex,
                                   show_edges=False, lighting=True)
                    else:
                        p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
                                   show_edges=False, lighting=True,
                                   show_scalar_bar=False)
                except Exception as e:
                    self.log(f"  [capture] 확장 메시 렌더 경고: {e}")

        # Phase 4: 계획선 오버레이
        self._add_overlay_to_plotter(p)

        p.enable_eye_dome_lighting()

        if self._saved_camera:
            p.camera_position = list(self._saved_camera)

        p.show(auto_close=False)
        img_array = p.screenshot(return_img=True)
        p.close()
        return Image.fromarray(img_array)

    def _capture_depth_from_camera(self, width, height):
        """저장된 카메라 위치로 Depth Map 캡처 (DEM 외곽 확장 포함)"""
        p = pv.Plotter(off_screen=True, window_size=[width, height])
        p.set_background("black")
        p.add_mesh(self.tin_mesh, color="white", lighting=True)
        # DEM 외곽 확장 — AI가 DXF 밖 지형도 깊이로 인식하도록 포함
        ext_mesh = self.tin_extension_textured or self.tin_extension_mesh
        if ext_mesh is not None:
            with contextlib.suppress(Exception):
                p.add_mesh(ext_mesh, color="white", lighting=True)
        p.enable_eye_dome_lighting()

        if self._saved_camera:
            p.camera_position = list(self._saved_camera)

        p.show(auto_close=False)
        z_buf = p.get_image_depth(fill_value=0.0)
        p.close()

        valid = z_buf[z_buf > 0]
        if len(valid) == 0:
            return Image.new("L", (width, height), 0)

        z_min, z_max = valid.min(), valid.max()
        if z_max - z_min < 1e-6:
            normalized = np.zeros_like(z_buf, dtype=np.uint8)
        else:
            normalized = np.clip((z_buf - z_min) / (z_max - z_min), 0, 1)
            normalized = (1.0 - normalized) * 255
            normalized[z_buf <= 0] = 0
            normalized = normalized.astype(np.uint8)

        return Image.fromarray(normalized)

    def _capture_lineart_from_camera(self, width, height):
        """저장된 카메라 위치로 Lineart Map 캡처 (DEM 외곽 확장 포함)"""
        p = pv.Plotter(off_screen=True, window_size=[width, height])
        p.set_background("white")
        p.add_mesh(self.tin_mesh, color="white", edge_color="black",
                   show_edges=True, line_width=1, lighting=False)
        ext_mesh = self.tin_extension_textured or self.tin_extension_mesh
        if ext_mesh is not None:
            with contextlib.suppress(Exception):
                p.add_mesh(ext_mesh, color="white", edge_color="black",
                           show_edges=True, line_width=1, lighting=False)

        if self._saved_camera:
            p.camera_position = list(self._saved_camera)

        p.show(auto_close=False)
        img_array = p.screenshot(return_img=True)
        p.close()

        img = Image.fromarray(img_array).convert("L")
        img = img.point(lambda x: 0 if x < 200 else 255, '1')
        return img.convert("L")

    def _compose_guide_image(self, capture, depth, lineart):
        """캡처 + Depth(반투명) + Lineart(오버레이) 합성"""
        size = capture.size

        # 모든 맵을 동일 사이즈로 리사이즈
        depth_rgb = depth.convert("RGB").resize(size, Image.LANCZOS)
        lineart_rgb = lineart.convert("RGB").resize(size, Image.LANCZOS)

        # 캡처를 베이스로
        result = capture.copy()

        # Depth를 20% 불투명도로 블렌드
        depth_blend = Image.blend(result, depth_rgb, alpha=0.2)
        result = depth_blend

        # Lineart의 검정 선만 오버레이 (검정 부분만 합성)
        lineart_np = np.array(lineart_rgb)
        result_np = np.array(result)

        # 선이 있는 곳 (어두운 부분)만 덮어쓰기
        mask = lineart_np.mean(axis=2) < 128
        result_np[mask] = [30, 30, 30]  # 진한 회색 선

        return Image.fromarray(result_np)


    # --- Step 4: AI 렌더링 (Stability AI API + Harness) ---
    def _build_render_prompt(self, user_extra=""):
        """프롬프트 레지스트리(YAML)에서 구조 보존형 프롬프트를 자동 조합"""

        # Harness 프롬프트 레지스트리 사용 시도
        template = None
        if self.prompt_reg:
            try:
                ver = self.prompt_reg.latest_version()
                if ver:
                    template = self.prompt_reg.load_template(ver)
            except Exception:
                pass

        if template:
            # YAML 기반 프롬프트 조합
            time_key_map = {
                "낮 (Daytime)": "daytime", "석양 (Sunset)": "sunset",
                "밤 (Night)": "night", "새벽 (Dawn)": "dawn", "흐린 날 (Overcast)": "overcast",
            }
            time_key = time_key_map.get(self.time_of_day.get(), "daytime")
            time_desc = template.get("time_presets", {}).get(time_key, "")

            # 앙각 → 프리셋 매핑
            elev = int(self.camera_elevation.get())
            thresholds = template.get("angle_thresholds", {})
            if elev >= thresholds.get("top_down", 70):
                angle_desc = template.get("angle_presets", {}).get("top_down", "")
            elif elev >= thresholds.get("high_angle", 45):
                angle_desc = template.get("angle_presets", {}).get("high_angle", "")
            elif elev >= thresholds.get("oblique", 30):
                angle_desc = template.get("angle_presets", {}).get("oblique", "")
            else:
                angle_desc = template.get("angle_presets", {}).get("low_angle", "")

            struct_parts = template.get("structure_preservation", [])
            quality_parts = template.get("quality_enhancement", [])

            parts = [angle_desc, time_desc, *struct_parts, *quality_parts]
            if user_extra:
                parts.append(user_extra)

            return ", ".join(p for p in parts if p)

        # 폴백: 하드코딩 프롬프트
        time_map = {
            "낮 (Daytime)": "bright daylight, clear blue sky, sharp shadows, vivid green vegetation",
            "석양 (Sunset)": "golden hour sunset, warm orange light, long dramatic shadows, glowing sky",
            "밤 (Night)": "nighttime aerial view, moonlight reflections on water, dark blue sky",
            "새벽 (Dawn)": "early dawn, soft pink and purple sky, morning mist over valleys",
            "흐린 날 (Overcast)": "overcast sky, diffused soft light, muted colors, atmospheric fog",
        }
        elev = int(self.camera_elevation.get())
        angle_desc = ("top-down overhead aerial view" if elev >= 70 else
                     "high-angle bird's-eye view" if elev >= 45 else
                     "oblique aerial perspective" if elev >= 30 else
                     "low-angle dramatic perspective")
        time_desc = time_map.get(self.time_of_day.get(), time_map["낮 (Daytime)"])

        base = (f"Ultra high resolution {angle_desc} of terrain with satellite imagery texture, "
                f"{time_desc}, photorealistic, enhance existing terrain details, "
                "maintain exact terrain shape and layout, 8K sharp detail")
        if user_extra:
            base = f"{base}, {user_extra}"
        return base

    def _get_negative_prompt(self):
        """네거티브 프롬프트도 YAML에서 로드"""
        if self.prompt_reg:
            try:
                ver = self.prompt_reg.latest_version()
                if ver:
                    template = self.prompt_reg.load_template(ver)
                    return template.get("negative_prompt", "")
            except Exception:
                pass
        return ("blurry, low quality, distorted, watermark, text, logo, "
                "cartoon, anime, illustration, painting, sketch, "
                "completely different scene, unrelated content, changed terrain layout")

    def _get_dxf_hash(self):
        """현재 DXF 파일의 SHA256 해시"""
        if not self.dxf_path:
            return "unknown"
        try:
            h = hashlib.sha256()
            with open(self.dxf_path, "rb") as f:
                for chunk in iter(lambda: f.read(8192), b""):
                    h.update(chunk)
            return h.hexdigest()[:16]
        except Exception:
            return "unknown"

    def _get_prompt_hash(self, prompt):
        """프롬프트 텍스트의 해시"""
        return hashlib.sha256(prompt.encode()).hexdigest()[:16]

    def btn_ai_render_callback(self):
        if not self.guide_image and not self.capture_image:
            messagebox.showwarning("주의", "먼저 제어맵 추출(Step 3)을 수행해야 합니다.")
            return

        engine = self.render_engine.get()

        # 시간대 + 출력 화질 선택 (Step 4 실행 시)
        time_win = ctk.CTkToplevel(self)
        time_win.title("렌더링 옵션")
        time_win.geometry("380x360")
        time_win.grab_set()

        ctk.CTkLabel(time_win, text="시간대 / 조명", font=ctk.CTkFont(size=14, weight="bold")).pack(pady=(15, 5))
        time_var = ctk.StringVar(value="낮 (Daytime)")
        ctk.CTkOptionMenu(time_win, variable=time_var,
            values=["낮 (Daytime)", "석양 (Sunset)", "밤 (Night)", "새벽 (Dawn)", "흐린 날 (Overcast)"],
            width=300).pack(pady=5)

        ctk.CTkLabel(time_win, text="추가 프롬프트 (선택)", font=ctk.CTkFont(size=12)).pack(pady=(10, 2))
        extra_entry = ctk.CTkEntry(time_win, width=300, placeholder_text="예: concrete dam, flowing water")
        extra_entry.pack(pady=5)

        # 출력 화질 — Step 3 화면비와 결합해 최종 픽셀 크기 결정
        ctk.CTkLabel(time_win, text="출력 화질",
                     font=ctk.CTkFont(size=12, weight="bold")).pack(pady=(12, 2))
        res_var = ctk.StringVar(value="HD (720p)")
        res_frame = ctk.CTkFrame(time_win, fg_color="transparent")
        for _label in ["HD (720p)", "FHD (1080p)", "UHD (4K)"]:
            ctk.CTkRadioButton(res_frame, text=_label, variable=res_var,
                               value=_label).pack(side="left", padx=8)
        res_frame.pack(pady=2)

        render_opts = [None]
        def on_ok():
            render_opts[0] = (time_var.get(), extra_entry.get() or "", res_var.get())
            time_win.destroy()
        def on_cancel():
            time_win.destroy()

        btn_f = ctk.CTkFrame(time_win, fg_color="transparent")
        btn_f.pack(pady=15)
        ctk.CTkButton(btn_f, text="취소", width=80, fg_color="transparent", border_width=1, command=on_cancel).pack(side="left", padx=10)
        ctk.CTkButton(btn_f, text="렌더링 시작", width=140, command=on_ok).pack(side="left", padx=10)

        time_win.wait_window()

        if render_opts[0] is None:
            return

        self.time_of_day.set(render_opts[0][0])
        user_extra = render_opts[0][1]
        res_choice = render_opts[0][2]
        final_prompt = self._build_render_prompt(user_extra)
        strength = self.render_strength.get()

        # 출력 화질 → (target_w, target_h) 계산. Step 3 화면비가 있으면 적용,
        # 자유 모드면 캡처 이미지의 실제 비율 사용.
        RES_HEIGHT = {"HD (720p)": 720, "FHD (1080p)": 1080, "UHD (4K)": 2160}
        target_h = RES_HEIGHT.get(res_choice, 720)
        aspect = self.extraction_aspect_ratio
        if aspect:
            rw, rh = aspect
            target_w = max(8, round(target_h * rw / rh / 8.0) * 8)
        else:
            cap = self.capture_image
            if cap is not None and cap.size[1] > 0:
                cap_w, cap_h = cap.size
                target_w = max(8, round(target_h * cap_w / cap_h / 8.0) * 8)
            else:
                target_w = target_h
        self.target_resolution = (target_w, target_h)
        self.log(f"  목표 해상도: {target_w}x{target_h} ({res_choice})")

        self.log(f">>> [Step 4] AI 렌더링 시작 ({engine})...")
        self.log(f"  시간대: {self.time_of_day.get()}")
        self.log(f"  변환 강도: {strength:.2f}")
        self.log(f"  프롬프트: {final_prompt[:100]}...")

        # 뷰포인트 캡처 갱신 — 화면비 락이 있으면 그 비율, 없으면 뷰어 창
        if hasattr(self, '_saved_camera') and self._saved_camera:
            self.log("  저장된 뷰포인트로 캡처 갱신...")
            out_w, out_h = self._compute_capture_size(1536)
            ar = self.extraction_aspect_ratio
            ar_label = f"비율 {ar[0]}:{ar[1]}" if ar else f"뷰어 창 {self._saved_window_size or '미저장'}"
            self.log(f"  캡처 해상도: {out_w}x{out_h} ({ar_label} 기반)")
            self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
            self.capture_image.save("capture_textured.png")
            self.depth_map = self._capture_depth_from_camera(out_w, out_h)
            self.depth_map.save("depth_map.png")

        if "Gemini" in engine:
            key = self.gemini_api_key.get().strip()
            use_vertex = "Vertex" in engine
            # Vertex 모드에서 key가 비어 있으면 gcp-key.json의 project_id로 폴백
            if use_vertex and not key and self._gcp_key_project_id:
                key = self._gcp_key_project_id
                self.gemini_api_key.set(key)
                self.log(f"  gcp-key.json에서 project_id 자동 로드: {key}")
            if not key:
                if use_vertex:
                    messagebox.showwarning("GCP Project ID 필요",
                        "GCP Project ID를 입력해 주세요.\n"
                        "또는 프로젝트 루트에 gcp-key.json을 배치하세요.")
                else:
                    messagebox.showwarning("API Key 필요",
                        "Gemini API 키를 입력해 주세요.\n"
                        "aistudio.google.com → API Key에서 무료 발급 가능.")
                return
            # Gemini 3.x 이미지 모델은 location="global"만 허용
            location = self.vertex_location.get().strip() or "global"
            self.set_status(
                f"Gemini 렌더링 중 ({('Vertex AI' if use_vertex else 'API')})...",
                "#F1C40F"
            )
            thread = threading.Thread(
                target=self._run_gemini_render,
                args=(key, final_prompt, use_vertex, location),
                daemon=True
            )
            thread.start()
        else:
            # Stability AI API
            key = self.gemini_api_key.get()
            if not key:
                messagebox.showwarning("API Key 필요",
                    "Stability AI API 키를 입력해 주세요.\n"
                    "또는 'Gemini (Nano Banana)'로 변경하세요.")
                return

            self.set_status("AI 렌더링 중... (15~60초 소요)", "#F1C40F")
            thread = threading.Thread(
                target=self._run_stability_render,
                args=(key, final_prompt, strength),
                daemon=True
            )
            thread.start()

    def _run_gemini_render(self, credential, prompt,
                            use_vertex=False, location="us-central1"):
        """Gemini 자동 호출 + Harness 통합 — 실제 로직은 gemini_renderer 모듈."""
        from gemini_renderer import run_gemini_render
        return run_gemini_render(self, credential, prompt, use_vertex, location)

    def _run_stability_render(self, api_key, prompt, strength):
        """Stability AI API 호출 + Harness 통합 (백그라운드 스레드)

        전략: Conservative/Creative Upscale 우선 → img2img 초저강도 폴백
        원본 위성 텍스처를 최대한 보존하면서 해상도와 디테일만 향상.
        """
        t_start = _time.time()
        job_id = None
        db = None

        # Harness: 작업 이력 시작
        dxf_hash = self._get_dxf_hash()
        prompt_hash = self._get_prompt_hash(prompt)
        prompt_ver = "v1"
        seed = 0

        if self.job_logger:
            try:
                db = get_db_session()
                job = self.job_logger.create_job(db, self.dxf_path or "unknown", dxf_hash)
                job_id = job.id
                seed = self.seed_mgr.get_or_create_seed(db, job_id, dxf_hash)
                if self.prompt_reg:
                    prompt_ver = self.prompt_reg.latest_version() or "v1"
                self.job_logger.start_job(db, job_id, seed, prompt_ver, prompt_hash)
                self.after(0, lambda: self.log(f"  Harness: job#{job_id}, {SeedManager.describe(seed)}, prompt={prompt_ver}"))
            except Exception as e:
                self.after(0, lambda e=e: self.log(f"  Harness 초기화 경고: {e}"))

        try:
            neg_prompt = self._get_negative_prompt()
            rendered = None

            # --- 방법 1: Conservative Upscale (원본 보존 최우선) ---
            self.after(0, lambda: self.log("  [1/3] Conservative Upscale (원본 텍스처 보존)..."))
            rendered = self._stability_upscale(api_key, prompt, neg_prompt, seed,
                                               mode="conservative", creativity=strength)

            # --- 방법 2: Creative Upscale (약간 더 창의적) ---
            if not rendered:
                self.after(0, lambda: self.log("  [2/3] Creative Upscale 시도..."))
                rendered = self._stability_upscale(api_key, prompt, neg_prompt, seed,
                                                   mode="creative", creativity=min(strength, 0.35))

            # --- 방법 3: img2img 초저강도 폴백 ---
            if not rendered:
                self.after(0, lambda: self.log("  [3/3] img2img 초저강도 폴백 (strength=0.2)..."))
                rendered = self._stability_img2img(api_key, prompt, neg_prompt,
                                                   min(strength, 0.2), seed)

            if not rendered:
                if self.job_logger and db and job_id:
                    self.job_logger.fail_job(db, job_id, "모든 API 방법 실패")
                self.after(0, lambda: self.log("  모든 방법 실패"))
                self.after(0, lambda: self.set_status("AI 렌더링 실패", "#E74C3C"))
                return

            # 출력 화질 후처리 — 사용자가 Step 4에서 고른 HD/FHD/UHD 로 리사이즈
            tgt = getattr(self, "target_resolution", None)
            if tgt and tgt[0] > 0 and tgt[1] > 0 and rendered.size != tuple(tgt):
                src_size = rendered.size
                self.after(0, lambda s=src_size, t=tgt: self.log(
                    f"  화질 리사이즈: {s[0]}x{s[1]} → {t[0]}x{t[1]}"))
                rendered = rendered.resize(tuple(tgt), Image.LANCZOS)

            # 성공: 저장
            output_path = "rendered_birdseye.png"
            rendered.save(output_path)
            latency_ms = (_time.time() - t_start) * 1000

            # Harness: 품질 검증
            quality_score = 0.0
            if self.quality_val:
                try:
                    vr = self.quality_val.validate(Path(output_path))
                    quality_score = vr.score
                    self.after(0, lambda: self.log(f"  품질검증: {vr.summary}"))
                except Exception as e:
                    self.after(0, lambda e=e: self.log(f"  품질검증 오류: {e}"))

            # Harness: 작업 완료 기록
            if self.job_logger and db and job_id:
                with contextlib.suppress(Exception):
                    self.job_logger.complete_job(db, job_id, output_path, quality_score, latency_ms)

            self.after(0, lambda: self.log(
                f"  AI 렌더링 완료! → {output_path} ({rendered.size}) "
                f"[{latency_ms:.0f}ms, 품질={quality_score:.2f}]"))
            self.after(0, lambda: self.set_status("AI 렌더링 완료", "#2ECC71"))
            self.after(0, lambda: self._show_rendered_result(output_path))

        except Exception as e:
            if self.job_logger and db and job_id:
                with contextlib.suppress(Exception):
                    self.job_logger.fail_job(db, job_id, str(e))
            self.after(0, lambda e=e: self.log(f"  렌더링 오류: {e}"))
            self.after(0, lambda: self.set_status("렌더링 실패", "#E74C3C"))
            self.after(0, lambda e=e: messagebox.showerror("오류", f"AI 렌더링 중 오류:\n{e}"))
        finally:
            if db:
                with contextlib.suppress(Exception):
                    db.close()

    def _stability_upscale(self, api_key, prompt, neg_prompt, seed, mode="conservative", creativity=0.3):
        """Stability AI Upscale API (원본 보존 + 해상도/디테일 향상)

        mode: "conservative" (원본 충실) 또는 "creative" (디테일 추가)
        creativity: 0.1~0.5 (낮을수록 원본에 가까움)
        """
        url = f"https://api.stability.ai/v2beta/stable-image/upscale/{mode}"

        # 캡처 이미지를 적절한 크기로 준비 (upscale 입력은 1MP 이하)
        source_img = self.capture_image if self.capture_image else self.guide_image

        # 입력 이미지를 768x768 이하로 축소 (upscale이 확대하므로)
        img_resized = source_img.copy()
        img_resized.thumbnail((768, 768), Image.LANCZOS)

        img_buf = io.BytesIO()
        img_resized.save(img_buf, format="PNG")
        img_buf.seek(0)

        headers = {
            "Authorization": f"Bearer {api_key}",
            "Accept": "image/*",
        }
        files = {"image": ("capture.png", img_buf, "image/png")}
        data = {
            "prompt": prompt,
            "negative_prompt": neg_prompt,
            "creativity": str(max(0.1, min(0.5, creativity))),
            "seed": str(seed),
            "output_format": "png",
        }

        try:
            resp = requests.post(url, headers=headers, files=files, data=data, timeout=180)

            if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
                result_img = Image.open(io.BytesIO(resp.content))
                self.after(0, lambda m=mode, s=result_img.size: self.log(
                    f"  {m} Upscale 성공! {s[0]}x{s[1]}"))
                return result_img
            else:
                detail = resp.text[:200] if resp.text else str(resp.status_code)
                self.after(0, lambda m=mode, d=detail, sc=resp.status_code: self.log(
                    f"  {m} Upscale [{sc}]: {d}"))
                return None
        except Exception as e:
            self.after(0, lambda m=mode, e=e: self.log(f"  {m} Upscale 오류: {e}"))
            return None

    def _stability_img2img(self, api_key, prompt, neg_prompt, strength, seed=0):
        """Stability AI Image-to-Image API (위성 텍스처 캡처 이미지 기반)"""
        url = "https://api.stability.ai/v2beta/stable-image/generate/sd3"

        # 위성 텍스처가 입힌 캡처 이미지 사용 (guide_image 대신 capture_image)
        source_img = self.capture_image if self.capture_image else self.guide_image

        img_buf = io.BytesIO()
        img_resized = source_img.resize((1024, 1024), Image.LANCZOS)
        img_resized.save(img_buf, format="PNG")
        img_buf.seek(0)

        headers = {
            "Authorization": f"Bearer {api_key}",
            "Accept": "image/*",
        }
        files = {"image": ("capture.png", img_buf, "image/png")}
        data = {
            "prompt": prompt,
            "negative_prompt": neg_prompt,
            "strength": str(strength),
            "seed": str(seed),
            "output_format": "png",
            "model": "sd3.5-large",
            "mode": "image-to-image",
        }

        try:
            resp = requests.post(url, headers=headers, files=files, data=data, timeout=120)
            if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
                return Image.open(io.BytesIO(resp.content))
            else:
                detail = resp.text[:300] if resp.text else str(resp.status_code)
                self.after(0, lambda d=detail, sc=resp.status_code: self.log(f"  img2img [{sc}]: {d}"))
                self.after(0, lambda d=detail, sc=resp.status_code: messagebox.showerror("API 오류",
                    f"Stability AI 호출 실패 ({sc}):\n{d}"))
                return None
        except Exception as e:
            self.after(0, lambda e=e: self.log(f"  img2img 오류: {e}"))
            return None


    def _show_structure_render(self, image_path):
        """Blender 구조물 렌더 결과를 별도 창에 표시 (AI 결과와 분리).

        blender_renderer.py 가 호출. 투명 PNG도 정상 표시(체커보드 배경).
        """
        try:
            from PIL import ImageTk, Image as _PILImage
            try:
                pil_img = _PILImage.open(image_path)
            except Exception as e:
                messagebox.showerror("이미지 열기 실패",
                    f"렌더 결과 PNG를 열 수 없습니다:\n{image_path}\n\n{e}")
                return

            # 투명 PNG는 체커보드 배경 위에 합성 표시
            display_img = pil_img
            if pil_img.mode == "RGBA":
                from PIL import ImageDraw
                tile = _PILImage.new("RGB", (16, 16), (200, 200, 200))
                d = ImageDraw.Draw(tile)
                d.rectangle((0, 0, 7, 7), fill=(170, 170, 170))
                d.rectangle((8, 8, 15, 15), fill=(170, 170, 170))
                bg = _PILImage.new("RGB", pil_img.size, (200, 200, 200))
                for y in range(0, pil_img.size[1], 16):
                    for x in range(0, pil_img.size[0], 16):
                        bg.paste(tile, (x, y))
                display_img = _PILImage.alpha_composite(
                    bg.convert("RGBA"), pil_img
                ).convert("RGB")

            win = ctk.CTkToplevel(self)
            win.title(f"🎨 Blender 렌더 결과 - {Path(image_path).name}")

            sw = self.winfo_screenwidth(); sh = self.winfo_screenheight()
            max_w, max_h = int(sw * 0.7), int(sh * 0.75)
            iw, ih = display_img.size
            scale = min(max_w / iw, max_h / ih, 1.0)
            disp_w, disp_h = int(iw * scale), int(ih * scale)
            disp = display_img.resize((disp_w, disp_h), _PILImage.LANCZOS)

            tk_img = ImageTk.PhotoImage(disp)
            lbl = ctk.CTkLabel(win, text="", image=tk_img)
            lbl.image = tk_img
            lbl.pack(padx=10, pady=10)

            info = ctk.CTkFrame(win, fg_color="transparent")
            info.pack(fill="x", padx=10, pady=(0, 5))
            mode_str = "투명 배경 (RGBA, 합성용)" if pil_img.mode == "RGBA" else "Sky 배경 (RGB)"
            ctk.CTkLabel(
                info,
                text=f"파일: {image_path}  ·  원본 {iw}×{ih}  ·  {mode_str}",
                font=ctk.CTkFont(size=11),
            ).pack(side="left", padx=5)

            btnf = ctk.CTkFrame(win, fg_color="transparent")
            btnf.pack(fill="x", padx=10, pady=(0, 10))

            def _open_external():
                try:
                    if sys.platform == "win32":
                        os.startfile(image_path)
                    elif sys.platform == "darwin":
                        import subprocess
                        subprocess.Popen(["open", image_path])
                    else:
                        import subprocess
                        subprocess.Popen(["xdg-open", image_path])
                except Exception as e:
                    messagebox.showerror("열기 실패",
                        f"기본 뷰어 실행 실패:\n{e}", parent=win)

            def _open_folder():
                try:
                    folder = str(Path(image_path).resolve().parent)
                    if sys.platform == "win32":
                        os.startfile(folder)
                    elif sys.platform == "darwin":
                        import subprocess
                        subprocess.Popen(["open", folder])
                    else:
                        import subprocess
                        subprocess.Popen(["xdg-open", folder])
                except Exception as e:
                    messagebox.showerror("폴더 열기 실패", f"{e}", parent=win)

            ctk.CTkButton(btnf, text="📂 폴더 열기", width=110,
                          command=_open_folder).pack(side="right", padx=3)
            ctk.CTkButton(btnf, text="🖼 외부 뷰어로 열기", width=160,
                          command=_open_external).pack(side="right", padx=3)
            ctk.CTkButton(btnf, text="닫기", width=80,
                          fg_color="transparent", border_width=1,
                          command=win.destroy).pack(side="left", padx=3)

        except Exception as e:
            import traceback
            traceback.print_exc()
            messagebox.showerror("결과 창 오류", f"렌더 결과 창 생성 실패:\n{e}")

    def _show_rendered_result(self, image_path):
        """렌더링 결과를 별도 창에서 표시"""
        try:
            from PIL import ImageTk

            win = ctk.CTkToplevel(self)
            win.title("S-CANVAS: AI 렌더링 결과")
            win.geometry("820x860")

            img = Image.open(image_path)
            display_size = 800
            img_display = img.copy()
            img_display.thumbnail((display_size, display_size), Image.LANCZOS)

            photo = ImageTk.PhotoImage(img_display)

            label = ctk.CTkLabel(win, text="", image=photo)
            label.image = photo  # 참조 유지
            label.pack(padx=10, pady=10)

            # 저장 버튼
            def save_hires():
                save_path = filedialog.asksaveasfilename(
                    defaultextension=".png",
                    filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")],
                    initialfile="S-CANVAS_rendered.png"
                )
                if save_path:
                    img.save(save_path)
                    self.log(f"  고해상도 저장: {save_path}")

            btn_save = ctk.CTkButton(win, text="고해상도 저장", command=save_hires, height=40)
            btn_save.pack(pady=(0, 10))

        except Exception as e:
            self.log(f"  결과 표시 오류: {e}")

if __name__ == "__main__":
    # 크래시 핸들러 — Python 미처리 예외 + thread 예외 + faulthandler. logs/ 에 회전 저장.
    try:
        from harness.crash_logger import install_crash_handlers
        _log_dir = install_crash_handlers()
        print(f"[crash_logger] logs → {_log_dir}")
    except Exception as _ch_err:
        print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}")

    # 인트로 스플래시 — Design/logo_intro.mp4 재생 후 메인 앱 기동.
    #   실패·파일 없음 시 조용히 skip(메인 앱은 항상 뜸).
    try:
        from splash import show_intro_splash
        show_intro_splash(
            resource_path("Design", "logo_intro.mp4"),
            max_duration_s=12.0,
            fade_ms=400,
        )
    except Exception as _splash_err:
        print(f"[Intro] 스플래시 경고: {_splash_err}")

    app = SCanvasApp()
    app.mainloop()
