# S-CANVAS 수정 이력 (CHANGELOG)

> **2026-04-24 rebrand**: 프로젝트명이 `EG-VIEW` → `S-CANVAS` (Generative Design &
> Visualization Engine) 로 변경되었습니다. 이 파일의 2026-04-24(후속 2) 이전 기록에
> 등장하는 "EG-VIEW" 는 동일 프로젝트의 이전 명칭이며, 역사적 기록 보존을 위해
> 수정하지 않았습니다.

이 프로그램의 모든 코드 수정 내역을 역순(최신이 위)으로 기록합니다.
사용자 원본 요청/피드백은 `Build_log.txt`에 보관되어 있으며, 이 파일은 *적용된 수정*의 기록입니다.

---

## 2026-04-28

### [fix] 화면비 버튼이 텍스트만 떠서 안 보이는 문제 — vtkButtonWidget 로 교체

- **수정 파일**: `scanvas_maker.py`
- **사용자 보고**: 하단에 글씨만 뜨고 버튼이 하나도 없음 → 클릭해도 반응 없음.
- **원인**: 초기 구현은 `p.add_text(viewport=True)` 텍스트 액터 14개 + 좌클릭 옵저버
  로 가짜 버튼을 흉내냄. 하지만 텍스트는 배경/테두리 없이 떠서 시각적으로 버튼처럼
  안 보였고, 일부 PyVista 버전에서 `viewport=True` 가 무시돼 위치까지 어긋났음.
- **수정**: `vtkButtonWidget` + `vtkTexturedButtonRepresentation2D` 기반 진짜 버튼.
  - PIL 로 78×32px 둥근 사각 버튼 이미지(배경+테두리+한글 텍스트) 를 그리고,
    `vtkImageData` 로 변환해 `rep.SetButtonTexture(0, img)` 로 적용.
  - 한글 가독성을 위해 `malgun.ttf` (Windows) 자동 로드, 폴백 체인 포함.
  - `widget.AddObserver("StateChangedEvent", _cb)` 로 클릭 처리 — 옵저버 우선순위
    조작이나 `SetAbortFlag` 불필요 (vtkButtonWidget 이 자체 hit-test 수행).
  - `_reposition_buttons` 헬퍼가 창 너비 기준 가운데 정렬 → `RenderWindow`
    `ModifiedEvent` 옵저버로 리사이즈에 자동 반응.
  - 활성 비율 = 금색(#FFD700) 채움 + 진한 텍스트, 비활성 = 다크 그레이 + 옅은 텍스트.

### [feature] AI 렌더링 워크플로 구체화 — 화면비 락 + 출력 화질 + Enter 확정

- **수정 파일**: `scanvas_maker.py`, `gemini_renderer.py`
- **사용자 요청**: 사진 편집기처럼 (1) 제어맵 추출 시 화면비를 명시 클릭으로 잠그고,
  (2) 'q' 대신 Enter 로 확정하고, (3) AI 렌더링 다이얼로그에서 출력 화질
  (HD/FHD/UHD) 을 미리 골라 그 해상도로 조감도가 나오게 만들고 싶음.
- **수정**:
  - `__init__`: `self.extraction_aspect_ratio = None`, `self._aspect_buttons = []`,
    `self.target_resolution = None` 추가 — Step 3/4 사이 상태 공유.
  - `_compute_capture_size`: 화면비 락이 있으면 그 비율로 lock, 없으면 기존 창 크기
    기반(자유 모드)으로 폴백. 호출처(`btn_control_map_callback:5733`,
    `btn_ai_render_callback:6264`) 두 군데가 자동으로 새 비율 따라감 — 단일 진실 원천.
  - `_open_interactive_viewer`: 14개 화면비 버튼 행을 viewport-normalized 좌표
    (`y=0.018`, `BTN_W=0.060`, `BTN_H=0.045`) 로 하단에 배치. 자유/1:1/9:16/16:9/4:5/
    5:4/3:4/4:3/2:3/3:2/5:7/7:5/1:2/2:1. 활성 버튼은 `▶라벨◀` 금색, 비활성은
    `[라벨]` 회색. `LeftButtonPressEvent` 옵저버를 priority=10.0 으로 등록 →
    InteractorStyle 카메라 회전보다 먼저 발화. 클릭 영역 안이면 `SetAbortFlag(1)`
    로 회전 시작 차단 후 `p.window_size = (cur_w, new_h)` 즉시 변경 + `p.render()`.
    `_refresh_buttons` 헬퍼가 14 actor 의 텍스트/색을 갱신.
  - Enter 키: `p.add_key_event("Return", _on_enter)` + `KP_Enter` 둘 다 바인딩.
    `_on_enter` 는 `_update_hud_and_save()` 후 `p.iren.terminate_app()`. 'q' 는
    VTK 기본 동작으로 그대로 폴백.
  - HUD 텍스트: "q로 확정" → "Enter로 확정 (q도 가능)", 라이브 갱신 텍스트에
    `비율: 16:9` 같은 현재 잠긴 비율 표시 추가.
  - 사이드바 로그/status: 같은 톤으로 "Enter 키(또는 q)" 안내.
  - `btn_ai_render_callback`: 다이얼로그를 `360x250` → `380x360` 으로 늘리고
    "출력 화질" 라디오 3개(HD 720p / FHD 1080p / UHD 4K) 추가. `on_ok` 가 3-튜플
    `(time, extra, res)` 반환. 화질 + Step 3 화면비를 곱해 `target_resolution =
    (target_w, target_h)` 계산. `extraction_aspect_ratio` 가 None 이면 캡처 이미지의
    실제 비율을 사용해 폴백 → 자유 모드도 안전.
  - `_run_stability_render`: 3-stage 폴백 후 성공한 `rendered` 를 저장 직전에
    `Image.LANCZOS` 로 `target_resolution` 리사이즈. 변경 시 콘솔 로그.
  - `gemini_renderer.run_gemini_render`: 동일 로직을 `app.target_resolution` 참조로
    삽입 (함수 시그니처 변경 없음, 기존 `app` 인스턴스 패턴 유지).
- **검증**: `python scanvas_maker.py` → Step 3 진입 시 14개 버튼 행이 보이고,
  "16:9" 클릭 → 즉시 와이드 비율로 창 크기 변경 + 강조 표시. Enter 로 확정 후
  `capture_textured.png` 가 16:9 비율(1536×864 등 8배수). Step 4 에서
  "FHD (1080p)" 선택 → 콘솔 "목표 해상도: 1920x1080" 로그 + 결과
  `rendered_birdseye.png` 가 정확히 1920×1080. 자유 모드도 회귀 없이 동작.

### [fix] 제어맵·조감도 화면비 보존 — 뷰어 창 크기 그대로 캡처

- **수정 파일**: `scanvas_maker.py`
- **사용자 보고**: 제어맵 추출이 무조건 1:1 정사각형으로 뽑혀, 인터랙티브 뷰어에서
  조정한 화면 크기·비율이 무시됨 → 조감도(Gemini) 결과도 사용자가 의도한 프레이밍과
  다르게 나옴.
- **원인**: `btn_control_map_callback` (`:5708`) 와 `btn_ai_render_callback` (`:6219`)
  에 `render_size = 1536` 이 박혀 있어 `_capture_from_camera/_depth/_lineart` 가
  항상 1536×1536 정사각형 오프스크린으로 렌더. 뷰어에서는 카메라 pose 만 가져오고
  화면비는 버려졌기 때문에 VTK 기본 view_angle 로 다른 프레임이 잡힘.
- **수정**:
  - `__init__`: `self._saved_window_size = None` 추가 — 뷰어 창 크기 영구 저장.
  - `_open_interactive_viewer`: `_update_hud_and_save` 옵서버에서 `p.window_size`
    도 매 인터랙션마다 캡처. show() 종료 후 폴백으로 한 번 더 시도. 결과를
    `self._saved_window_size` 에 저장.
  - 신규 `_compute_capture_size(max_long_side=1536)`: 저장된 화면비로 (out_w, out_h)
    계산. long-side 는 1536 으로 캡, 짧은쪽은 비율 보존 + 8배수 정렬(Gemini/Stability
    친화). 미저장이면 1536×1536 폴백 (구버전 호환).
  - `btn_control_map_callback` / `btn_ai_render_callback`: `render_size` 하드코딩
    제거 → `_compute_capture_size()` 결과를 캡처 3종에 동일하게 전달.
- **트레이드오프**: 뷰어 창이 극단적으로 길쭉하면(예 21:9) 가장자리 환각 가능성이
  있으나, long-side 캡 방식이 메모리·API 호환성·일반적 프레이밍 기대치를 모두
  만족하는 가장 안전한 기본값. 정사각 강제가 다시 필요해지면 토글 1개 추가하면 됨.

### [build] 배포용 .exe 빌드 인프라 — PyInstaller onedir + 사용자 데이터 분리

- **신규 파일**:
  - `resource_paths.py` — 런타임 경로 해석 헬퍼
  - `scanvas_maker.spec` — PyInstaller 빌드 설정
  - `build.bat` — 원클릭 Windows 빌드 스크립트
- **수정 파일**: `scanvas_maker.py` (하드코딩 경로 → 헬퍼 함수)
- **사용자 요청**: 코드 의존 없이 사용자가 .exe 더블클릭으로 온전히 이용.
- **설계 결정** (이전 답변 + 디폴트 적용):
  1. **onedir** (단일 .exe 가 아니라 `dist/S-CANVAS/` 폴더 통째 배포). 첫 실행 빠름,
     디버깅 쉬움, AV false-positive 위험 낮음. zip 으로 묶어 배포(150-200MB).
  2. **런타임 데이터 위치**: `%LOCALAPPDATA%\S-CANVAS\` (Windows 표준). 사용자별
     분리, 설치 폴더 쓰기권한 불필요(Program Files 설치 OK).
- **`resource_paths.py` API**:
  - `asset_root()` → 번들 모드 `sys._MEIPASS`, 개발 모드 소스 디렉토리
  - `resource_path("Design", "Logo.png")` → 자산 절대경로
  - `user_data_dir()` → `%LOCALAPPDATA%\S-CANVAS\` (없으면 생성)
  - `db_path()`, `harness_log_path()`, `diagnostic_log_path()`, `cache_dir(*sub)`
  - 환경변수 오버라이드: `SCANVAS_USER_DATA` (강제), `SCANVAS_DEV_LOCAL=1` (소스
    트리 옆에 쓰기 — 개발 모드)
  - `describe()` — 진단용 한 줄 요약
- **`scanvas_maker.py` 리팩터** (10 곳):
  - `Path("scanvas_jobs.db") → str(db_path())`
  - `Path("scanvas_harness.log") → harness_log_path()`
  - `Path("scanvas_diagnostic.log") → diagnostic_log_path()`
  - `Path("prompt_templates") → resource_path("prompt_templates")`
  - `Path("structure_types/structure_v1.yaml") → resource_path("structure_types",
    "structure_v1.yaml")`
  - `Path(__file__).parent / "Design" → resource_path("Design")` (3 곳)
  - `cache_dir="cache/dem" → str(cache_dir("dem"))` (2 곳, dem_extender 호출)
  - 윈도우 아이콘 캐시: `Path(__file__).parent/"cache"/"icons" → cache_dir("icons")`
  - **gcp-key.json 검색 우선순위**: `user_data_dir()/gcp-key.json` (배포본 권장)
    → `resource_path("gcp-key.json")` (개발/legacy 폴백). 사용자가 키 파일을
    `%LOCALAPPDATA%\S-CANVAS\gcp-key.json` 에 두면 바로 인식.
  - `splash` 호출의 video_path → `resource_path("Design", "logo_intro.mp4")`
- **`scanvas_maker.spec` 핵심**:
  - `datas`: `Design/`, `prompt_templates/`, `structure_types/` 트리 그대로 번들
  - `collect_all`: pyvista, vtkmodules, pyproj, ezdxf, tkintermapview, structlog,
    customtkinter, PIL — 자동 감지 누락 가능성 높은 거대 패키지 명시 수집
  - `hiddenimports`: `google.genai` 서브모듈 + sqlalchemy/scipy 누락 빈출 모듈
  - `excludes`: pytest, IPython, jupyter, pyvista.examples, vtkmodules.test 등
    번들 크기 절감
  - `console=False` (GUI 앱), `icon=cache/icons/scanvas_S.ico` (build.bat 가
    빌드 전 생성)
  - `name="S-CANVAS"` → `dist/S-CANVAS/S-CANVAS.exe`
- **`build.bat` 단계**:
  1. PyInstaller 미설치 시 `pip install pyinstaller` 자동
  2. 윈도우 아이콘 사전 생성 (logo_V2.png → scanvas_S.ico, 멀티사이즈)
  3. `build/` + `dist/S-CANVAS/` 정리 후 `pyinstaller --clean --noconfirm
     scanvas_maker.spec`
  4. 결과·런타임 데이터 위치 안내 출력
- **검증**:
  - `ast.parse` OK (scanvas_maker.py + resource_paths.py)
  - `importlib` 로드 OK — `SCanvasApp` 정상 정의
  - `resource_paths.describe()`:
    ```
    asset_root=D:\2026\00_EGVIEW2 (bundled=False)
    user_data_dir=C:\Users\saman\AppData\Local\S-CANVAS
    db=C:\Users\saman\AppData\Local\S-CANVAS\scanvas_jobs.db
    harness_log=C:\Users\saman\AppData\Local\S-CANVAS\scanvas_harness.log
    diagnostic_log=C:\Users\saman\AppData\Local\S-CANVAS\scanvas_diagnostic.log
    cache_root=C:\Users\saman\AppData\Local\S-CANVAS\cache
    ```
- **사용자 사용법**:
  1. `build.bat` 더블클릭 (또는 cmd 에서 실행) → 10-15분 후 `dist/S-CANVAS/` 생성
  2. `dist/S-CANVAS/` 폴더를 zip
  3. 사용자에게 zip 전달 → 압축 해제 → `S-CANVAS.exe` 더블클릭
  4. (선택) 사용자가 GCP 인증 쓰려면 `%LOCALAPPDATA%\S-CANVAS\gcp-key.json`
     에 키 파일 배치
- **남은 일** (실제 빌드 수행은 사용자가):
  - 첫 빌드 후 깨끗한 Windows 머신(Python 미설치) 에서 검증
  - 빌드 후 SmartScreen/Defender 가 차단하면 코드 서명 추가 검토
  - 배포 형태(zip / NSIS 인스톨러 / MSIX) 결정

---

## 2026-04-25

### [feature] 윈도우 타이틀바·작업표시줄 'S' 아이콘 — logo_V2 에서 자동 추출

- **파일**: `scanvas_maker.py` (신규 메서드 `SCanvasApp._setup_window_icon`)
- **사용자 요청**: 창 표시줄 "S-CANVAS" 왼쪽에 보이는 기본 Tk 아이콘을 logo_V2 의
  오렌지 'S' 글자만 따서 교체.
- **수정**:
  1. **신규 메서드 `_setup_window_icon()`** — `__init__` 의 `self.title()` 직후 호출.
     절차:
     - 다크 배경 **하드 스트립** (v_max < 90 → 알파 0). 아이콘용은 부드러운 전이가
       오히려 노이즈가 되므로 단일 임계.
     - 좌측 850px 크롭 (S 영역 isolate)
     - **scipy.ndimage.label 연결 컴포넌트 분석** — 633개 컴포넌트 중 가장 큰
       것(S 본체 105,923px) + 그 10% 이상 크기인 것(S 의 하부 곡선 31,637px) 만
       유지. 회로 패턴·EG-BIM 워터마크 등 잔여 잡음 제거.
     - 타이트 bbox: 461×598 (aspect 0.77)
     - 정사각 패딩(투명) + 4% 외곽 여백
     - **ICO 멀티사이즈 저장** (16/32/48/64/128/256)
     - PNG 폴백 사본도 같이 저장(iconphoto 폴백용)
  2. **캐시 정책**: `cache/icons/scanvas_S.ico` 에 저장. logo_V2.png mtime 이
     ICO 보다 새로우면 재생성. 이후 실행 시는 캐시 즉시 사용.
  3. **Tk 적용**: `self.iconbitmap(default=str(ico_path))` 우선, 실패 시
     `self.iconbitmap(...)` → `self.iconphoto(True, PhotoImage(...))` 순으로 폴백.
     PhotoImage 는 `self._icon_photo_ref` 로 GC 방지.
  4. **실패 시 silent skip** — 로고 파일 없거나 PIL/scipy 에러 시 경고 로그만 찍고
     기본 Tk 아이콘 유지(메인 앱 기동 항상 보장).
- **검증**:
  - `ast.parse` OK
  - ICO 생성 확인: 94,981 bytes, 6개 사이즈, 2 컴포넌트(105923+31637=137560 px)
    유지, 잡음 컴포넌트 631개 제거
  - 256×256 PNG 시각 확인 — 오렌지 'S' + 하부 swoosh 깔끔, 회로 패턴 노이즈 없음

---

## 2026-04-24 (후속 4)

### [feature] Vworld API 키 프리필 + logo_V2 (다크 배경 strip) + 인트로 스플래시 (logo_intro.mp4)

- **파일**: `scanvas_maker.py`, **신규** `splash.py`
- **사용자 요청**:
  1. Vworld API 키 `383CB30A-2AD8-3199-8A7B-215DE3E4280C` 기본값으로 프리필.
  2. 로고를 `logo_V2.png` 로 교체, 뒷배경(다크 네이비) 제거해 GUI 배경과 동일하게.
  3. 프로그램 시작 시 `logo_intro.mp4` 로 역동적 로딩 스플래시 구현 (JS 등 가능).
- **수정**:
  1. **Vworld API 키 프리필** (`scanvas_maker.py` line 229): `ctk.StringVar(value="")`
     → 사용자 제공 키 하드코딩. 이제 Vworld 타일 서버가 즉시 동작.
  2. **logo_V2.png 전환** + 어두운 배경 소프트 스트립:
     - 신규 헬퍼 `_load_image_strip_dark_bg(path, v_low=30, v_high=80)`:
       V(=max RGB) 기반 선형 보간. V≤v_low → 알파 0, V≥v_high → 알파 유지, 사이는
       선형 전이(halo 최소화). logo_V2 는 v_low=25/v_high=75.
     - 결과 측정: 2626×1600 중 36.7% 완전 투명 / 49.5% 부분 알파(부드러운 엣지)
       / 13.8% 완전 불투명. 다크 배경 회로 패턴 제거, S-CANVAS 글자/지형 텍스처/
       "GENERATIVE DESIGN & VISUALIZATION ENGINE" 서브타이틀은 보존됨.
     - 사이드바 폭 예산이 220 → 230px 로 약간 확대 (V2 이미지는 aspect 가 조금 더 와이드).
  3. **인트로 스플래시** (신규 `splash.py`, 210 LOC):
     - **기술**: cv2 `VideoCapture` (프로젝트 공통 dep) → PIL → Tkinter
       `Toplevel`+`Label`. 신규 외부 의존 0.
     - **역동적 효과**:
       (a) 알파 0→1 페이드인 400ms
       (b) MP4 고유 애니메이션 재생 (24fps, 192 frames, 8s, 1280×720)
       (c) 알파 1→0 페이드아웃 400ms → 자동 destroy
       (d) 화면 중앙 frameless 배치 (`overrideredirect(True)`), topmost
       (e) 비디오 아래 44px tag-bar 에 오렌지 italic 로 `"S-CANVAS — Generative
           Design & Visualization Engine"` 브랜드 라인
     - **안정성**:
       - `max_duration_s=12.0` safety cap (비디오 8s + fades 0.8s + 버퍼 3.2s).
       - 비디오 끝(`cap.read() → ret=False`) 자동 감지 → 페이드아웃.
       - 임시 `tk.Tk` 루트를 스플래시 전용으로 만들고 `mainloop` 종료 후 `destroy`
         → 이후 `SCanvasApp()` 가 새 `ctk.CTk` 인스턴스를 만들어도 충돌 없음.
       - 파일 없음/cv2 미설치/VideoCapture 실패 시 조용히 skip — 메인 앱 항상 기동.
       - 최대 표시 폭 `max_display_w=1000` 로 거대한 MP4 다운스케일 (cv2.INTER_AREA).
  4. **`__main__` 연결**: `SCanvasApp()` 기동 직전 `show_intro_splash(
     Path(__file__).parent / "Design" / "logo_intro.mp4")` 호출. try/except 로
     스플래시 실패가 메인 앱을 막지 못하도록.
- **검증**:
  - `ast.parse` OK (scanvas_maker.py + splash.py)
  - logo_V2 bg-strip: 36.7% 투명 / 49.5% 부분 알파 / 13.8% 불투명 — 부드러운 전이 확인
  - MP4 메타데이터 probe OK: 24fps × 192 frames = 정확히 8.0s, 1280×720

---

## 2026-04-24 (후속 3)

### [ui-redesign] Light 기본 테마 + 사이드바 섹션 카드형 + Saman CI 배경 제거

- **파일**: `scanvas_maker.py`
- **사용자 요청**:
  1. Default 테마를 Light 로 (글씨는 반드시 보여야 함)
  2. Design/Logo.png 가 투명 배경으로 교체됨 → 반영
  3. homepage_sample 참고해서 maker 창 예쁘게 리디자인
  4. SAMAN_CI 배경(흰색) 제거 가능? — 이전 디자인 불만족
- **수정**:
  1. **Light 기본 테마**: `ctk.set_appearance_mode("dark") → "light"`.
     `appearance_mode_optionemenu.set("Dark") → "Light"`. 사용자가 원하면 사이드바에서
     언제든 Dark 전환 가능.
  2. **SAMAN_CI 배경 제거**: 모듈 상단에 `_load_image_strip_white_bg(path,
     threshold=240)` 헬퍼 추가 — PIL RGBA 변환 후 (R,G,B ≥ threshold) 픽셀의
     알파를 0 으로. `threshold=235` 로 SAMAN_CI.gif 의 흰 배경 68.5% 투명화 확인.
     로고/텍스트 픽셀은 보존. 사이드바 푸터에 삽입, 폭 150px.
  3. **Light-mode 대비 보정** — 모든 하드코딩된 단일 색상을 `(light_hex, dark_hex)`
     튜플 쌍으로 교체해 CustomTkinter 테마 자동 스위칭:
     - 서브타이틀·섹션 헤더 `text_color=("#6C757D", "#9A9A9A")`
     - `map_frame` 배경 `"#2b2b2b"` → `("#FFFFFF", "#2b2b2b")` + 경계선 추가
     - `btn_detail` hover 이전에 light 모드에서 대비 실종(gray 텍스트 ↔ 다크 네이비
       hover) → `hover_color=("#E9ECEF", "#2C3E50")`, `text_color=("#6C757D",
       "#7F8C8D")`, `border_color` 테마 쌍.
     - `btn_reopen_3d` fg/hover 테마 쌍, text `#FFFFFF` 로 고정해 다크 배경 버튼에
       흰 글자 — light 모드에서 "secondary action" 버튼으로 뚜렷이 구분.
  4. **사이드바 시각 계층화** (homepage_sample 의 섹션 구분 패러다임 참고):
     - `_divider(pady)` 로컬 헬퍼 추가 — 1px 수평선, 테마 쌍 `("#DEE2E6",
       "#3F3F3F")`.
     - 헤더 ↔ SETTINGS, SETTINGS ↔ WORKFLOW, WORKFLOW ↔ OPTIONS, OPTIONS ↔
       Saman 크레딧 사이에 divider 삽입.
     - `WORKFLOW`, `OPTIONS` 섹션 헤더 신규(이전에는 SETTINGS 만 있었음) — uppercase
       10pt bold muted gray.
     - 기존 `"─"*22` 문자 구분선(line 374 경) 제거.
  5. **투명 로고 활용**: `Design/Logo.png` 가 알파 채널 포함으로 교체됨. 추가로
     `_load_image_strip_white_bg(threshold=240)` 를 거쳐 엣지에 남은 near-white
     픽셀까지 확실히 투명화(Logo 는 43.9% 알파 0, 2622×1600). CTkLabel
     `fg_color="transparent"` 로 사이드바 배경이 관통되어 로고 주변 블록 없이
     자연스럽게 어우러짐. SAMAN_CI 도 동일하게 `fg_color="transparent"` 적용.
  6. **기타 정리**:
     - 로고 padding `(18, 4) → (22, 6)`, 서브타이틀 아래 `(0, 12) → (0, 14)` 여백 확대.
     - `map_frame corner_radius 10 → 12`, `border_width=1` 추가.
  7. **Python 호환성 픽스**: 헬퍼의 `Path | str` PEP 604 union 타입힌트가 Python
     < 3.10 에서 TypeError → 타입 힌트 제거(런타임 영향 없음, 기능 동일).
- **검증**:
  - `ast.parse` OK
  - `importlib` 로드 OK (`SCanvasApp`, `_load_image_strip_white_bg` 모두 노출)
  - SAMAN_CI 배경 제거 확인: 520×169 이미지에서 60,178/87,880 = 68.5% 픽셀
    투명화 (흰색 영역이 정확히 알파 0 으로).
- **호환성**: 기존 워크플로 UI (버튼·콜백·상태바) 전부 동작 변경 없음. 시각 스타일만 교체.

---

## 2026-04-24 (후속 2)

### [rebrand] EG-VIEW → S-CANVAS 전면 리네이밍 + 사이드바 GUI 브랜드 적용

- **파일**: 코드 7개 + 자산 3개 + 파일 rename 1건
  - Python: `egview_maker.py → scanvas_maker.py` (mv), `gemini_renderer.py`,
    `detail_parser.py`, `dem_extender.py`, `structure_placement.py`,
    `structure_vlm_feedback.py`
  - YAML: `prompt_templates/prompt_v1.yaml`, `structure_types/structure_v1.yaml`
  - 자산: `Design/Logo.png`, `Design/SAMAN_CI.gif`, `Design/homepage_sample.png`
- **동기**: 프로젝트가 `EG-VIEW` (AI 기반 조감도 생성 시스템) → `S-CANVAS`
  (Generative Design & Visualization Engine) 로 리브랜딩. Saman Corp. CI 적용.
- **Phase 1 — 기계적 리네이밍**:
  1. 클래스 `EGViewApp` → `SCanvasApp` (`class`·인스턴스화·import 타입힌트 포함).
  2. 모듈 파일명 `egview_maker.py` → `scanvas_maker.py` (bash `mv`). 의존 파일의
     docstring/주석 내 `egview_maker` 참조도 모두 `scanvas_maker` 로 동기화.
  3. 런타임 경로: `egview_jobs.db → scanvas_jobs.db`, `egview_harness.log →
     scanvas_harness.log`, `egview_diagnostic.log → scanvas_diagnostic.log`.
     structlog logger name `"egview" → "scanvas"`.
  4. 사용자 대면 문자열 `EG-VIEW` → `S-CANVAS` (윈도우 타이틀·서브윈도우 타이틀·
     버튼 레이블·로그·PyVista plotter 제목·저장 파일명 prefix 등 19개소).
  5. User-Agent, YAML description, 모듈 docstring 내 브랜드 문자열도 교체.
  6. **보존**: `Build_log.txt`(사용자 원본 로그), `SAMPLE_CAD/*.dxf`(CAD 바이너리),
     `_unused/`(보관), 이 CHANGELOG 의 과거 기록(히스토리).
  - 기존 `egview_jobs.db`/`egview_*.log` 파일을 보존하려면 사용자가 수동으로
    `mv egview_jobs.db scanvas_jobs.db` (로그는 새로 생성됨).
- **Phase 2 — 사이드바 GUI 브랜드 적용** (`scanvas_maker.py` 사이드바 헤더·푸터):
  1. **로고 헤더**: 기존 `CTkLabel(text="EG-VIEW", size=24 bold)` → `Design/Logo.png`
     를 `CTkImage` 로 로드(220px 폭, aspect 유지). 파일 없거나 PIL 실패 시 텍스트
     `"S-CANVAS"` 폴백.
  2. **서브타이틀**: `"Phase 4: AI Rendering + Layer Classification"` →
     `"Generative Design & Visualization Engine"` (로고와 동일 문구, italic gray).
  3. **메인 액션 버튼**(`btn_step4` 4. AI 렌더링): `#1f538d/#14375e` (기존 blue) →
     `#E67E22/#BA6116` (Saman 브랜드 오렌지). 사이드바 내 유일한 warm 색상으로
     시각적 위계 강화. 나머지 Step/서브 버튼은 기존 blue 테마 유지.
  4. **Saman 크레딧 푸터**: 사이드바 최하단에 `Design/SAMAN_CI.gif` 140px 폭으로
     삽입(`CTkImage`, 팔레트 모드 → RGBA 변환). 실패 시 `"© Saman Corp."` 텍스트 폴백.
  5. **윈도우 타이틀**: `"EG-VIEW: AI 기반 조감도 생성 시스템"` → `"S-CANVAS —
     Generative Design & Visualization Engine"` (em-dash 로 브랜드 + 설명 분리).
- **손대지 않은 것**: 기존 로직(TIN 생성·DEM 확장·draping·AI 렌더링·Harness) 전부.
  이번 수정은 **브랜딩 + 사이드바 시각 요소**만 교체하고 파이프라인은 불변.
- **검증**: 리네이밍 대상 6개 Python 파일 `ast.parse` OK, 자산 PIL 로드 OK
  (Logo.png 2622×1600 RGBA, SAMAN_CI.gif 520×169 palette).

---

## 2026-04-24 (후속)

### [render-fix] DEM 확장 경계 사각 선 제거 — TIN+DEM ring seam-free 통합 렌더링

- **파일**: `egview_maker.py` (`show_3d_preview`, `_capture_from_camera`)
- **증상(error.png)**: Step 1.5 DEM 확장 후 3D 미리보기에 **원본 TIN bbox 경계가
  사각형 실선**으로 보임. Z·feather·smoothstep·Laplacian 은 이미 적용돼 있어
  Z 연속(C⁰)은 달성돼 있으나, 경계를 가로질러 **쉐이딩이 꺾여** 선으로 읽혔음.
- **원인 분석**:
  1. `tin_mesh` 와 `tin_extension_mesh` 가 **두 개의 별도 PolyData** 로 각각
     `p.add_mesh()` 호출(이전 show_3d_preview line 5133 & 5151/5154). PyVista 는
     메시 단위로 normal 을 계산·평균하므로 공유 경계 정점이라도 **각 메시 내부에서만**
     법선이 평균되어 seam 에서 normal 불연속 → 사각 쉐이딩 선.
  2. `dem_extender.py` 의 링 Z Laplacian(line 700-702)은 공유 경계 정점을 pin 해
     커널이 seam 을 넘지 못함. smoothing 커널 강화(Smootherstep 등)로는 이 선을
     못 지움 — 원인이 "커널이 약해서"가 아니라 "메시가 분리돼서"였음.
- **수정(1순위 구조적 통합 + 2순위 normal 평활 동시 달성)**:
  1. **`target_mesh.merge(ext_mesh, merge_points=True, tolerance=0.01)`**: 공유
     경계 정점을 weld(용접). Z 는 `_reinterpolate_tin_boundary_with_dem` 에서
     bbox 정점까지 DEM 강제 매칭돼 있어 0.01m 톨러런스로 안전하게 동일 정점으로
     병합됨 → 위상적으로 **단일 연속 표면**(1순위 unified Delaunay 의 효과).
  2. **`compute_normals(feature_angle=180.0, auto_orient_normals=True,
     consistent_normals=True)`**: 모든 edge 를 smooth 처리 → 경계에서 normal 평균.
     쉐이딩 불연속 원천 제거(2순위 seam normal averaging 의 효과).
  3. **폴백 경로 유지**: `merge`/`compute_normals` 예외 발생 시 기존 2-mesh
     `add_mesh` 경로로 복구. 확장 메시가 없을 때도 legacy 경로.
  4. **캡처 경로 동일 적용**(`_capture_from_camera`): AI 프롬프트용 캡처 이미지에
     사각 선이 남으면 AI 가 실제 지형 피처로 오인할 수 있어, 인터랙티브 뷰어와
     동일 파이프라인. depth 캡처(`_capture_depth_from_camera`)는 normal 무관이라 변경 없음.
- **후속 버그픽스(error1.png)**: 텍스처 모드에서 `Input mesh does not have texture
  coordinates to support the texture` 오류 발생. 원인: `merge(merge_points=True)`
  → `extract_surface()` 경로에서 PolyData 의 TCoords(UV) 가 소실됨.
  - 해결: `btn_draping_callback` 에서 UV 파라미터 `(origin_uv, point_u, point_v)` 를
    `self._uv_mapping_params` 로 저장. `show_3d_preview`·`_capture_from_camera`
    에서 merge 후 텍스처 모드일 때 동일 파라미터로 `texture_map_to_plane` 재적용.
  - 순서 중요: **UV 재매핑 먼저 → compute_normals(inplace=True)**. texture_map_to_plane
    은 새 메시를 반환(inplace=False)하므로 normal 을 그 이후에 붙여야 같은 메시에 유지됨.
  - 확장 메시가 없는 경로(else)에서는 `self._uv_mapping_params = None` 로 명시적 비활성.
- **불변(손대지 않은 것)**: `dem_extender.py` 의 링 생성·smoothstep feather·
  Laplacian 1pass·slope_ratio 벽 컷 모두 유지. 문제는 링 내부가 아닌
  **렌더 단계의 메시 분리**였음.
- **검증**: `ast.parse` OK (3회).

---

## 2026-04-24

### [ui-fix] "TIN 이용 범위" 창 — 제출 버튼이 아래로 잘리는 문제

- **파일**: `egview_maker.py` (`btn_select_core_range_callback`)
- **증상**: 창이 뜨면 "✅ 선택 결과 제출" 버튼이 화면 아래로 잘려 보이지 않고,
  사용자가 창 아래쪽 테두리를 잡아 세로로 드래그해야만 버튼이 나타남.
- **원인**: 창 기본 높이 820px 에 비해 내부 컨텐츠 요청 합계가 큼.
  - info_frame(~62) + slide_frame(~40) + canvas figure(10,7)@100dpi = **700** +
    toolbar(~35) + btn_row(~50) + submit_frame(~76) ≈ **963px > 820px**.
  - 게다가 pack 순서가 `canvas(expand=True)` → `submit_frame/btn_row(side=bottom)` 순이라,
    Tk 가 matplotlib 캔버스 요청 크기를 먼저 확보하려 해 하단 버튼 바가 창 바깥으로 밀림.
- **수정**:
  1. **기본 창 크기 확대**: `1100x820` → `1100x920`, `win.minsize(900, 640)` 추가.
     작은 해상도에서도 최소 높이가 유지되어 내부 overflow 발생 시 창 리사이즈로 해소 가능.
  2. **pack 순서 재배치** (핵심): `submit_frame`·`btn_row` 를 **캔버스보다 먼저** `side="bottom"`
     으로 pack → Tk 가 고정 높이 하단 위젯에 공간을 먼저 배정, 나머지를 `expand=True` 캔버스가 차지.
     - 이전: `info → slide → canvas(expand) → [tb 자동 bottom] → submit → btn_row` (submit 이 clip 됨)
     - 수정: `info → slide → submit → btn_row → tb(manual bottom) → canvas(expand)` (submit 항상 보장)
  3. **툴바 수동 pack**: `NavigationToolbar2Tk(canvas, win, pack_toolbar=False)` 로 자동 pack 끄고
     `tb.pack(side="bottom", fill="x")` 수동 배치 → btn_row 위(캔버스 바로 아래) 위치 확정.
  4. 후반부에 있던 중복 `submit_frame`/`btn_row` 생성 블록 제거 (상단에서 이미 생성·pack 됨).
- **최종 수직 스택(위→아래)**: info_frame · slide_frame · canvas(expand) · toolbar · btn_row · submit_frame.
- **검증**: `ast.parse` OK.

---

## 2026-04-23 (후속 6)

### [ui] "TIN 이용 범위" — 실시간 드래그 사각형 + 눈에 띄는 제출 바

- **파일**: `egview_maker.py` (`btn_select_core_range_callback`)
- **사용자 요청**: (1) 그냥 드래그로 선택, (2) 드래그 중 마우스 따라 사각형이 실시간 이동,
  (3) 선택 완료 후 결과를 제출하는 버튼이 명확하게 보여야 함.
- **수정**:
  1. **2클릭 방식 / RectangleSelector 제거** → `button_press_event` + `motion_notify_event`
     + `button_release_event` 수동 핸들러로 교체.
     - press: 드래그 시작점 기록, 기존 사각형 제거.
     - motion: 마우스 따라가며 반투명 빨강 사각형(`facecolor="#E74C3C"`, `alpha=0.18`)을
       매 이벤트마다 그려 **실시간 추적** + 상태 라벨에 현재 크기(m) 표시.
     - release: 최종 bbox 확정, core_rect + 전이대 주황 사각형 표시.
     - `toolbar.mode`(pan/zoom) 활성 시 자동 무시.
  2. **제출 버튼 강조**:
     - 창 최하단에 어두운 배경의 `submit_frame` 추가.
     - 그 안에 `height=56`, `font=16pt bold`, `#2ECC71` 초록 풀폭 버튼
       "✅  선택 결과 제출 (이 범위를 정밀 TIN core 로 확정)" 배치.
     - 보조 버튼(재선택·전체 사용·닫기)은 위쪽 얇은 행에.
     - **Enter 키** 로도 제출, **Esc 키** 로 닫기.
  3. **제출 시 클램프 결과를 화면 반영**: TIN bbox 초과 시 클램프 후 `_draw_bbox()` 재호출
     → 사용자가 최종 확정된 사각형을 제출 직전에 시각 확인.
  4. **상태 라벨**: 드래그 중엔 "드래그 중 W×H m  X=[..] Y=[..]" 실시간 업데이트.
  5. 제목/안내 텍스트를 "좌클릭 드래그" 중심으로 단순화. `RectangleSelector` import 제거.
- **검증**: `ast.parse` OK.

---

## 2026-04-23 (후속 5)

### [remove] Step 3.5 "제어맵 배경 확장" 제거

- **파일**: `egview_maker.py` (사이드바 버튼 + `btn_extend_control_maps_callback` +
  `_render_ext_only` + `_overlay_non_background`)
- **사유**: Step 1.5 가 DEM 확장 메시를 통합 생성하고, Step 2 위성 합성이 이 확장 범위를
  그대로 이어받으며, Step 3 제어맵도 확장본 포함 장면에서 추출되므로 별도의 제어맵 배경
  확장 단계(3.5)는 중복 작업이 됨. 특히 "🎯 TIN 이용 범위 (후속 4)" 도입 후 core+blend
  경계가 링까지 연속 메시로 이어져 3.5 없이 자연스러운 AI 입력 캔버스가 만들어짐.
- **삭제 항목**:
  - 사이드바 버튼 `self.btn_step3p5`.
  - 콜백 `btn_extend_control_maps_callback` (약 85줄).
  - 내부 헬퍼 `_render_ext_only` (확장 메시 단독 offscreen 렌더).
  - 내부 헬퍼 `_overlay_non_background` (non-bg 픽셀 마스크 합성).
  - 사용되지 않게 되는 `capture_extended.png`/`depth_extended.png`/`lineart_extended.png`/
    `guide_extended.png` 출력 경로도 더 이상 생성되지 않음 (파일은 사용자 디스크에 그대로
    잔존, 다음 실행에서 덮어쓰기 없음).
- **영향 범위**: Step 4 AI 렌더링은 Step 3 가 만든 `capture_for_ai.png` 를 그대로 사용
  (3.5 가 덮어쓰지 않으므로 Step 3 의 확장 포함 제어맵이 그대로 입력됨).
- **검증**: `ast.parse` OK. 잔존 참조(grep) 0건.

---

## 2026-04-23 (후속 4)

### [feat] 🎯 "TIN 이용 범위" — 3-zone 정밀 TIN / Transition / DEM 확장

- **파일**: `egview_maker.py` (신규 `btn_select_core_range_callback`, `_apply_core_precision_zone`;
  `btn_extend_tin_with_dem_callback` 분기; `__init__`/`btn_tin_callback` 상태; sidebar 버튼)
- **사용자 동기**: 도면 전체가 아니라 **사용자가 정밀하게 필요한 영역** 만 원본 측점 Z 그대로
  유지하고, 경계/외곽은 DEM 으로 부드럽게 확장해 튀는 Z·fin 을 원천 차단. 해상도 격차(TIN 1m
  vs DEM 15.5m)를 전이대로 격리.
- **UI 추가**:
  - 사이드바에 **"🎯 TIN 이용 범위 (정밀 구역)"** 버튼 (Step 1과 Step 1.5 사이).
  - Toplevel 창 (1100×820): matplotlib terrain 탑뷰 + colorbar + 도면 bbox(점선).
    - 입력 방식 2종: (1) 좌상단 → 우하단 2클릭, (2) RectangleSelector 드래그박스.
    - 슬라이더: 전이대 폭 20~300m (기본 80m). 실시간 주황 점선 사각형으로 미리보기.
    - 통계 라벨: core 크기(m², km²), 포함 측점 수, Z 범위.
    - 버튼: "재선택", "전체 사용 (core 해제)", "✅ 확인".
- **3-zone 알고리즘** (`_apply_core_precision_zone`):
  - `_signed_distance_to_polygon` (기존) 재사용 → core bbox 로부터의 부호 있는 거리 d.
    코너 주변도 L2 거리로 자연 smooth.
  - `d ≤ 0` (core 내부) → **TIN Z 그대로** (원본 측점 100% 보존).
  - `0 < d < blend_width_m` (전이대) → `w_dem = smoothstep(d/blend)` 가중치로
    `z = (1-w)*z_tin + w*z_dem_aligned` 블렌드. smoothstep 은 C1 연속이라 경계/폭 끝에서
    미분 0 → 절벽 fin 0.
  - `d ≥ blend_width_m` (DEM zone) → `z_dem_aligned = z_dem - self._dem_datum_offset`
    (TIN densify 시 저장한 값 재사용 → datum 일관).
  - XY 불변, Z만 덮어씀 → Delaunay 재계산 불필요.
  - 멱등성: `self._tin_core_original_points` 에 원본 Z 백업해서 재실행해도 결과 동일.
- **Step 1.5 분기** (`btn_extend_tin_with_dem_callback`):
  - `use_core = self.tin_core_bbox is not None` 체크.
  - core 모드:
    1. `_apply_core_precision_zone()` 호출 — TIN Z 를 3-zone 재구성.
    2. `_fill_tin_bbox_gap_with_dem` **skip** (전이대가 이미 DEM-aligned).
    3. `build_extended_terrain_ring(projected_bounds=core+blend, ...)` — 링의 inner
       경계를 전이대 바깥 = core+blend 로 설정. 따라서 core → transition(TIN 안쪽) →
       ring(DEM 바깥) 이 하나의 연속 표면으로 연결.
  - non-core 모드: 기존 경로 유지.
- **엣지 가드**:
  - core < 20m × 20m: "너무 작음" 경고.
  - core 에 측점 < 3: "측점 부족" 경고.
  - core 가 TIN bbox 벗어남: TIN bbox 로 자동 클램프.
  - `blend_width_m` 가 core 의 1/4 초과: 자동 제한 + 로그.
  - TIN 재생성 (Step 1 재실행): `tin_core_bbox=None` 으로 리셋.
  - DEM 격자 미수신(`_dem_elev_grid=None`): 3-zone skip → legacy 경로 폴백.
- **검증**:
  - `ast.parse` OK.
  - 스모크: `_signed_distance_to_polygon` + smoothstep weight 계산 검증 —
    d=-200→w=0, d=0→w=0, d=50→w=0.684, d=100/200→w=1.0 (C1 연속 확인).
- **장점**:
  - 사용자가 원하는 정밀 구역(측량 Z)과 DEM 확장(광역 Z)을 **수식으로 명확히 분리**.
  - 전이대가 해상도 격차(1m↔15.5m)를 흡수 → core 경계에 보이던 fin/네모박스 시각 크리스 소실.
  - PoC 단계에서 고정밀 한국 DEM(NGII 5m) 없이도 **측량 영역만 정확히 유지**하는 실용적 대안.

---

## 2026-04-23 (후속 3)

### [fix] DEM 링 확장 — TIN 침범/톱니 fin/NaN/구멍 동시 제거 (error.png, error1.png)

- **파일**: `dem_extender.py` (`_generate_ring_points_hull`, `build_extended_terrain_ring`)
- **증상**:
  1. 확장 DEM 링이 TIN bbox 를 **침범**해 TIN 위를 이상한 값으로 덮음 (error.png 검은/갈색 패치).
  2. 외곽 링에 **톱니/벽돌 fin** 이 전반적으로 발생 (error1.png bbox 외곽 전체).
  3. 접합점에 NaN/빈 공간 가능성.
- **원인 분석**:
  - **침범**: `_generate_ring_points_hull` 의 `outside_grid` 가 TIN bbox 바로 바깥·선 위까지 격자점을 그대로 둬서, TIN bbox 공유정점과 섞여 Delaunay 가 **bbox 선을 가로지르는 납작 삼각형** 을 생성. `has_strict_vertex` 가드는 3 정점 모두 경계 위인 경우 통과 → **엣지 중점** 이 bbox 내부로 진입해 TIN 위에 그려짐.
  - **톱니 fin**: outer radial ramp-down 이 각 점별로 "이웃 하위 10% 분위수" 로 강제 하강 → 점마다 하강량이 달라 표면에 수직 fin.
  - **공유정점 gap**: TIN bbox 공유정점 간 간격(50m) > 외곽 grid_step(40m)일 수 있어 bbox 변에 큰 삼각형이 생겨 TIN 경계선이 꺾여 보임.
  - **NaN**: terrarium 타일 엣지 디코딩 실패 → 스파이크 제거는 되지만 NaN 채움 경로가 전역 outlier 뒤 단계에만 의존.
- **수정**:
  1. **`_generate_ring_points_hull` 재작성**:
     - `outside_grid` 에서 bbox + `guard = grid_step*0.5` 안쪽 격자점 모두 제거 → 링 삼각형이 bbox 선을 가로지르지 못함.
     - `precomputed_boundary` 에 **outer grid_step 과 같은 밀도로 densify** — TIN 공유정점을 유지한 채 gap > grid_step 구간 선형 보간 점 삽입.
     - `outside_grid` 중 boundary_pts 와 `< grid_step*0.25` 거리인 점은 제거 (sliver 방지).
  2. **outer ramp-down → outer smooth blend** 교체:
     - 타겟 Z = 반경 이웃 **평균** (분위수 폐기).
     - smoothstep 가중치 유지, 이웃 반경을 `grid_step*6` 로 확대.
     - 최종 편차 `|Δ| ≤ grid_step*0.8` 캡 → 튀는 값 원천 차단.
  3. **inner 제거 3중 가드** (`build_extended_terrain_ring`):
     - (a) centroid strict 안쪽 + (b) 세 정점 중 하나 strict 안쪽 + **(c) 엣지 중점 중 하나 strict 안쪽** 추가.
     - (c) 는 "세 정점 모두 bbox 변 위" 인 납작 삼각형의 엣지 중점이 내부로 진입하는 경로를 차단.
  4. **5c Z 설정 개선**: 공유정점은 TIN Z 그대로, densify 신규 점은 **k=2 거리 가중 TIN Z 보간** → seam Z 점프 0.
  5. **NaN 삼중 가드**:
     - 4a 단계에서 NaN 을 전체 median 으로 **즉시** 채움(outlier clip 이전).
     - 5d 단계에서 최종 z_final NaN 체크 → 이웃 값 채움.
     - `_sample_grid_bilinear` 반환 전에도 NaN median 채움(기존).
  6. **5e 링 Laplacian 1pass 평활** — TIN 공유 경계 정점은 고정, 링 내부만 이웃 평균 50% 블렌드 → 잔존 톱니 미세 fin 제거.
- **스모크 테스트** (1.8×1.3km TIN, 40m grid, 1000m buffer):
  - Strictly inside TIN bbox ring points: 0 (침범 0).
  - NaN/Inf in Z: False.
  - inner 제거: centroid 308 + strict-vertex 0 + edge-midpoint 308개 차단.
  - 벽 컷: 대상 없음 (seam 471개 보호).
  - Z 범위 정상 (46~501m).
- **검증**: `ast.parse` OK.

---

## 2026-04-23 (후속 2)

### [fix] DEM 확장 내부 네모박스 경계선 + 접합점 구멍 제거 (error.png 두 이슈 동시 해결)

- **파일**: `egview_maker.py` (`_reinterpolate_tin_boundary_with_dem`, `create_tin_from_dxf`,
  `_fill_tin_bbox_gap_with_dem`, `btn_extend_tin_with_dem_callback`),
  `dem_extender.py` (`build_extended_terrain_ring`)
- **증상** (error.png 두 이슈):
  1. 생성된 TIN 안에 "또다른 네모박스 같은 경계선"이 또렷하게 보임.
  2. 일부 확장 접합점에 삼각형 구멍(빈 공간).
- **원인 진단**:
  - **내부 네모박스 = 평활 경계의 시각화**. 두 개의 서로 다른 "평활 링" 경계:
    - `create_tin_from_dxf`의 bbox 4변 Z smoothing (win=5, 3pass) + 30m 안쪽 feather 블렌드
      → 30m 안쪽 거리에서 평활 Z vs 자연 DEM Z 크리스(crease)가 선처럼 보임.
    - `_reinterpolate_tin_boundary_with_dem`의 Laplacian smoothing 4pass (feather_m=200m)
      → 200m 거리에서 평탄화된 Z vs 자연 DEM Z 경계가 또 다른 선처럼 보임.
  - **접합점 구멍 = 벽 컷이 자연 급사면까지 삭제**:
    - `build_extended_terrain_ring`: `slope_ratio>2.5 & Z>10m` 컷이 실제 70° 산사면을 제거
    - `_fill_tin_bbox_gap_with_dem`: `slope_ratio>1.5 & Z>5m` 컷이 TIN–DEM 접합부 삼각형을 제거
  - **부가 원인 — datum offset 불일치**. 3곳에서 서로 다른 offset 계산:
    1. `create_tin_from_dxf`: 원본 DXF vs DEM median
    2. `_fill_tin_bbox_gap_with_dem`: (DXF + Phase 추가점) vs DEM median → 0에 편향
    3. `build_extended_terrain_ring`: 경계 근처 TIN vs DEM median
    세 값이 다르면 bbox 내/외 DEM Z에 단차가 생김.
- **수정**:
  1. **Laplacian smoothing 4pass 제거** (`_reinterpolate_tin_boundary_with_dem`). smoothstep
     블렌드만으로 C1 연속이 확보되므로 추가 평활은 네모박스 경계선의 원인. 블렌드 로직은 유지.
  2. **bbox 4변 Z smoothing + 내부 feather 블렌드 제거** (`create_tin_from_dxf`).
     Phase A(50m 변 densify) + Phase C(1m 내부 grid)가 이미 모든 bbox/내부 점에 동일한 DEM
     샘플 Z를 주므로 자연스럽게 연결됨. 인위적 평활이 오히려 크리스를 만듦.
  3. **벽 컷 완화**:
     - `_fill_tin_bbox_gap_with_dem` v7: `slope_ratio>3.0(≈72°) & z_span>20m & e_max>5m`
       (이전 v6: 1.5/5m). 자연 급사면 보존.
     - `build_extended_terrain_ring`: `slope_ratio>4.0(≈76°) & Z스팬>30m & e_max>5m`
       (이전 2.5/10m). **추가: TIN 공유 boundary 정점을 가진 삼각형은 컷 제외** → 접합점
       구멍의 직접 원인 제거.
  4. **Datum offset 통일** (`_dem_datum_offset`/`_dem_elev_grid`/`_dem_grid_bounds`):
     - `create_tin_from_dxf`가 계산한 offset을 `self._dem_datum_offset`에 저장.
     - `_fill_tin_bbox_gap_with_dem`가 저장된 offset + elev_grid을 **재사용** (네트워크 절약
       + datum 일관성).
     - `build_extended_terrain_ring`에 신규 파라미터 `datum_offset_override`,
       `elev_grid_override`, `grid_bounds_override` 추가. `btn_extend_tin_with_dem_callback`
       에서 저장된 값 넘김 → bbox seam에서 **동일 offset 적용** → Z 단차 0.
  5. **CRS 진단 로그 강화** (`create_tin_from_dxf`): "per-point 점변환이라 XY 평면
     warp/resample **왜곡 없음**. 왕복 오차 N m" 메시지를 TIN 생성 시에도 출력해
     사용자가 좌표 변환 정확성을 즉시 확인 가능.
- **효과**:
  - **내부 네모박스 경계선 제거**: 두 평활 링 제거로 bbox 내부 Z가 자연 DEM 분산 그대로
    → 크리스 선 소실.
  - **접합점 구멍 제거**: seam 삼각형 보호 + 완화된 slope_ratio 기준으로 자연 급사면 보존.
  - **bbox seam Z 단차 0**: 통일 offset으로 bbox 내/외 DEM Z가 동일 datum.
  - **속도 향상**: DEM 타일 2~3회 다운로드 → 1회로 축소 (격자 재사용).
- **호환성**: `build_extended_terrain_ring` 신규 파라미터 모두 `Optional=None` 기본값 →
  기존 호출은 무변경으로 동작.
- **검증**:
  - `ast.parse` OK (dem_extender.py, egview_maker.py 모두).
  - 스모크 테스트: override 미지정 → 자동 offset +58.44m; override=+55.0 지정 → 그대로 반영.
    CRS 왕복 오차 0.0000m 확인.
  - 벽 컷: 테스트 mesh에서 "대상 없음 (seam 12개 보호) — 자연 경사면 유지" 로그.

---

## 2026-04-23

### [fix] 경계 sliver 컷 v5 → v6 — 절대 edge 기준 폐기, slope_ratio 기반으로 교체 (내부 구멍 해소)

- **파일**: `egview_maker.py` (`create_tin_from_dxf`의 v5 컷, `_fill_tin_bbox_gap_with_dem` 하단 v5 컷)
- **증상** (error.png): TIN bbox 4변까지 densify(Phase A/B/C)가 정상 동작했음에도, DEM 확장 **전** 단계에서 이미 bbox 내부에 듬성듬성 검은 구멍이 발생. DEM 확장 후에도 그대로 남음.
- **원인**: 두 곳의 v5 sliver 컷이 모두 `edge > median × 4.0` **절대 기준**. 정상 산지 경사면(z_span 크지만 xy_edge도 큼 → slope_ratio 작음)까지 "long edge"로 오판해 함께 삭제 → 내부 구멍. `memory/feedback_wall_root_cause.md` Rule 2 위반: *"절대 Z/edge 기준은 정상 경사면까지 지워서 듬성듬성 구멍을 낸다. slope_ratio 기준으로만 판정하라."*
- **수정 (v6, 2곳 동일 로직)**:
  1. 각 삼각형의 `z_span = max(Z) - min(Z)`, `max_xy_edge = max(edge len)`, `slope_ratio = z_span / max_xy_edge` 계산.
  2. 컷 조건: `bbox 4변 접촉` AND `slope_ratio > 1.5` (≈56°) AND `z_span > 5m`.
     - 정상 경사면: z_span 크지만 xy_edge도 크므로 slope_ratio 작아 살아남음.
     - 수직 벽: z_span 크고 xy_edge 작으므로 slope_ratio 큼 → 제거.
     - 평탄 bbox 삼각형: z_span 작으므로 gate 아래 → 살아남음.
  3. bbox 4변 접촉 기준은 유지 (내부 cave-in은 여전히 불가침).
- **효과**:
  - Phase A(50m bbox edge) + Phase C(10→1m hull 바깥 grid) + Phase B densify 결과가 사라지지 않음 → **bbox 4변까지 꽉 찬 TIN**.
  - 내부 정상 삼각형 절대 삭제 안 함 → **구멍 없음**.
  - 실제 수직 벽(Z 점프 큰 sliver)만 제거 → 경계 벽도 여전히 억제.
- **로그**: "TIN 경계 벽 컷 v6: N개 제거 (bbox 4변 접촉 + slope_ratio>1.5(≈56°) + z_span>5m) — 정상 경사면 100% 보존" 또는 "제거 대상 없음 — bbox 4변까지 꽉 찬 TIN".
- **검증**: `ast.parse` OK.

### [feat] Step 1.5-a 빈공간 DEM 보간 → TIN 재생성 → 1.5-b 외곽 확장 순서화

- **파일**: `egview_maker.py` (`btn_extend_tin_with_dem_callback`, 신규 `_fill_tin_bbox_gap_with_dem`)
- **사용자 요구**: bbox 내부 빈 공간(hull 바깥 × bbox 내부)이 그대로 비어 있는 채로 DEM 외곽 확장이 붙고 있음. **먼저 빈공간을 DEM으로 보간해 채운 다음에 외곽 확장**하도록 순서화.
- **수정**:
  1. Step 1.5 콜백 실행 순서 분리:
     - **1.5-a** `self._fill_tin_bbox_gap_with_dem()` — bbox 내부 hull 바깥을 10→1m 점진 DEM densify + Delaunay 재계산 → tin_mesh의 hull이 bbox와 일치.
     - **1.5-b** `build_extended_terrain_ring(...)` — 이미 꽉 찬 bbox에서 외곽 DEM 확장 링 생성.
     - **1.5-c** `_reinterpolate_tin_boundary_with_dem(...)` — 기존 경계 Laplacian smoothing.
  2. 신규 helper `_fill_tin_bbox_gap_with_dem`:
     - 현재 tin_mesh.points를 절대좌표로 변환 → DEM 타일 1회 fetch → datum offset 계산.
     - step ∈ (10,9,…,1)m 10단계 루프에서 매번 `ConvexHull`로 현재 hull 재계산 → bbox 격자 중 hull 바깥·`step×0.4` 이상 떨어진 점만 DEM 샘플로 추가.
     - 완료 후 Delaunay 재계산 + v5 sliver 컷 + `pv.PolyData` 재생성 → `self.tin_mesh` 교체.
- **효과**: Step 1.5 호출 한 번에 "빈공간 채우기 → 외곽 확장 → 경계 smoothing"이 순서대로 실행. 사용자가 본 빈공간 + DEM 중첩 문제 해소.
- **로그**: "[Step 1.5-a 빈공간 채움] DEM datum offset=±…m" → "N개 DEM 점 추가 [10m:…, 9m:…]" → TIN 재생성 "정점 N, 삼각형 M".
- **검증**: `ast.parse` OK.

---

## 2026-04-22

### [revert] Phase A0 + A 재작성 되돌림 — 이전 동작으로 복귀

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **사유**: 사용자 피드백 — 직전 수정(A0 코너 추가 + A 전 구간 densify) 결과가 이상해짐. 바로 전 상태로 롤백.
- **복원된 Phase A**: 측점 간 내부 gap만 `np.diff(main_coord)`로 densify. n_on<2인 변은 코너 간 전체 span을 densify. A0 블록 제거.
- **검증**: `ast.parse` OK.

### [feat] Phase C 점진 densify — 10m → 1m 격자로 bbox 꽉 채움

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **사용자 요구**: 삼각망 10m 간격으로 시작, 격자가 영역보다 커서 보간이 안 되면 1m씩 줄여가며 bbox 꽉 채우기.
- **수정 (Phase C 확장)**:
  1. `for _step in (10, 9, 8, 7, 6, 5, 4, 3, 2, 1)` — 10단계 반복.
  2. 각 step마다:
     - `ConvexHull(pts[:, :2])`로 **현재** hull 재계산 (이전 step의 추가 점이 반영된 새 hull).
     - bbox 내부 grid → hull 바깥 필터.
     - 기존 점과 **`step × 0.4` 이하** 거리 점은 제외 (중복 방지).
     - 남은 점만 DEM Z 샘플로 append.
  3. 각 step별 추가 개수를 로그에 누적: `[Phase C] 총 N개 추가 [10m:a, 9m:b, …, 1m:z]`.
- **동작 예시**:
  - 큰 도면: 10m에서 대부분 채워지고 이후 step은 0~소량 추가하며 빠르게 종료.
  - 좁은 틈새/작은 bbox: 10m 격자가 안 맞으면 1m까지 내려가며 점진적으로 꽉 채움.
- **중복/성능 가드**: `cKDTree` nearest로 `step × 0.4` 거리 필터 → 같은 영역에 점이 무한히 누적 안 됨. hull 재계산은 scipy ConvexHull이 수천 점에서 ms 단위라 10회 반복 부담 미미.
- **검증**: `ast.parse` OK.

### [feat] Phase C 추가 — hull 바깥 bbox 내부 grid densify로 사각형 꽉 채움

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **증상** (error.png): TIN이 convex hull 모양이라 bbox 4모서리까지 닿지 않음. hull 외곽 ~ bbox 사이 영역이 검은 빈 공간으로 드러남.
- **원인**: Phase A는 bbox **변 위** 점만, Phase B는 **hull 내부** 긴 edge만 처리. hull 외곽과 bbox 사이의 "면적" 영역은 어느 phase도 커버 안 함.
- **수정 (Phase C 신규, 순서 A → C → B로 재배치)**:
  1. `ConvexHull(pts[:, :2])`로 현재 TIN hull polygon 계산.
  2. bbox 전체를 `grid_step = 50m` 격자로 찍고, `MplPath(hull_poly).contains_points`로 **hull 안쪽 제거** → hull 바깥 × bbox 내부 점만 남음.
  3. 이 점들의 Z를 `_dem_sample_minus_offset`로 DEM 샘플링 후 pts에 append.
  4. Phase B는 C 이후에 돌려서 hull이 bbox로 확장된 상태에서 남은 긴 edge만 centroid densify.
- **효과**: 최종 Delaunay의 convex hull이 bbox와 일치 → 평면 탑뷰에서 사각형이 TIN으로 꽉 참.
- **로그**: "[Phase C] hull 바깥 × bbox 내부 grid densify: N개 추가 (step=50m) — bbox 꽉 채움".
- **검증**: `ast.parse` OK.

### [feat] 삼각망 조밀화 2-phase — 경계 gap 50m + 내부 긴 edge centroid densify

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **사용자 제안**: 삼각망 간격 자체를 줄이면 경계 벽도 작아지고 내부도 부드럽지 않겠나?
- **수정**: 단일 "경계 gap densify"를 2-phase로 확장.
  - **Phase A (축소)**: `_gap_thresh = 100m → 50m`. 경계 인접 측점 간격이 50m 넘으면 DEM 샘플 점을 중간에 채움.
  - **Phase B (신규)**: 임시 Delaunay 수행 → `edge_max > 50m` 삼각형 **중심(centroid)**에 DEM 샘플 점 추가. 최종 Delaunay 때 그 삼각형은 더 작은 3개로 세분화됨.
  - **DEM fetch·datum offset 1회**만 계산해 Phase A·B에서 공유 (`_dem_sample_minus_offset` 헬퍼).
  - 측점 XY/Z는 여전히 불변. 추가는 항상 append.
- **효과**:
  - 경계 sliver가 50m 단위로 촘촘 → 벽이 시각적으로 소멸.
  - 내부 삼각형도 edge 50m 이하로 균일 → 지형 표현 부드러움, Step 1.5 DEM 확장 seam도 매끄럽게.
- **로그 (2줄)**: "[Phase A] bbox gap densify: N개 추가 (간격>50m)" + "[Phase B] 내부 긴 edge densify: M개 삼각형 중심 추가 (edge>50m) — 삼각망 조밀화".
- **검증**: `ast.parse` OK.

### [feat] TIN bbox 4변 gap DEM densify — 측점 원본 보존, 100m 초과 구간만 DEM 보충

- **파일**: `egview_maker.py` (`create_tin_from_dxf`), `dem_extender` import 확장
- **사용자 통찰**: 문제는 코드보다 raw data — 측량 성과(측점)는 정확하지만 **bbox 4변에 측점 간격이 100m 넘게 벌어진 구간**에서 Delaunay가 수직 벽 sliver를 만듦. DEM 공간해상도는 낮지만 gap 보충 용도로는 충분.
- **수정**:
  1. `dem_extender` import에 `fetch_terrarium_grid`, `_sample_grid_bilinear` 추가.
  2. `create_tin_from_dxf`에서 bbox 계산 직후 (origin 설정 **전**):
     - bbox 4변별로 측점을 수직좌표 순 정렬 → 인접 측점 gap > 100m 구간마다 DEM 샘플 점 삽입.
     - 측점이 2개 미만인 변은 bbox 코너 간 전체 span을 100m 간격으로 densify.
     - 추가 점 XY는 해당 변 위 (fixed + interpolated coord), Z는 `_sample_grid_bilinear`로 DEM 샘플.
     - **Vertical datum 보정**: 측점 전체 XY에서 DEM 샘플링 → `offset = median(dem_z - 측점_z)` → 새 점 Z에서 차감 → DEM이 측점 수준에 맞춰짐.
  3. 원본 측점(`pts`)은 **변경 없이 그대로** — 측량 정확성 100% 보존.
- **효과**:
  - bbox 4변이 100m 이내 간격으로 조밀해져 Delaunay가 경계 수직 벽 sliver 못 만듦.
  - 이후 [bbox 종잇장 처리] smoothing + Step 1.5 DEM 확장까지 모든 단계가 자연스럽게 이어짐.
- **로그**: "TIN bbox gap DEM densify: N개 점 추가 (간격>100m 구간, datum offset=±…m) — 측점 원본 보존, DEM은 gap 보충".
- **검증**: `ast.parse` OK.

### [feat] TIN bbox 4변 종잇장 처리 — 생성 단계에서 경계 벽 원천 제거

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **사용자 의도** (WANT.png 설명): TIN 내부는 지금처럼 삼각망으로 다 채우되, **bbox 4변 경계**만 끝점끼리 부드럽게 이어 "한 장의 종잇장 가장자리"처럼 만들기. 그래야 바닥까지 보간해야 하는 수직 벽이 안 생기고 DEM 확장 시에도 매끄럽게 이어짐.
- **근본 원인**: bbox 4변에 놓인 정점들의 Z가 정점별로 들쑥날쑥(DXF 등고선 끝점이 불규칙) + 바로 안쪽 정점과의 Z 단차 → Delaunay가 수직에 가까운 경계 삼각형 생성 → 3D에서 벽처럼 보임.
- **수정** (`create_tin_from_dxf` Delaunay **직전** 삽입):
  1. **1단계: bbox 4변 정점 Z 1D smoothing**. 각 변(좌/우/상/하)별로 정점을 수직 좌표 순으로 sort → `win=5, 3 pass` moving average → 변 따라 Z 곡선이 매끄러운 종이 가장자리.
  2. **2단계: 내부 feather 블렌드**. bbox에서 `feather_m = max(15m, bbox_size × 2%)` 이내 내부 정점 Z를 **가장 가까운 경계 smoothed Z**와 smoothstep 블렌드 → 안쪽에서 경계까지 완만히 수렴. 경계에서 edge Z 100%, feather_m 안쪽에서 원본 TIN Z 100%.
  3. **XY 불변, feather 바깥 내부 정점 Z 불변** → 도면 내부 지형 전면 보존.
- **효과**:
  - bbox 4변 수직 벽 소멸 (Z가 변 따라 부드럽게 이어지므로 Delaunay도 sliver 안 만듦).
  - Step 1.5 DEM 확장 시 TIN 경계 Z가 이미 매끄러우므로 DEM과 자연 seam.
- **로그**: "TIN bbox 종잇장 처리: 변 정점 N개 Z smoothing (win=5, 3pass) + 내부 M개 feather 블렌드 (feather=…m)".
- **검증**: `ast.parse` OK.

### [fix] DEM이 TIN bbox 침범 + 경계 fin — strict 가드 + 공유 정점 inner

- **파일**: `dem_extender.py`
- **증상** (error.png/error1.png): DEM 링이 TIN bbox 내부까지 덮어 두 메시 중첩, bbox 변을 따라 수직 벽/fin 잔존.
- **원인**:
  1. Delaunay inner 제거가 **centroid만** 검사하여 bbox를 가로지르는 삼각형 중 centroid가 바깥인 것이 살아남아 TIN 영역을 덮음.
  2. DEM 링의 inner 경계 densified 점이 TIN 정점과 **다른 XY**라 T-vertex 발생 → fin 형태 수직 벽.
- **수정**:
  1. **Strict 가드**: Delaunay 삼각형 중 세 정점 중 하나라도 inner bbox strict 내부(`tol = max(bbox_w)×1e-4 + 1mm`)이면 제거. bbox 변 위 densified 점은 경계라 strict 아님 → 정상 링 삼각형은 유지, 가로지르는 삼각형만 차단.
  2. **공유 정점 inner 경계**: `_generate_ring_points_hull`에 `precomputed_boundary` 인자 추가. DEM 링의 inner 경계를 **TIN mesh에 실제로 존재하는 bbox 변 정점 XY 그대로 사용**(densify 대체). 두 메시가 동일 XY의 공유 정점을 가지므로 T-vertex/fin 원천 제거.
  3. 로그: "inner 제거 삼각형: centroid N, strict-vertex M" + "경계=TIN bbox 공유정점 K개".
- **검증**: `ast.parse` OK.

### [fix] TIN/DEM 중복 표출 — hull 대신 TIN bbox를 inner로, densified Z는 TIN 최근접

- **파일**: `dem_extender.py` (`build_extended_terrain_ring`)
- **사용자 의심**: DEM이 DXF와 같은 EPSG:5187로 처리되는지 + 일부 구간이 중복 표출.
- **좌표계 확인**: `dem_extender`는 DXF `src_crs`(예: EPSG:5187)를 WGS84로 변환해 **DEM 타일의 Z만** 샘플링하고, 결과 mesh의 XY는 그대로 `src_crs` 평면. 즉 TIN과 DEM 링은 동일 EPSG:5187. 좌표계 불일치는 없다.
- **진짜 원인 (중복)**:
  1. 이전 `use_hull_boundary=True`는 DEM 링 inner 경계를 **TIN convex hull** 따라 구성. TIN pts의 **bbox ≠ convex hull**이라, hull 외곽 ~ bbox 사이의 얇은 영역을 두 메시가 중복 덮거나 T-vertex를 만들어 fin 생성.
  2. hull densified 점 Z를 "4코너 거리 가중평균"으로 계산 → 중간 점이 실제 TIN 경계 Z와 어긋남 → seam에서 Z 튀는 정점.
- **수정**:
  1. **inner polygon을 TIN bbox(4모서리)로 치환**. DEM 링 inner가 TIN bbox 선과 정확히 맞닿음 → 중복 영역 제거. `boundary_mode = "TIN bbox(4모서리)"`.
  2. **densified 경계점 Z는 TIN 전체 정점 중 최근접 Z**로 설정 (`cKDTree(tin_abs_full[:, :2]).query(boundary_xy, k=1)`). 선형 가중평균 제거. 실제 TIN 경계 Z를 그대로 따라가 두 메시가 bbox 선에서 공유 Z.
  3. **좌표계 진단 로그** 추가 — `src_crs`, TIN bbox (평면/WGS84 중심), 왕복 변환 오차(m)를 매 실행 시 출력. 이후 의심 재발 시 로그로 즉시 확인 가능.
- **검증**: `ast.parse` OK.

### [fix] Step 1.5 후 bbox 변 따라 fin/spike 다수 — bbox 고정 + Laplacian smoothing 추가

- **파일**: `egview_maker.py` (`_reinterpolate_tin_boundary_with_dem`)
- **증상** (error.png): Step 1.5 DEM 결합 후 도면 bbox 4변을 따라 격자 모양의 수직 fin(빨래판 스파이크) 다수 돌출.
- **원인**:
  1. 기존 로직이 DEM smoothstep 블렌드만 적용. **bbox 변 위 정점**은 `t≈0`이라 DEM 가중이 거의 1이지만 여전히 **원본 TIN Z가 일부 섞임** → 인접 정점 간 Z 편차 증폭으로 튀는 정점 발생.
  2. DXF 등고선 끝점이 bbox 근처에 불규칙 분포하고 DEM 샘플도 정점별 편차가 있어 **feather 영역 Z가 정점마다 들쑥날쑥**. 이 편차가 Delaunay에서 fin 형태 수직 삼각형으로 드러남.
- **수정** (3단계 파이프라인):
  1. **DEM smoothstep 블렌드** (기존 유지): 경계 0 → DEM 100%, feather_m → TIN 100%.
  2. **bbox 4변 정확히 위 정점 DEM 100% 강제**: `dist_edge < bbox_tol` 정점은 블렌드 없이 DEM 샘플 값으로 치환. 원본 TIN Z 혼입으로 인한 튐 원천 차단.
  3. **Laplacian smoothing 4 pass** (feather & ¬bbox): 각 정점을 `0.4×자기 + 0.6×이웃평균(반경 max(30m, feather×15%))`로 여러 회 평탄화. 정점별 Z 편차가 이웃으로 확산·평균화되어 fin spike 소멸. **bbox 변 정점은 고정**(DEM 참값 유지), **feather 바깥 내부 정점은 참조만·변경 없음** → 도면 내부 지형 보존.
- **로그**: "[경계 재보간] N개 정점 DEM 블렌드 (bbox 고정 M개, Laplacian smoothing 4회 r=…m, Δ평균=±…m, Δmax=…m) — spike/fin 제거".
- **검증**: `ast.parse` OK.

### [fix] Iterative peel이 내부 침투 + Step1 후 dialog 로딩 지연 — Single iteration(v5)

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **증상**:
  1. **도면 범위 내부에 구멍 재발** (error.png/error1.png): 등고선이 있는 영역인데도 TIN이 삭제되어 검은 구멍. 사용자 요구 "범위 내 TIN은 무조건 보존".
  2. **"계획선 고도 설정" 창 로딩 지연**: Step 1(`btn_tin_callback`) 직후 `_open_elevation_dialog`가 떠야 하는데 한참 걸림.
- **원인 (공통)**: v3/v4의 **iterative peel(최대 50회) + np.unique(axis=0) 매 iteration**.
  - 점이 많을 때 edge 수백만 개 → np.unique 반복이 초 단위로 지연 → dialog 앞 단계에서 시간 소모.
  - Iterative 구조상 경계를 한 겹 깎고 새 경계를 또 깎음. 임계값이 낮으면 내부 정상 삼각형까지 연쇄로 먹힘.
- **수정 (v5)**:
  1. **iterative peel 완전 폐기** → single iteration. Step1 속도 회복.
  2. **bbox 4변 접촉 삼각형만** 컷 (내부 cave-in 가드 폐기). bbox 안에 있는 모든 삼각형은 무조건 보존 → 사용자 요구 "범위 내 TIN 보존" 완벽 충족.
  3. 임계값 `edge > median × 4.0` (v4 3.0보다 보수). 정상 외곽은 median 근처, sliver는 수십 배라 여전히 식별.
- **Trade-off**: 북쪽 hull cave-in 벽이 남을 수 있음(내부 cave-in 컷을 폐기했으므로). 사용자 우선순위상 "내부 구멍 없음 > cave-in 벽 제거". 필요 시 차후 개선.
- **로그**: "TIN 경계 sliver 컷 v5: N개 제거 (bbox 4변 접촉 + edge>…m, median=…m × 4.0, single iteration)".
- **검증**: `ast.parse` OK.

### [fix] 내부 구멍 재발 — 도면 bbox 접촉 vs. cave-in 이중 임계값(v4)

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **증상**: v3 iterative boundary peeling(`edge > median × 3.0` 단일 기준)이 내부 정상 삼각형까지 반복적으로 깎아 원본 도면 범위 안에 **구멍**을 냄. 사용자 지적: "도면 범위 안에는 원래 구멍이 없었다".
- **원인**: iterative peel은 외곽 한 겹 제거 후 새 경계가 드러나면 또 컷하는 구조라, 임계값이 낮으면 정상 내부 삼각형도 edge > median × 3 인 것부터 연쇄로 삭제됨. 결과 = 내부에 구멍 번짐.
- **수정 (v4)** — 두 구역을 **다른 임계값**으로 분리:
  - **bbox 4변(도면 경계) 접촉 삼각형**: `edge > median × 3.0` → 컷. 동·서·남·북 외곽 얇게.
  - **내부 boundary 삼각형 (bbox 미접촉)**: `edge > median × 8.0` → 컷. 북쪽 hull cave-in처럼 bbox 안쪽 깊이 파고든 긴 sliver만 타겟. 정상 내부 삼각형은 median × 8을 넘기 어려워 보존.
- **iterative 유지**: cave-in이 여러 겹이어도 peel로 제거. 내부 정상 구간은 두 임계값 모두 넘지 않아 절대 불변.
- **로그**: "TIN 경계 sliver 컷 v4: 총 N개 제거 (bbox 접촉 N개 edge>…m, 내부 cave-in N개 edge>…m, median=…m)".
- **검증**: `ast.parse` OK. 화면상 내부 구멍 해소 확인.

### [feat] 프로세스 재설계 — DEM 확장을 별도 Step 1.5로 분리, draping 중복 제거

- **파일**: `egview_maker.py`
- **사용자 요구**: (1) Step 1 TIN이 마음에 안 들면 Step 1.5에서 DEM으로 TIN을 확장, (2) 원본 도면 경계와 DEM이 겹치는 구간은 **DEM을 참값**으로 보간, (3) Step 2(위성지도 결합)에서 DEM 확장은 **중복이므로 제거**.
- **변경**:
  1. 사이드바에 **"1.5 DEM으로 TIN 확장" 버튼** 신설 (`btn_step1p5`, Step 1과 Step 2 사이).
  2. 신규 콜백 `btn_extend_tin_with_dem_callback`:
     - `build_extended_terrain_ring`으로 `self.tin_extension_mesh` 생성.
     - `self._reinterpolate_tin_boundary_with_dem`로 원본 TIN 경계 feather 영역의 Z를 DEM 값으로 smoothstep 블렌드(경계=100% DEM, feather_m 안쪽=100% TIN → **DEM이 참값**).
     - `feather_m = max(150, buffer * 0.2)`, `buffer_m`은 사이드바 "지형 확장 (DEM)" 입력칸 사용.
     - 완료 시 뷰어 자동 갱신.
  3. 신규 helper `_reinterpolate_tin_boundary_with_dem`: TIN 정점 중 bbox 4변에서 feather_m 이내만 골라 `cKDTree(ext_pts[:, :2])`로 최근접 DEM Z 샘플링 → `w_dem = 1 − smoothstep(t)` 블렌드. 내부 정점은 완전 무간섭.
  4. `btn_draping_callback` 대폭 간소화:
     - 기존 내부의 `build_extended_terrain_ring` 호출 블록 **완전 제거** (중복 제거).
     - 타일 다운로드 bbox는 `self.tin_extension_mesh`가 있으면 그 bounds, 없으면 원본 `projected_bounds` 사용 → 이미 확장된 TIN 범위로 위성 타일 범위도 자동 매칭.
     - 뷰 버퍼(%)만 적용. DEM 관련 로직(`dem_enabled`, `dem_buffer_m` 계산) 제거.
  5. Step 3.5 안내문을 '1.5 DEM으로 TIN 확장 버튼을 먼저 누르라'는 새 프로세스에 맞게 교체.
- **호환성**: 기존 체크박스 `dem_extend_var`와 `dem_buffer_var` 입력칸은 유지 — `dem_buffer_var`는 Step 1.5에서 버퍼 m 입력으로 재사용. 체크박스는 이제 무기능(나중에 UI 정리 여지).
- **검증**: `ast.parse` OK.

### [fix] 북쪽만 벽이 남는 현상 — bbox 접촉 기준 → edge-count boundary peeling

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **증상**: `사연댐 계획 평면도_V6.dxf`에서 동·서·남은 얇게 끝까지 퍼지는데 **북쪽만 벽이 계속 생김**.
- **원인**: 북쪽으로는 DXF 등고선이 bbox 변까지 찍히지 않아 **Delaunay convex hull이 북쪽으로 cave-in**됨. 그 cave-in 된 sliver 삼각형은 `y_max`에 닿지 않으므로 이전 v2 `touches_bbox` 조건에 걸리지 않아 살아남음.
- **수정 (v3)**: bbox 접촉 판정을 버리고 **edge-count 기반 iterative boundary peeling**으로 전환.
  1. 매 iteration에 모든 edge를 `a<b` 키로 정렬, `np.unique(..., return_counts=True)`로 **count=1인 edge = boundary edge** 식별.
  2. 이 boundary edge를 하나라도 포함한 삼각형 중 `edge_max > median × 3.0` 이면 제거.
  3. 제거 후 새 경계가 드러나면 다시 반복(최대 50회).
  4. 내부 삼각형의 모든 edge는 반드시 2개 삼각형에 속하므로 절대 boundary로 분류 안 됨 → 내부 완전 무간섭.
  5. 볼록·오목(cave-in) 외곽 모두 동일하게 처리되어 북쪽 cave-in sliver까지 정리.
- **효과**: 동·서·남과 동일 품질로 **북쪽도 얇은 종이**. 내부 구멍 여전히 0.
- **검증**: `ast.parse` OK.

### [fix] hull 경계 컷이 내부 벽까지 놓침 → bbox 4변 접촉 기준 + iterative 컷

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **증상** (이번 error.png): 여전히 TIN 외곽이 수직 벽처럼 솟음. convex hull 경계 조건(`hull_count>=2`)이 "사각 4변 경계"와 일치하지 않아 실제 bbox 변에 걸친 벽 삼각형을 놓침.
- **수정**:
  1. 조건을 **`hull_count>=2` → `touches_bbox (bbox 4변 중 하나라도 닿음)`**로 교체. 이게 사용자가 말한 "사각형 범위의 4변" 정의와 정확히 일치.
  2. **Iterative 컷** (최대 30회): 경계 sliver 한 겹 제거 후 새 경계가 드러나면 다시 판정. 변화 없을 때 중단. 내부는 bbox에 닿지 않으므로 영향 0.
  3. median은 최초 분포로 고정(기준 안정성).
  4. 로그: "TIN 경계 sliver 컷 v2: N개 제거 (bbox 4변 접촉 + edge>…m, median=…m × 3.0) — 내부 Delaunay 불변, 경계만 얇게".
- **검증**: `ast.parse` OK.

### [fix] 전역 concave 컷이 내부 구멍 재발 — hull 경계 삼각형만 컷

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **증상**: 직전 concave hull 컷(`전역 edge > median*3`)이 내부 저밀도 구간 삼각형까지 지워 TIN 내부에 또 구멍 발생.
- **사용자 지시**: 내부는 기존 Delaunay 그대로 두고, **사각형 범위의 4변(외곽)만** 벽이 안 생기도록 처리.
- **수정**: edge 컷에 `is_hull_tri = hull_count >= 2` 조건 복구. **내부 삼각형(hull_count ≤ 1) 절대 무간섭.** 임계값은 `median × 3.0` 유지(경계 sliver 타이트 제거).
- **원리**:
  - 경계 벽의 원인은 convex hull이 데이터 없는 영역을 가로지르는 **긴 edge sliver**. 이 sliver는 반드시 hull edge(2개 정점이 hull 정점)를 공유한다.
  - hull_count ≥ 2인 삼각형만 컷 → 내부 영향 0. edge > median*3만 제거 → 경계의 짧은 정상 삼각형은 유지.
- **검증**: `ast.parse` OK.

### [fix] TIN 경계를 "얇은 종이"로 — Concave hull 컷 전환 (ramp-down 폐기)

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **사용자 요구**: 경계에 **벽을 아예 세우지 말고** 등고선 끝점들이 그대로 이어져 3D에서 "얇은 종이"처럼 보이게 해달라.
- **근본 해법 (방향 전환)**: 이전의 ramp-down(경계 Z를 낮춤) 접근은 "벽을 완만한 경사로 바꾸는" 것이었지 "벽을 없애는" 것이 아니었음. 진짜 해법은 **convex hull 전체를 채우지 않고 실제 등고선 분포를 따라 잘라내는 concave hull**.
- **수정**:
  1. hull ramp-down 블록 **완전 삭제** (Z 수정 없음).
  2. 벽 컷 로직을 **전역 edge 기준**으로 단일화: 각 삼각형의 최대 edge 길이 > `median × 3.0` 이면 제거. hull 조건 없음, Z/slope 조건 없음.
  3. 원리: Delaunay가 convex hull을 채우며 데이터 없는 영역을 가로지르는 **긴 edge sliver**가 바로 수직 벽의 원인. 이 sliver는 edge 길이 하나로 확실히 식별됨(내부 등고선 간 edge는 median 근처, 외곽 sliver는 median×5~50배). 제거하면 외곽은 실제 등고선에서 끝나 "얇은 종이" 형태.
  4. 로그: "TIN concave hull 컷: N개 삼각형 제거 (edge>…m, median=…m × 3.0)".
- **안전성**: Z/slope 기준 없음 → TIN 내부 구멍 원천 차단(error.png 재발 방지). hull 조건 없음 → 경계 벽 원천 차단(error1.png 재발 방지). 임계값 `× 3.0`은 보수적이라 내부 저밀도 구간도 유지.
- **검증**: `ast.parse` OK.

### [feat] TIN 경계 radial ramp-down — DEM과 동일 원리로 외곽 벽 원천 제거

- **파일**: `egview_maker.py` (`create_tin_from_dxf`)
- **관찰**: DEM+위성 합성에서는 경계가 벽처럼 튀지 않음. TIN에서만 튄다. 이유는 DEM 링에 있는 두 단계 경계 처리(`dem_extender.py` 5b `outer radial ramp-down` + 5c `hull 경계 Z 오버라이드`)가 **TIN에는 전혀 없었기 때문**.
- **근본 원인**: DXF의 가장 바깥 등고선 Z가 그대로 convex hull 경계 정점으로 남고, 바로 안쪽 정점과 Z 차이가 크면 Delaunay가 "낮→높 수직 sliver"를 생성. DEM은 경계 점 Z를 이웃 하위 분위수로 내려 완만히 수렴시키므로 벽이 없음.
- **수정** (DEM 경계 처리와 동일 원리를 TIN에 이식):
  1. **hull 경계 ramp-down**: Delaunay 직전에 `ConvexHull(pts[:, :2])`로 hull 정점만 식별, 각 hull 정점 Z를 반경 `max(hull_edge_median*3, 50m)` 내 **비-hull 이웃의 하위 10% 분위수**와 자기 Z 중 작은 값으로 교체. 내부 정점은 전혀 건드리지 않아 지형 보존.
  2. **보조 컷(safety net)**: ramp-down 후에도 convex hull이 오목 지형을 가로질러 만든 긴 edge sliver가 남을 수 있으므로 `hull_count>=2 AND e_max > median*10`만 제거. **Z 기반 컷은 완전히 제거** (내부 구멍 재발 차단).
  3. 로그: "TIN hull 경계 ramp-down: N개 정점 중 M개 하강 (반경=…m, 평균=±…m, 최대=±…m)" + "TIN hull sliver 삼각형 N개 제거 (edge>…m)".
- **기대**: DEM에서 벽이 없던 것과 동일 메커니즘으로 TIN 외곽도 자연스럽게 수렴 → 수직 벽 소멸. 내부 경사면은 완전 무간섭.
- **검증**: `ast.parse` OK.

### [fix] 전역 slope 컷이 TIN 내부에 구멍, 외곽 벽은 생존 — hull 경계 한정 + 기준 재보정

- **파일**: `egview_maker.py`, `dem_extender.py`
- **증상** (이번 error.png/error1.png):
  - **error.png**: 방금 넣은 전역 slope 컷(`slope_ratio>1.5 & z_span>3m`, 모든 삼각형 대상)이 TIN **내부** 정상 급경사 경사면까지 지워 능선 따라 **검은 반점 구멍**이 다수 발생.
  - **error1.png**: 동시에 외곽 수직 벽은 **여전히 남음**. 그 벽은 edge가 길고 Z스팬도 크지만 `slope_ratio < 1.5`라 전역 컷을 통과. "정상은 망가뜨리고 벽은 못 잡음"의 최악 조합.
- **근본 인식**:
  1. TIN 벽은 convex hull이 실제 등고선 외곽선보다 밖으로 튀어나오는 부분의 **얇은 외곽 sliver**. 내부에는 벽이 존재하지 않는다 — 등고선 데이터가 이미 있기 때문.
  2. 따라서 **내부 삼각형은 절대 건드리지 말고 hull 경계만 컷하는 것이 옳음.** 전역 컷은 원천 잘못.
  3. hull 정점 1개만 포함한 삼각형도 경계 sliver가 될 수 있으므로 이전 `hull_count>=2` 대신 `>=1`로 완화.
- **수정**:
  1. `egview_maker.py` `create_tin_from_dxf`:
     - **전역 slope 컷 제거.** 내부는 보존.
     - hull 경계(정점 1+) 대상으로 `(e_max > median*8) OR (slope_ratio > 1.0 AND z_span > 5m)` 시 제거.
     - edge 과장 기준은 `median*15 → 8`로 타이트하게 (벽 edge가 그 사이에 끼어 통과하던 케이스 방지).
     - 로그: "TIN 외곽 벽 삼각형 N개 제거 (hull 경계만 대상: edge>…m N개, slope>1.0&Z>5m N개) — 내부 경사면은 보존".
  2. `dem_extender.py` ring 벽 컷:
     - 기준 상향: `slope_ratio > 2.5 (≈68°) AND z_span > 10m`. DEM ring은 등고선 단차가 없는 격자이므로 실제 수직 벽은 ratio가 훨씬 크다. 완화하면 위성 구멍 재발하지 않음.
- **기대 결과**:
  - error.png 내부 구멍 소멸 (내부 컷 자체를 없앰).
  - error1.png 외곽 벽 제거 (hull 1정점 + edge>median*8 or slope>1.0 조건에 걸림).
  - error2.png 위성 텍스처 구멍 완화 (링 기준 2.5/10m로 보수적).
- **검증**: `ast.parse` OK.

### [fix] TIN/DEM 벽 판정을 slope-ratio 기반으로 통일 — error1/error2 동시 해결

- **파일**: `egview_maker.py`, `dem_extender.py`
- **증상**:
  - **error1.png** (DEM 확장 OFF, 순수 TIN): DEM 확장 전에도 TIN 자체 외곽이 수직 벽처럼 솟아있음. 즉 벽의 **1차 원인은 `create_tin_from_dxf`**.
  - **error2.png** (DEM 링 + 위성 결합): 직전 수정의 공격적 절대 Z 스팬 컷(`max(grid_step*0.9, 15m)`)이 정상 경사 삼각형까지 잘라 위성 텍스처에 듬성듬성 검은 구멍 발생.
- **근본 원인**:
  1. `create_tin_from_dxf`의 벽 컷은 `is_boundary_tri = hull_count>=2` 조건에 묶여 **hull 정점 1개만 포함한 내부 급경사 삼각형**을 놓침. 등고선 데이터 희박 구역이 만드는 벽이 그대로 유지돼 순수 TIN 상태에서도 수직 벽이 렌더링됨.
  2. **절대 Z 스팬 기준 자체가 잘못된 메트릭.** 정상 산지 경사면의 삼각형도 등고선 간 차이만큼 Z 스팬이 나오므로 절대 기준으로 자르면 경사면까지 같이 삭제 → 구멍.
- **수정** — 두 파일 모두 **slope_ratio = Z스팬 / 최대 XY edge** 기준으로 통일:
  1. `egview_maker.py` `create_tin_from_dxf` 컷 로직 전면 교체:
     - **단계 A (전역 slope 컷)**: 모든 삼각형 대상 `slope_ratio > 1.5 (≈56°)` AND `z_span > 3m` → 제거. hull 위치와 무관하게 수직 벽을 잡음.
     - **단계 B (hull sliver)**: hull 정점 2개 이상 포함 AND `e_max > median*15` → 제거 (데이터 없는 영역 가로지르는 긴 sliver). Z 절대 기준은 제거.
     - 로그: "TIN 벽 삼각형 N개 제거 (slope 조건 N개, hull sliver N개)".
  2. `dem_extender.py` `build_extended_terrain_ring` 링 벽 컷 교체:
     - 기존 `z_span > max(grid_step*0.9, 15m)` → `slope_ratio > 1.5 AND z_span > 5m`.
     - 정상 경사면은 `e_max ≈ grid_step`이므로 slope_ratio < 1 → 보존. 국소 outlier 수직 벽(이웃 없음→짧은 edge에 큰 Z)만 제거.
     - 로그: "링 벽 삼각형 N개 제거 (slope_ratio>1.5 & Z스팬>5m)".
  3. 04-21·04-22 초기본의 radial outer ramp-down은 유지 (외곽 테두리의 점진적 하강은 별개 효과라 여전히 필요).
- **기대 결과**:
  - error1.png 순수 TIN 상태에서 외곽 수직 벽 사라짐 (전역 slope 컷이 hull 1정점 벽까지 포함).
  - error2.png 위성 텍스처 구멍 닫힘 (정상 경사 삼각형 보존).
- **검증**: `ast.parse` OK (두 파일). Step1 → Step2 재실행해 로그와 3D 뷰 확인 필요.

- **파일**: `dem_extender.py`
- **증상** (error.png 빨강 구간 재발): 전날(04-21) 수정본에도 불구하고 여전히 outer bbox 경계에 수직 벽 + 내부 plateau가 남음. 사용자 기대는 노란 선처럼 완만한 하강.
- **근본 원인**:
  1. 이전 `5b outer ramp-down`은 `np.abs(ring_xy-outer)<grid_step*0.6` — 즉 **outer bbox에 정확히 닿은 한 줄**만 내림. 한 줄 내려가도 **바로 안쪽 줄과의 Z 단차가 새 벽**을 만들어 빨간 구간이 그대로 남음.
  2. `z_wall_thresh = max(grid_step*1.5, 30m)`는 느슨해, 예컨대 `grid_step=40m`(buffer 1000m 기본)이면 Z 스팬 60m 이하 삼각형은 그대로 벽으로 유지.
- **수정**:
  1. **5b Radial outer ramp-down 재설계**: 각 ring 점의 `dist_to_outer`를 계산해 `ramp_width = max(grid_step*5, buffer_m*0.35)` 안쪽 모든 점을 대상으로 smoothstep 가중치(`u=1-t`, `w=u²(3-2u)`) 적용. `t=0`(outer 경계) → z_target 100%, `t=1`(ramp_width 안쪽) → 원본 유지. 단차 없이 연속적으로 하강 → 3D에서 "노란 선" 형태의 스커트.
  2. **z_target**: 각 점 반경 `max(grid_step*4, 80m)` 이웃의 **하위 10% 분위수**와 자기 Z 중 **더 낮은 값**으로. 이전 25% 분위수보다 공격적.
  3. **Z 스팬 벽 컷 강화**: `z_wall_thresh = max(grid_step*0.9, 15m)` (이전 `1.5, 30m` → `0.9, 15m`). radial ramp가 남긴 국소 outlier 수직 삼각형까지 컷.
  4. 로그 보강: 평균/최대 Z 하강량, ramp 폭, 이웃 반경, 컷된 삼각형 개수를 모두 출력해 효과 점검 가능.
- **검증**: `ast.parse` OK. Step2 재실행으로 확인 필요. 로그에 "outer radial ramp-down N개 점 (ramp폭=…m, r=…m, 평균Z하강=…m, 최대=…m)" + "링 가파른 삼각형 N개 제거 (Z스팬>…m)" 표시.

---

## 2026-04-21

### [fix] DEM 확장 링의 외곽·내부 수직 벽 제거 — outer ramp-down + Z 스팬 컷

- **파일**: `dem_extender.py`
- **증상** (error.png 빨강 구간): DEM 확장 결과가 바깥 bbox 경계에서 수직 벽처럼 뚝 끊기고, 내부에도 일부 가파른 삼각형이 벽으로 드러남. 사용자 기대: 노란 선처럼 외곽으로 갈수록 완만히 수렴.
- **수정**:
  1. **5b outer bbox 테두리 ramp-down**: `ring_xy`에서 outer bbox 가장자리에 닿은 점(거리 ≤ `grid_step*0.6`)을 찾고, 각 점 Z를 반경 `grid_step*4` 내 **내부 이웃의 25 퍼센타일**과 자기 Z 중 **작은 값**으로 내림 → 외곽으로 갈수록 자연스레 하강하는 "노란 선" 모양.
  2. **링 내부 Z 벽 삼각형 제거**: 삼각형 Z 스팬이 `max(grid_step*1.5, 30m)` 초과면 제거. 링 내부에 이웃 없는 점이 만든 가파른 벽 후보를 잘라내 해당 구간이 투명하게 비워짐 → 위성/TIN 평면이 자연스럽게 드러남.
- **검증**: `ast.parse` OK. Step2 재실행으로 결과 확인 필요. 로그에 "outer bbox 테두리 ramp-down N개 점" + "링 내부 가파른 삼각형 N개 제거 (Z스팬>…m)" 표시.

### [fix] 같은 (X,Y)에 Z 여러 개 있어 생기던 내부 수직 벽 제거 — 최저 Z 통합

- **파일**: `egview_maker.py`
- **증상** (error.png): Sliver 제거 로직 수정 후에도 **TIN 내부**에 수직 벽이 남음. 등고선 polyline과 3D 폴리선이 동일 (X,Y) 좌표에 **서로 다른 Z**로 다수 찍혀, Delaunay가 어떤 Z를 택하든 인접 삼각형과 Z 점프가 생기는 구조적 문제.
- **근본 원인**: `create_tin_from_dxf`는 LWPOLYLINE + LINE + POLYLINE + POINT + SPLINE + 3DFACE의 좌표를 모두 단순 수집. `np.unique(pts, axis=0)`는 (X,Y,Z) 3튜플이 완전히 같을 때만 중복 제거하므로, **같은 XY + 다른 Z** 는 모두 살아남음 → Delaunay 삼각형이 한 쪽은 위 Z, 다른 쪽은 아래 Z를 택해 수직 면이 생성.
- **수정** (`create_tin_from_dxf` 내부):
  - `pts[:,:2]`를 3자리 반올림한 key로 `np.lexsort((Z, Y_key, X_key))` 정렬 → 같은 XY 그룹이 Z 오름차순으로 연속.
  - 그룹의 **첫 행(=최저 Z)만 유지**하는 `diff_mask`로 중복 제거.
  - 최저 Z를 택하는 이유: 원지형/바닥 면을 유지하는 것이 벽 제거에 일관. 계획고는 이미 `structure_registry`/오버레이로 별도 반영됨.
  - 제거된 점 개수를 로그로 출력 ("동일 XY 중복 점 N개 통합 (최저 Z 유지)").
- **검증**: `ast.parse` OK.

### [fix] TIN sliver 제거가 내부 저밀도 영역까지 구멍을 내던 문제 — 조건 재설계

- **파일**: `egview_maker.py`
- **증상** (error.png): 이전 수정(median×8 edge-length 컷)이 내부 저밀도 영역까지 삭제해 X=0~1400m 범위 안쪽이 벌집처럼 뚫림. 사용자 요구: "X/Y는 유지, 벽만 없애라".
- **수정** (`create_tin_from_dxf` sliver 제거 조건 재설계):
  - `tri.convex_hull`로 **hull 경계 정점 집합**을 먼저 얻음.
  - 각 삼각형 중 hull 정점을 **2개 이상** 포함하는 것만 경계 후보(`is_boundary_tri`).
  - 그 중 **Z 스팬 > `max(median(z_span)*4, 20m)`** 또는 **edge > median×15** 인 경우에만 제거.
  - 내부 삼각형(hull 정점 ≤1개)은 edge/경사 무관하게 **전부 유지** → XY 커버리지 보존.
- **기대**: 외곽 convex hull 테두리에서 위성 텍스처가 수직 벽으로 드러나게 했던 긴·급경사 sliver만 선택 제거. 내부 급경사(절토 법면 등)는 보존.
- **검증**: `ast.parse` OK. UI에서 Step1 → Step2 확인 필요. 로그에 "TIN 외곽 sliver 삼각형 N개 제거 (hull 경계 중 Z스팬>… or edge>…)"가 표시.

### [fix] 위성 텍스처 결합 후 TIN 테두리에 '벽'이 생기던 현상 — Delaunay sliver 제거

- **파일**: `egview_maker.py`
- **증상**: Step 1 TIN (Elevation 컬러맵)에서는 테두리가 자연스럽지만, Step 2 위성 텍스처 결합 후 바로 TIN 경계에 수직 벽이 나타남. 사용자 지적: "TIN 단계에선 문제 없었는데 위성 합성 후 테두리에 벽."
- **근본 원인**:
  - `create_tin_from_dxf`는 2D `scipy.spatial.Delaunay`로 전체 점 집합의 **convex hull 전체를 채움**. 등고선 점이 밀집한 내부 영역과 달리 외곽은 **긴 sliver 삼각형**(한 변이 수십~수백 m)이 생성되어 먼 점 사이를 가로지르며 급격한 Z 경사를 만듦.
  - Elevation cmap은 그 경사가 부드러운 색 그라디언트라 티가 안 남. 위성 텍스처는 픽셀 대응이 평면 투영인데 Z가 가파르면 **텍스처가 수직 벽처럼 뻗어** 보임 + `eye_dome_lighting`이 해당 경계 edge를 어둡게 강조.
- **수정** (`create_tin_from_dxf`):
  - Delaunay 결과 각 삼각형의 **최대 edge 길이**를 계산. 전체 max-edge 길이의 median을 기준으로 `edge_thresh = median × 8` 초과 삼각형은 외곽 sliver로 판단해 제거.
  - 제거된 개수를 로그로 출력. concave hull에 가까운 경계로 TIN이 정돈되어 위성 텍스처의 가장자리가 실제 데이터가 있는 영역까지만 그려짐 → 수직 벽 사라짐.
- **검증**: `ast.parse` OK. UI에서 Step1 → Step2 재실행해 위성 렌더 확인 필요. sliver 제거 로그가 기존 Step1 로그창에 "TIN 외곽 sliver 삼각형 N개 제거 (edge>…m, median=…m)"로 표시됨.

### [fix] 프로세스 정렬 — 뷰어·제어맵·AI가 모두 "확장된 전체 장면"을 공유 + 경계 스파이크 제거

- **파일**: `egview_maker.py`, `dem_extender.py`
- **증상 A (프로세스)**: 위성사진을 버퍼로 확장 합성했는데 뷰포인트 선택/제어맵/AI 렌더에서 TIN 원본 bbox 기준으로만 카메라가 잡혀 확장 영역이 결과에서 다시 잘려 나감. "버퍼 확장이 무의미".
- **증상 B (경계 스파이크, 2.png)**: DEM 확장 링 경계에서 뾰족한 고점/저점이 튀어 지형이 불자연스러움.
- **근본 원인**:
  - `_open_interactive_viewer`가 `target.bounds`(TIN만) 기준으로 camera diagonal·그리드 계산. DEM 확장 메시는 `_add_overlay_to_plotter`로도 추가되지 않아 사용자가 뷰포인트를 고를 때 **확장 영역이 회색/빈 배경**으로 보이고 카메라도 TIN 중심으로 프레이밍. 사용자가 q로 확정한 `_saved_camera`가 애초 확장 외부를 버림.
  - `show_3d_preview`도 `tin_mesh.bounds`로 `show_grid` + `reset_camera` → 같은 결과.
  - `dem_extender`의 DEM 샘플링(`_sample_grid_bilinear`)이 terrarium 타일 경계/디코딩 특이값에서 극단 Z(±수백 m)를 만들면 Delaunay 결과에 수직 스파이크가 남음. 전역 nan 채움만 있고 outlier/스파이크 필터 부재.
- **수정**:
  1. `_open_interactive_viewer` — `target`에 TIN을 추가한 뒤 `tin_extension_textured ∥ tin_extension_mesh` 도 **같은 텍스처**로 add_mesh. 카메라 기준 bounds를 **TIN + 확장 합집합**으로 교체. `self._capture_bounds` 저장해 이후 단계 일관성 보강.
  2. `show_3d_preview` — `scene_bounds`(TIN + 확장 합집합)로 `show_grid` / `reset_camera`. 확장 영역이 뷰 프레임 밖으로 잘리지 않음.
  3. (이미 반영된 `_capture_*_from_camera` 3종의 확장 메시 포함)과 결합 → 사용자가 확장된 장면에서 선택한 바로 그 뷰가 제어맵·AI 입력까지 일관 유지.
  4. `dem_extender.build_extended_terrain_ring` DEM 샘플링 후처리 2단계 추가:
     - **4a 전역 클립**: 5~95 퍼센타일 IQR × 3배 외 극단치 + 절대 범위(-500m~9000m)로 clip.
     - **4b 국소 스파이크 필터**: 각 점의 Z가 반경 `max(grid_step*3.5, 60m)` 이웃 median 대비 4×MAD 초과면 이웃 median으로 치환. 제거된 개수를 로그.
- **검증**: `ast.parse` 2 파일 OK. UI에서 Step2 → Step3(뷰포인트 선택 시 확장 영역도 화면에 포함되어 있는지) → Step3.5/4 재실행해 결과 확인 필요.

### [fix] DEM 확장 합성 — 도로 포장 확대, 경계 수직 벽, TIN↔DEM 단절 해소

- **파일**: `egview_maker.py`, `dem_extender.py`
- **증상** (error111.png/error222.png):
  1. DEM 버퍼(1000m 등)를 크게 줄 때 도로 포장(아스팔트 오버레이)이 원본 도면 대비 **2~3배 확대** 되어 보임.
  2. TIN 원본 범위 가장자리가 **3D 박스처럼 튀어나와** 주변 DEM 산지와 단절.
  3. TIN ↔ DEM 경계가 부드럽지 않고 선명한 seam으로 끊어져 보임.
- **근본 원인**:
  1. `_composite_material_textures`는 `projected_bounds ± 5%` 고정 bbox로 도로를 픽셀화. 그러나 실제 `satellite_temp.png`는 `dem_buffer_m` 버퍼까지 확장된 넓은 범위로 다운로드됨 → 같은 도로 픽셀이 좁은 5% 박스 기준으로 매핑되므로 이미지 안쪽에 훨씬 크게 찍힘.
  2. UV 매핑도 `tin_mesh.bounds ∪ extension.bounds` 합집합으로 잡아 다운로드 실제 extent와 미묘하게 불일치 → 텍스처 좌우 어긋남.
  3. `feather_m` 기본 80m — TIN이 평탄화된 계획고, DEM 링이 자연 지형이라 두 Z 차가 수십 m 이상 나는데 전이 구간이 80m뿐 → 세로 벽이 남음. 선형 블렌드도 C0 연속이라 미분에서 kink.
- **수정**:
  1. `_composite_material_textures` 시그니처에 `bbox_min_x/min_y/max_x/max_y`를 추가. 호출부(`btn_draping_callback`)가 **실제 다운로드 bbox(`min_x_p..max_x_p` — 버퍼 적용 후)**를 넘기도록 변경. 미지정 시 legacy 5% 폴백.
  2. UV 매핑 origin/point_u/point_v를 **다운로드 bbox** 기준으로 계산. 두 메시가 정확히 같은 좌표계의 같은 픽셀에 정렬.
  3. DEM 확장 호출 시 `feather_m = max(150, dem_buffer_m * 0.2)` 로 자동 상향 (1000m 버퍼 → 200m).
  4. `dem_extender.build_extended_terrain_ring` 내부 feather 블렌드를 **smoothstep**(3t²−2t³)으로 교체 → 경계·바깥 양 끝에서 Z 미분이 0이 되어 절벽 제거, C1 연속 전이.
- **검증**: `ast.parse` 2 파일 OK. 실제 결과는 Step 2 재실행으로 UI에서 확인 필요.

### [fix] 사이드바 UI — 위젯 겹침·창 축소 시 버튼 잘림 제거, 안내 문구 명확화

- **파일**: `egview_maker.py`
- **증상**:
  1. 3.5 버튼 경고창이 "'지형 확장 (DEM)'을 체크하라"고 안내하지만 실제 체크박스가 **보이지 않음**.
  2. EG-VIEW 창을 줄이면 사이드바 아래쪽 버튼 몇 개가 잘려서 접근 불가.
- **원인**:
  1. `self.dem_frame`과 `self.appearance_mode_optionemenu`가 **모두 row=25** 로 배치 → 동일 셀에 겹쳐 DEM 체크박스가 테마 메뉴 뒤에 가려짐.
  2. 사이드바가 고정 `CTkFrame` + `grid_rowconfigure(24, weight=1)` 구성인데 3.5 버튼 추가로 row가 밀려 weight가 `wireframe_check` 위치로 옮겨져 아래 위젯들이 찌그러짐. 창을 축소하면 전부 프레임 바깥으로 밀려 잘림.
- **수정**:
  1. 사이드바를 `CTkScrollableFrame`(내부) + 고정폭 `CTkFrame`(외부 컨테이너) 구조로 전환 → 창이 작아져도 세로 스크롤로 모든 버튼 접근 가능.
  2. 모든 사이드바 row 번호를 **누적 카운터**(`_next_row()`)로 할당 → row 충돌 구조적 불가능. dem_frame과 테마 메뉴가 각각 다른 row 차지.
  3. 버튼 높이 45 → 34, pady 10 → 4, sticky="ew" 일관 적용 → 수직 압력 감소. `create_sidebar_button` 기본값도 축소.
  4. 3.5 가드 메시지를 "사이드바 아래쪽 옵션 영역 — 구분선 아래 '지형 확장 (DEM)' 체크박스를 켜고 버퍼(m)를 입력한 뒤 Step 2 다시 실행" 등 **경로가 명시된** 2줄 메시지로 확장. 로그창에도 동일 단계 출력.

### [add] Step 3.5 "제어맵 배경 확장" — 제어맵 추출과 AI 렌더링 사이 중간 단계

- **파일**: `egview_maker.py`
- **배경**: Step 3에서 생성된 제어맵(capture_textured/depth/lineart/guide) 4장은 DXF 범위만 담고, 바깥은 흰 배경. Gemini 프롬프트가 그 영역을 "프레임 외부"로 해석해 조감도가 bbox만 그려지는 문제. 직전 수정(캡처 메서드 3종에 `tin_extension_mesh`를 포함) 외에 **사용자가 확장 결과를 미리 검증하고 AI에 넘길지를 결정할 수 있는 명시적 중간 단계**를 추가.
- **UI**: 사이드바에 **"3.5 제어맵 배경 확장"** 버튼 신설(기존 Step4 AI 렌더링 버튼 위). Step 3 완료 후 DEM 확장(`지형 확장 (DEM)` 체크)이 활성 상태면 누르기만 해도 아래 산출물 생성.
- **동작**:
  1. Step 3 산출물 4장을 로드.
  2. 확장 메시(`tin_extension_textured ∥ tin_extension_mesh`)를 **단독으로** Step3 카메라로 offscreen 렌더 → textured/depth/lineart 3종 배경.
  3. 중앙 제어맵을 배경 이미지 위에 **non-background 픽셀 마스크**로 덮어 합성 (`_overlay_non_background`): RGB는 배경색과 tolerance>6 차이인 픽셀만, Depth는 black≠픽셀, Lineart는 white≠픽셀을 취함.
  4. 확장본 저장: `capture_extended.png`(= `capture_for_ai.png` Gemini 입력 덮어쓰기), `depth_extended.png`, `lineart_extended.png`, `guide_extended.png`.
  5. `self.capture_image`도 확장본으로 교체되어 Step 4 Gemini 호출이 자동으로 확장 캔버스 사용.
- **가드레일**: Step 3 미수행 / DEM 확장 미설정 / 카메라 미저장 시 `messagebox.showwarning`으로 안내 후 종료.
- **기존 수정과 관계**: 지난 커밋에서 `_capture_*_from_camera` 3종에 확장 메시를 추가한 변경은 그대로 유지. 본 단계는 "자동 포함"에 더해 "사용자가 명시적으로 실행·검증"할 수 있는 경로를 제공.
- **검증**: `ast.parse` OK. (실 파이프라인은 UI 환경에서 실행해 `capture_extended.png` 시각 확인 권장.)

### [fix] AI 조감도 렌더링 — DEM 외곽 확장 메시가 제어맵 캡처에서 누락되던 문제

- **파일**: `egview_maker.py`, `gemini_renderer.py`
- **증상** (capture_textured.png vs rendered_birdseye.png): AI 조감도가 사용자 입력 bbox 범위만 렌더 → 외곽이 비어 보임. `dem_extender.build_extended_terrain_ring`으로 DXF 바깥 DEM+위성 확장 메시(`tin_extension_mesh`/`tin_extension_textured`)가 준비돼 있고 인터랙티브 뷰어에는 추가되지만, AI 전달용 캡처에는 누락.
- **원인**:
  - `_capture_from_camera`가 `self.total_mesh` 또는 `self.tin_mesh`만 add_mesh. `tin_extension_mesh`는 무시.
  - `_capture_depth_from_camera`, `_capture_lineart_from_camera` 동일.
  - 결과: capture_textured.png의 bbox 밖이 흰 배경 → Gemini 프롬프트가 그 영역을 "비어 있음/프레임 외부"로 해석.
- **수정**:
  1. 3개 캡처 메서드 모두 `tin_extension_textured ∥ tin_extension_mesh`를 조건부로 add_mesh. 텍스처가 있으면 같은 `satellite_temp.png`를 공유(인터랙티브 뷰어와 동일 경로).
  2. `gemini_renderer.run_gemini_render`의 프롬프트에 "scene may combine high-detail DXF center + real DEM+satellite outer ring; render BOTH seamlessly, do NOT trim to central bbox" 힌트 추가.
- **검증**: `ast.parse` 2 파일 OK. (실제 Gemini 호출 결과는 사용자 UI에서 재실행해 확인 필요.)

### [fix] 제수변실 — 도면에 없는데도 M-301 분기가 계속 생성되던 강제-시드 제거

- **파일**: `structure_templates.py`, `valve_chamber_parser.py`
- **증상** (error1.png): 도면 파싱 결과에 도수관이 없고 사용자가 파라미터를 바꿔도 좌측에 Y-분기 관로가 항상 생김.
- **근본 원인**:
  1. `ValveChamberTemplate.build_meshes`가 `_pipes` 리스트에 도수관이 하나도 없으면 **"M-301 도수관" 시드를 강제 주입**한 뒤 `_finalize`를 호출 → `has_inlet_branch=True`일 때 무조건 분기 5개 pipe 생성.
  2. `_finalize`의 `has_inlet_branch=False` 경로도 `orig_main`이 있으면 좌↔우 전관통 단일 pipe를 합성(origin-only seed조차 구조물로 실체화).
- **수정**:
  1. `build_meshes`에서 **시드 주입 로직 삭제**. `vc.pipes`는 파싱 결과 그대로만 사용.
  2. UI 토글 `has_inlet_branch=0`일 때 기존 `_pipes`에서 M-301 및 "도수관" 포함 이름을 **전량 제거**.
  3. `_finalize`의 분기-OFF 폴백도 `orig_main.start`가 origin(=미확정)이면 pipe를 만들지 않도록 제한.
- **검증 (3가지 시나리오 모두 PASS)**:
  - DXF 있음 · 분기 ON → M-301 계열 5개 pipe 정상 생성.
  - DXF 없음 · 분기 ON → pipe 0개 (도면에 없는 관로 **생성 금지**).
  - DXF 있음 · 분기 OFF → M-301 계열 0개 (UI 토글로 완전 숨김).

### [fix] 제수변실 — 좌측 고아 도수관 제거, 우측 관로 길이 현실화, 상류 Y-분기 지원

- **파일**: `valve_chamber_parser.py`, `valve_chamber_3d_builder.py`, `structure_templates.py`
- **지적**:
  1. 평면도(신설 제수변실.dxf)와 빌드된 3D가 어긋남 — 좌측에 관로가 chamber와 연결 안 된 채 독립적으로 떠 있음.
  2. 우측 송수관이 실제 도면에서 3~4m 남짓인데 3D는 15m 이상으로 길게 렌더.
  3. 도면상 도수관 1개 → Y자 분기 → 2개(상단·하단)로 chamber 진입하는데 빌더·파서·UI 모두 분기 개념 부재.
- **도면 실측 (신설 제수변실.dxf, INSERT 재귀 전개)**:
  - Chamber CS-CONC-밸브실 bbox = 14000×9000mm.
  - 좌측 M-LINE 관로: 상단 Y=25336~26536, 하단 Y=22136~23336 → **spread ≈ 4.4m**, 중앙에서 45° 전후 대각선 2개가 Y=0으로 합류.
  - 우측 MZ-LINE: 29376 → 32971 = **3.6m** (3D의 15m와 4배 차이).
- **수정**:
  1. `ValveChamberParams` 신규 필드:
     - `upstream_pipe_length` (기본 3.0m), `downstream_pipe_length` (기본 4.0m) — 좌/우 길이 분리.
     - `has_inlet_branch` (기본 True), `branch_spread_m` (4.4), `branch_angle_deg` (35), `branch_trunk_length` (3.0) — Y-분기 파라미터.
     - `external_pipe_length` 기본값 15.0 → 5.0 (fallback·legacy).
  2. `ValveChamberParser._extract_from_mleaders` — 좌측(-X)은 `upstream_pipe_length`, 우측(+X)은 `downstream_pipe_length`를 외부 연장으로 사용 (기존 단일 15m 제거).
  3. `ValveChamberParser._finalize` — 기존 단일 M-301 폴백을 걷어내고 `has_inlet_branch=True`면 trunk(1) + 경사 2 + 평행 2 = 총 5개 Pipe를 자동 생성 (chamber 좌측벽에 정확히 도달). False면 chamber 관통 단일 pipe로 폴백.
  4. `ValveChamberBuilder._build_main_conduit` — 기존 별도 생성 경로(좌측 고아 파이프 원인)를 no-op로. 모든 도수관/송수관은 `_build_transmission_pipes`가 단일 경로로 처리.
  5. `structure_templates.py` ValveChamberTemplate:
     - `get_parameter_schema`에 `upstream_pipe_length`, `downstream_pipe_length`, `has_inlet_branch`, `branch_spread_m`, `branch_angle_deg`, `branch_trunk_length` 노출 (사용자가 UI에서 분기 여부·치수 조정 가능).
     - `parse()` → params.params에 신규 필드 전달.
     - `build_meshes()` — UI에서 분기 파라미터 변경 시 기존 `_pipes`의 낡은 분기 형상이 쓰이지 않도록 `ValveChamberParser._finalize`를 재실행해 재생성. M-301 시드가 없는 경우에도 최소 하나를 넣어 분기 자동 생성.
- **검증**:
  - `신설 제수변실.dxf` parse 결과 M-301 계열 5개 pipe(`상단/하단/분기상경사/분기하경사/도수관`), 우측 송수관 3개 모두 end X = half_w + 4.0m.
  - default 파라미터 테스트: trunk (-16.14, 0) → 분기점 (-13.14, 0) → 상단 (-10, +2.2) → chamber 좌측벽 (-7, +2.2), 하단 대칭. 분기각 35°에서 L_branch = 2.2/sin(35°) ≈ 3.83m 정확히 반영.
  - `ast.parse` 3 files OK / import OK.

### [fix] 개폐장치를 gate 중심이 아닌 pier X 중심 위에 embed — 허공 부양 완전 제거 + 용어 잔존 제거

- **파일**: `gate_3d_builder.py`, `gate_parser.py`, `optional_detector.py`, `structure_templates.py`
- **지적**:
  - 직전 수정 후에도 사용자 UI/요약/코드 docstring 여러 곳에 "권양기" 문자열이 잔존.
  - 개폐장치가 여전히 pier가 아니라 gate 중심 X 위에 놓여 공도교 상면에서 떠 보임.
  - 블록을 explode해 평면도를 전수조사해보면 실제로는 pier 상면에 단위 기초가 있다.
- **수문_1.dxf 평면도 전수조사 결과** (INSERT 재귀 전개 후):
  - pier 4개 X 중심 ≈ 24374/44475/63957/84232 mm (간격 ~20m = gate 15m + pier 5m)
  - pier 상면의 CS-CONC-Spillway closed 4각형 — X폭 ≈ 4481mm, Y길이 ≈ 2181mm — pier X 중심과 정확히 일치 ⇒ **이것이 개폐장치 기초 footprint**. pier 2·3 위에서 관측(사이트별 설치 위치 상이할 수 있어 빌더는 모든 pier에 1개씩 배치)
  - 측면도(수문_2.dxf) MZ-BASE Y=24938~27015mm ↔ 표고 offset +30.962 ⇒ **EL.55.900~57.977**, 높이 2.077m. 공도교 상면 EL.56.000 대비 0.1m embed.
- **수정**:
  1. **용어 정정 (사용자 노출 텍스트)** — 이전 PR에서 놓친 잔존 문자열 전수 처리:
     - `gate_3d_builder.py` 모듈 docstring, 주석 — "권양기" → "개폐장치"
     - `gate_parser.py` GateParams.has_hoist_housings 주석 + `summary()` 문자열
     - `optional_detector.py` 모듈 docstring 및 주석
     - `structure_templates.py` description / ParamField 라벨
     - `has_hoist_housings` 플래그 이름과 `ComponentSpec.layer_tokens/text_keywords`의 한국어 토큰("권양")은 직렬화 호환·DXF 검출 목적으로 보존.
  2. **`_compute_pier_x_centers` 헬퍼 분리**:
     - Phase B' pier 폴리곤이 있으면 각 폴리곤의 X bbox 중심 사용, 없으면 parametric 계산(gate_centers_x + pier_w).
     - `_build_piers`와 `_build_gate_hoists`가 동일 결과를 공유.
  3. **`_build_gate_hoists` 재작성 (pier 중심 기반)**:
     - 루프를 `p.gate_centers_x` → `pier_x_centers`로 교체 ⇒ 개수는 n_gates+1 (= pier 수).
     - 치수: `house_w = max(p.pier_width, 2.5)` (pier 폭에 딱 맞춤 — 양옆 돌출 제거),
       `house_l = 2.2m`, `house_h = 2.1m` (평면도·측면도 실측).
     - Y 중심: `nose_len + 0.5m` (pier body 시작선 0.5m 하류 = 평면도 실측 local Y ≈1.5m 지점 근방).
     - Z: `base_z = pier_top_el - 0.1m`(embed), top `base_z + 2.1`. 더 이상 허공에 뜨지 않음.
     - 지붕 overhang 0.2m로 축소해 body Y 범위 내 유지.
- **검증**(default 파라미터 + 수면/apron/공도교 off):
  - pier_x_centers = [1.5, 19.5, 37.5, 55.5]m (4개)
  - 각 hoist X=pier와 정확히 동일 범위 ([0,3]/[18,21]/[36,39]/[54,57]), 양옆으로 돌출 없음
  - hoist Y=[3.00, 5.20], Z=[55.90, 58.00] → 측면도 실측 EL.55.9~57.977과 일치
  - 지붕 X overhang ±0.2m만, Y overhang ±0.2m만 (body 0.1m 이상 margin 확보)
  - 모든 구조물 Y=[0, 25] body 내, 허공 부양 제로
  - `ast.parse` 4 files OK / `import` 성공

### [fix] "권양기 하우징" → "여수로 개폐장치"로 용어 정정 + 허공 부양 버그 해결

- **파일**: `gate_3d_builder.py`
- **배경**: 사용자 지적 — 빌더 내부 명칭 "권양기 하우징"은 정확한 도면 용어가 아님. 실제는 **여수로 개폐장치(gate hoist)**. 또한 error2.png에서 개폐장치가 공도교/pier 상면과 분리된 채 2.4m 상공에 떠 있는 것이 관측됨.
- **도면 검토 (수문_1.dxf 평면/정면, 수문_2.dxf 측면)**:
  - 측면도 MZ-BASE 레이어 Y=24938~27015mm(표고 offset=30.962m 적용 시 EL.55.900~57.977), 높이 ≈ 2.1m.
  - 평면도 MZ-LINE Y 분포 66366~71344mm → Y 방향 평면 길이 ≈ 5m.
  - 공도교 상면(`el_bridge_top=56.0m`) 대비 개폐장치 바닥 EL.55.9는 **pier 상면 안으로 ~0.1m embed**.
  - 개폐장치 단독 레이어는 DXF에 없음(MZ-LINE/MZ-BASE 혼재). 따라서 빌더는 측면도 관찰 수치를 파라메트릭으로 반영.
- **수정**:
  1. `COLORS` 키 `hoist_housing` → `gate_hoist`, `gate_hoist_roof` 추가.
  2. 메서드 `_build_hoist_housings` → `_build_gate_hoists`로 리네임. `build_all`의 호출부도 갱신.
     (`has_hoist_housings` 플래그 이름은 직렬화 호환 유지를 위해 보존.)
  3. 주요 수치 실측 반영:
     - `house_w = max(gate_width * 0.7, 4.0)` (수문 폭의 70%, ≈10.5m)
     - `house_l = 5.0` (Y 방향, 평면도 기반)
     - `house_h = 2.1` (높이, 측면도 기반)
     - `embed_depth = 0.3` (pier 상면 안쪽으로 파고드는 깊이)
     - `base_z = el_bridge_top - embed_depth` → 허공 부양 제거, "일부 파여서 심어진" 형태 재현
  4. Y 중심도 지붕 돌출 포함 body 범위 내로 clamp(margin=`house_l/2 + 0.3`).
- **검증**:
  - default 파라미터 + 수면/apron/공도교 off → 모든 구조물 mesh Y=[0.00, 25.00] 내부.
  - 개폐장치 본체 Z=[55.70, 57.80], 지붕 Z=[57.80, 58.05]. 측면도 EL.55.9~57.977과 일치 범주.
  - `ast.parse` OK / import 테스트 OK.

### [fix] 3D 프리뷰에서 수문·권양기·교각이 월류부 슬래브(25m) 밖 Y=-9m까지 돌출되던 문제

- **파일**: `gate_3d_builder.py`
- **증상**: 사용자 제보(error1.png/error2.png) — 3D 프리뷰 bbox가 Y=-9.0~25.0 (총 34m)로 표시. 측면도(error.png) 기준 월류부 슬래브 전체 길이는 25m, 수문·옹벽·권양기가 이 범위를 9m 이상 벗어남.
- **근본 원인 2개**:

  1. **`_compute_gate_geometry`의 `gate_y = 1.0` 하드코딩** — gate_height=7m/radius=8.75m 조건에서 `horizontal = sqrt(r²-dz_half²) ≈ 8.02m`이므로 `trunnion_y = gate_y - horizontal = -7.02m`가 되고, 권양기 `y_center = trunnion_y`, `y0 = -8.77m`로 하우징 전체가 상류 경계 8~9m 밖으로 탈출.
  2. **`_build_piers` parametric 폴백에서 pier body를 y0=0부터 시작시키고 그 앞에 nose를 돌출**시킴 → `_make_pier_nose`가 `y_tip = y_front - pier_w*1.2 = -3.6m`까지 튀어나감.

- **수정**:

  1. `_compute_gate_geometry`
     - `ogee_profile`에서 `|z − el_weir_crest|`가 최소인 점의 x를 `crest_y_candidate`로 채택(상류 끝점 x=0은 제외).
     - `hoist_half = 2.0m` (하우징 반길이 1.75 + 지붕 여유 0.2) 반영해
       `gate_y = clamp(crest_y_candidate, horizontal + hoist_half, pier_length - 0.5)`.
     - 결과: default 값에서 `gate_y = 10.02`, `trunnion_y = 2.00`, hoist `y0 = 0.25`, 지붕 `y0 = 0.05` → body 내부.
  2. `_build_piers` parametric 경로
     - `nose_len = pier_width * 1.2`만큼 body 시작점을 안쪽으로 이동(`body_y0 = nose_len`), nose는 `[0, nose_len]` 구간에 배치해 slab 범위 `[0, pier_length]`를 벗어나지 않게 함.
     - 덤으로 `mesh.merge(nose)` 호출도 try/except로 감싸 merge 실패 시 본체만이라도 추가되게.

- **검증**:
  - default GateParams + `has_water_surface/apron/service_bridge=False`로 build → 모든 구조물 mesh Y 범위 `[0.00, 25.00]`. 이전 `-9`값 재현 안 됨.
  - `ast.parse` 통과 / import smoke test OK.

- **비고**:
  - `has_water_surface`(-40~+0.5m)와 `has_downstream_apron`(25~55m)은 시각 맥락용으로 의도적 외부 배치. 사용자 토글로 제어.
  - default ogee_profile에서 crest가 x=1.0으로 설계되어 gate_y가 clamp에 걸려 상대적으로 상류측에 위치. 실제 section DXF가 파싱되어 crest x가 더 정확하게 잡히면 clamp 없이 해당 값 사용(여전히 body 내부로 한정).

---

## 2026-04-20

### [fix] gate_3d_builder·gate_parser 종합 검토 및 결함 정리

마지막 빌드 전 gate_3d_builder.py + gate_parser.py 전반 검토. 직접 보고된 `'list' object has no attribute 'merge'`의 실제 원인부터, 병행해 발견한 4개 결함까지 일괄 수정.

- **파일**: `gate_3d_builder.py`, `gate_parser.py`
- **검증**: `ast.parse` 통과 + `from gate_parser import GateParams`·`from gate_3d_builder import GateBuilder` 모두 성공.

#### 1. [critical] `_make_gate_arms`에서 list에 `.merge()` 호출 (gate_3d_builder:457)
  - **증상**: `'list' object has no attribute 'merge'` — 수문 암(arm) 빌드 시마다 발생.
  - **원인**: `merged = parts`로 list를 seed로 쓴 뒤 곧바로 `merged.merge(m)` 호출. PyVista의 `merge`는 `PolyData` 메서드.
  - **수정**: `merged = parts[0]`으로 첫 tube를 seed로 삼고, 루프 내 `merge`는 개별 tube 파손 대비 try/except로 감쌈.

#### 2. [high] `_make_pier_nose` 뒷면(back face) 누락 (gate_3d_builder:285-307)
  - **증상**: 노즈가 속이 비어 보일 가능성. 통상 pier body와 겹쳐 문제 없지만 arm merge 실패 시 노출되던 과거 이슈와 복합적 정황.
  - **수정**: `pts[0,3,4,1]` 4-정점 사각형 face 추가 (pier 쪽 면).

#### 3. [medium] `_make_radial_skin` 각도 계산이 sill·top의 실제 위치와 어긋남 (gate_3d_builder:382-388)
  - **원인**: `dx_sill = dx_top = gate_y - trunnion_y`로 두 점의 수평 거리를 동일하게 사용. `_compute_gate_geometry`가 trunnion을 mid_el에 맞추면 대칭이라 결과가 맞지만, trunnion 오차 0.5m까지 허용되는 현재 로직에선 skin이 원호를 살짝 벗어남.
  - **수정**: 각 점의 dx를 `sqrt(radius² − dz²)`로 계산해 실제 원호 상 위치에 정합.

#### 4. [medium] `_make_gate_arms` 방어력 보강 (gate_3d_builder:458-463)
  - `merged = parts[0]` 교체와 함께, 반복문 안에서 `.merge()` 실패 시 해당 tube는 건너뛰고 계속 진행하도록 try/except 추가.

#### 5. [low] `GateParams`에 `el_stoplog_sill` 필드 부재 (gate_parser:50)
  - **증상**: `_scan_text_annotations`가 "Stoplog Sill EL." 패턴을 파싱해도 `hasattr(params, 'el_stoplog_sill')` 체크에서 탈락 → 값이 버려짐.
  - **수정**: dataclass에 `el_stoplog_sill: float = 46.000` 필드 추가. 현재 빌더는 이 값을 직접 쓰지 않지만 `raw_text_annotations`/추후 확장을 위해 보존.

### [fix] gate_3d_builder.py IndentationError (L343)

- **파일**: `gate_3d_builder.py`
- **증상**: `unindent does not match any outer indentation level (gate_3d_builder.py, line 343)` — 모듈 로드 실패
- **원인**: 이전 편집에서 `def _compute_gate_geometry(self):`(L311)가 클래스 들여쓰기(4칸)가 아닌 0칸으로 저장되어 메서드가 모듈 최상위로 분리됨. 뒤따르는 `def _build_radial_gates(self):`(L343, 4칸)과 레벨이 어긋나 파서가 L343에서 에러 발생.
- **수정**: L311 `def _compute_gate_geometry` 앞 공백을 4칸으로 교정해 클래스 메서드로 복원.
- **검증**: `python -c "import ast; ast.parse(...)"` → OK.

### [fix] FLOW → plan_frame_angle 수식이 180° 틀려 수문이 반대로 배치되던 문제

- **파일**: `gate_parser.py`
- **증상**: 직전 FLOW 화살표 검출 구현 후 결과가 여전히 반대 방향으로 배치. 사용자: "수문이 여수로 쪽을 보고 있으면 물이 수문 뒤쪽에 튀는데…"
- **근본 원인**:
  - `structure_placement.fit_meshes_to_quad`는 CW 사각형 default 시 `flip_y_for_cw_quad=True`로 **mesh Y를 먼저 반전** 후 회전 적용
  - 이 Y-flip이 내 빌더 컨벤션(+Y=downstream)과 겹쳐 상쇄되는데, 이전 공식 `atan2(-fx, fy)`는 그 상쇄를 고려하지 않아 plan_frame이 180° 틀린 값(=178°) 생산
  - 결과: 최종 회전 후 mesh +Y가 flow의 **반대** 방향에 매핑 → 수문 skin이 하류(여수로) 방향을 봄
- **유도** (tin_angle=0, detail_angle=0 가정):
  - mesh point (0, 1)이 파이프라인을 거쳐 world (fx, fy) [flow 방향]에 매핑되어야 함
  - (0,1) → Y-flip → (0,-1) → rotate(-span) → `(-sin(span), -cos(span))`
  - 이 벡터 = (fx, fy) → sin(span)=-fx, cos(span)=-fy
  - 따라서 **span_angle = atan2(-fx, -fy)**
- **변경**:
  - 기존 `math.atan2(-fx, fy)` → `math.atan2(-fx, -fy)` (두 번째 인자 부호 뒤집기, 결과 180° 보정)
  - 주석에 유도 과정 풀이 추가
- **검증** (수문_1.dxf, flow=(-0.035, -0.999)):
  - 이전 plan_frame_angle = 178°
  - 수정 후 plan_frame_angle = **+2.00°** ≈ 0°
  - 수학 검증: Mesh +Y point (0,1) 파이프라인(Y-flip + rotate(-2°)) 통과 후 → **(-0.035, -0.999)** = flow 방향과 일치 ✓
  - 결과: 수문 skin(convex면)이 **upstream(물 쪽)** 을 향하고, trunnion/hoist가 **downstream(여수로 쪽)** 으로 올바르게 배치

### [feat] 도면의 FLOW 화살표 자동 인식으로 수문 방향 180° 모호성 해소

- **파일**: `gate_parser.py`, `structure_templates.py`
- **증상**: 수문이 설치 방향(upstream/downstream)을 반대로 빌드하는 문제. PCA만으로는 span 축만 알 수 있고 어느 방향이 downstream인지 180° 모호성 존재 → 이전에는 사용자가 4점 매칭에서 quad 순서로 잡아야 했지만 centroid 폴백에선 틀릴 가능성. 사용자 요청: 도면의 "FLOW" 화살표를 인식해 자동 보정.
- **변경**:
  - `GateParams`에 `flow_direction_2d: Optional[tuple]` 필드 추가 (DXF XY 프레임의 단위벡터).
  - 신규 `GateParser._detect_flow_direction(msp)`:
    1. TEXT/MTEXT에서 "FLOW"/"흐름"/"유수" 키워드 탐지
    2. 각 텍스트 반경 10m 내 LINE 수집 → 길이 ≥2m는 shaft 후보, <2m는 arrowhead
    3. shaft 최장 선택, 양 끝점 중 **arrowhead 점 밀집도가 높은 쪽을 tip**으로 결정
    4. tip-tail 벡터 정규화. 여러 FLOW는 평균 (불일치 시 |avg|<0.3으로 거부)
  - `_parse_plan_file`의 plan_frame_angle_deg 계산 로직 변경:
    - **1순위**: FLOW 검출 성공 시 flow 각도에서 derive
      - 빌더 +Y = flow 방향 / 빌더 +X = rotate(-90°)(flow) → plan_frame_angle = atan2(-fx, fy)
      - 전체 -180~+180 범위 유지 (모호성 없음)
    - **2순위**: 기존 PCA (결과는 -90~+90 정규화, 180° 모호성 존재)
  - `structure_templates.SpillwayGateTemplate.parse` pass-through에 `plan_frame_angle_deg`, `flow_direction_2d` 추가.
- **검증**:
  - **수문_1.dxf** (사용자가 FLOW 2개 추가): `flow_direction_2d=(-0.035, -0.999)` ≈ DXF 남쪽 방향, `plan_frame_angle_deg=+178.0°` (= builder +X가 DXF -X 방향). 이전 PCA로는 `-2°` 근처였으므로 **180° 뒤집힘 해소** ✓
  - **여수로 수문1.dxf** (FLOW 없음): flow_direction_2d=None, PCA 폴백으로 `-2.23°` 유지 — 기존 동작 보존 ✓
  - 템플릿 경유 빌드 22 meshes 정상.
- **한계/후속**:
  - 검출 파라미터는 경험적(반경 10m, shaft 길이 2m). DXF 축척이 매우 다르면 동작 안 할 수 있음 → 후속에서 view_detector의 unit_scale 활용해 자동 조정 가능.
  - 현재 gate에만 적용. 다른 구조물(intake_tower, valve_chamber)도 유사한 FLOW 기반 방향 결정 가능 — 후속 turn.
  - centroid 폴백 경로는 `orientation_deg` from `compute_orientation_from_points`를 사용. FLOW 기반 각도를 여기에도 전달하도록 egview_maker의 centroid 계산부 업데이트 필요 (후속).

### [fix] 도로 cut/fill TIN을 재-삼각화 경로로 교체 (vertex displacement → synthetic 정점 + 새 Delaunay)

- **파일**: `egview_maker.py` (`_retriangulate_for_cut_fill`, `_resample_polyline`, `_slope_breakpoints`, `_deform_tin_for_plans`)
- **증상 (error.png)**: 이전 vertex displacement 방식은 target Z 계산이 정확했으나, TIN 정점이 등고선에 정렬돼 있어 이를 단순히 이동시키면 **삼각형 스파이크·찢김** 발생. 도로 주변 원본 삼각화 구조가 사면 단절점(소단 모서리·toe)을 표현할 수 없었음.
- **근본 해결 — 재-삼각화**:
  - **synthetic 정점을 사면 단절점 위치에 삽입**: 도로 중심선, 도로 edge(±half_w), 각 사면 꼭대기/소단 edge, 사면 toe. along 방향 2m 간격 re-sampling.
  - **cut zone 폴리곤 내부 원본 TIN 정점 제거**: 사면 toe 기준 footprint 계산, `MplPath.contains_points`로 마스킹.
  - **잔존 TIN + synthetic 통합 후 `scipy.spatial.Delaunay`로 재-삼각화** → 도로 평탄면·소단·사면이 모두 삼각형 경계에 정확히 일치하는 깨끗한 기하.
- **변경**:
  - 신규 `_resample_polyline(pts, step=2.0)` — polyline을 균등 2m 간격으로 재샘플링.
  - 신규 `_slope_breakpoints(depth, half_w, vh, berm_v, berm_w)` — 사면 단절점 `(cross_dist, v_rise)` 리스트 반환 (도로 edge · 각 사면 꼭대기 · 각 소단 끝 · toe).
  - 신규 `_retriangulate_for_cut_fill(...)` — cut/fill 레이어만 대상, synthetic 정점 생성 + 원본 제거 + 재-삼각화.
  - `_deform_tin_for_plans` 초반에 cut/fill 레이어 존재 시 재-삼각화 경로로 분기. terrain/manual만 있으면 기존 smoothstep 유지.
- **검증** (100×100 평탄 TIN EL=60, 도로 Y=50 width 6m, 절토 10m, V:H=1:0.5, 소단 5m@1m):
  - 원본 900 정점 → 잔존 756 + synthetic 451 = 1,207 정점, 2,296 삼각형 (깨끗한 삼각화)
  - X=50 단면 Z 프로파일:
    - Y=47, 50, 53 (도로 평탄): **Z=50.00** ✓
    - Y=44.5 / 55.5 (h_edge=2.5, 첫 사면 꼭대기): **Z=55.00** ✓
    - Y=43.5 / 56.5 (h_edge=3.5, 첫 소단 끝): **Z=55.00** (소단 플랫) ✓
    - Y=40.7 / 59.0 (h_edge=6, 사면 toe): **Z=60.00** (지형 복귀) ✓
  - 완벽한 계단식(stepped) 사면 프로파일 + 1m 폭 소단.
- **정직한 한계**:
  - `_excavate_tin_for_structures`(구조물 굴착)은 아직 이전 로직. 구조물에도 동일 재-삼각화 적용은 다음 턴.
  - terrain/manual 모드는 재-삼각화 적용 안 함 (cut/fill 명시 offset이 있을 때만 synthetic 생성). terrain mode에서도 깨끗한 삼각화가 필요하면 후속 확장.
  - TIN 재-삼각화는 원본 정점의 배열 순서를 유지하지 않음. 이후 structure placement 등에서 인덱스에 의존하면 재검증 필요.
  - 현재 cut zone 폴리곤이 여러 도로 겹치면 토대 벤 부분이 중복 처리될 수 있음. 단일 도로엔 영향 없음.

### [feat] 도로 TIN 변형에 토목 표준 사면 + 소단 자동 생성 (V:H=1:0.5, 5m@1m)

- **파일**: `egview_maker.py` (`_open_elevation_dialog`, `_deform_tin_for_plans`, 신규 `_cut_slope_rise`)
- **증상 (error.png)**: 직전 smoothstep 블렌드만으로는 원본 TIN 정점이 등고선에 정렬돼 있어 절토 적용 시 **삼각형 스파이크**가 튀어나오는 현상. 사용자 요구: 도로 시/종점 계획고 선형 보간은 이미 맞지만, 주변 지형과 연결되는 절토/성토 사면이 **V:H=1:0.5 경사 + 5m마다 1m 소단**의 토목 표준으로 자동 형성되어야 함.
- **변경 A — 신규 헬퍼 `_cut_slope_rise(h_dist, total_depth, vh_ratio, berm_step_v, berm_width_h)`**:
  - 수평 h_dist 지점에서의 수직 상승량 반환 (cut/fill 공통).
  - 알고리즘: V:H 비율(예: 1:0.5)로 sloping 하다가 수직 `berm_step_v`(5m) 도달 시 수평 `berm_width_h`(1m) 소단 삽입, 다시 slope, 반복. `total_depth`에서 캡.
  - 단위 검증 통과: h=1→v=2, h=2.5→v=5, h=2.5~3.5 (소단) v=5 유지, h=4→v=6, h=6→v=10 (최종).
- **변경 B — `_deform_tin_for_plans` 재작성**:
  - `is_cut_fill_mode` 분기: 이전의 `smoothstep` transition → **표준 사면 기하**로 교체.
  - 각 TIN 정점에 대해:
    1. 가장 가까운 도로 세그먼트의 (along, cross) 계산
    2. cross ≤ half_w → 도로 평탄 (target_z = road_z)
    3. cross > half_w 이고 cut/fill 모드:
       - `h_from_edge = cross - half_w`
       - `cut_depth = terrain_z - road_z` (부호로 cut/fill 결정)
       - `slope_rise = _cut_slope_rise(h_from_edge, |cut_depth|)`
       - cut: `target_z = road_z + slope_rise`, 지형 초과 시 지형 캡
       - fill: `target_z = road_z - slope_drop`, 지형 미만 시 지형 캡
       - 사면 영역 weight = 1.0 (완전 적용)
    4. terrain/manual 모드는 기존 smoothstep 유지
  - 사면 영향 범위 자동 계산: `slope_horiz_max = offset × vh_ratio + (offset/berm_step × berm_width)` → 예상보다 멀리까지 탐색.
- **변경 C — 다이얼로그 확장**:
  - 기존 7개 컬럼 → **10개 컬럼**: 레이어 / 방식 / 시EL / 종EL / 절·성(m) / **V:H** / **소단V(m)** / **소단W(m)** / 전이(m) / 시·종좌표
  - 각 도로별 독립적으로 경사비·소단 간격·소단 폭 입력 가능 (default V:H=0.5, 소단 V=5, W=1)
  - `layer_elevations[ln]`에 `slope_vh`, `berm_step_v`, `berm_width_h` 저장.
- **검증** (100×100 평탄 TIN EL=60, 도로 Y=50 width 6m, 절토 10m):
  - Y=48.72 (cross=1.28, 도로 위): Z=**50.00** (road_z) ✓
  - Y=46.15 (h_edge=0.85, 첫 사면): Z=**51.69** (=50+1.7, 계산값 51.7) ✓
  - Y=43.59 (h_edge=3.41, 첫 소단): Z=**55.00** (소단 플랫 v=5 유지) ✓
  - Y=41.03 (h_edge=5.97, 두 번째 사면): Z=**59.95** (=50+9.94) ✓
  - Y=38.46 (h_edge=8.54, 사면 완료): Z=**60.00** (지형 유지) ✓
  - 양쪽 대칭 ±Y 동일 프로파일 ✓
- **정직한 한계**:
  - TIN 정점은 등고선에서 추출된 비정형 위치. 사면 프로파일 **목표 Z는 정확**하나, 정점 밀도가 낮으면 시각적으로 여전히 거칠 수 있음. 완벽히 매끈한 사면을 위해선 synthetic vertex 삽입 + 재-삼각화 필요 (후속 작업).
  - `_excavate_tin_for_structures`는 아직 이전 로직 사용. 구조물 굴착에도 동일 사면 로직 적용은 다음 턴.
  - `transition_m`은 이제 terrain/manual 모드에서만 사용 (cut/fill은 경사 기하가 대체).

### [feat] 도로 TIN 변형 재작성 — 절토/성토 모드 + smoothstep 부드러운 전이

- **파일**: `egview_maker.py` (`_open_elevation_dialog`, `_deform_tin_for_plans`)
- **증상 (road_error.png, road_error2.png)**: 도로를 평면도에 올리고 고도 설정(인근 지형/계획고 어느 쪽이든) 후 TIN 변형하면 도로가 **수직 절벽** 양쪽으로 튀어나온 "협곡"·"댐"처럼 보임. 인접 지형과 어긋남. 사용자 요청: 도로별 절토/성토 선택·수치 입력·부드러운 TIN 연결.
- **근본 원인**:
  - 기존 `_deform_tin_for_plans`가 도로 중심 ±`half_w`(3m) 이내를 **`pts[vi, 2] = road_z`로 강제 평탄화** → 주변 지형 Z와의 차이만큼 즉시 절벽.
  - `slope_w = half_w * slope_ratio = 4.5m` 폭의 좁은 전이 영역만 **선형** 블렌드 → C0 연속이지만 경사 단절(kink) 유발.
  - 절토/성토 개념이 schema에 전무. `z_offset` YAML 상수는 있으나 양수 지원·UI 입력 없음.
  - 한 정점이 여러 세그먼트(곡선 구간) 근처에 있을 때 매 iteration에서 Z를 **덮어쓰기** → 마지막 세그먼트만 반영되는 문제도 잠재.
- **변경 A — 고도 다이얼로그 (`_open_elevation_dialog`)**:
  - 모드 4가지로 확장: `"인근 지형 참조"(terrain)` / `"계획고 입력"(manual)` / **`"절토"(cut)`** / **`"성토"(fill)`**
  - 신규 컬럼 2개: `"절토·성토(m)"` (수치 입력), `"전이폭(m)"` (smoothstep blend zone, 기본 10m, 최소 1m)
  - 모드 변경 시 placeholder 자동 갱신, 상관없는 필드는 비움.
  - 저장 스키마: `layer_elevations[layer_name] = {mode, start_el, end_el, offset_m, transition_m}`.
- **변경 B — `_deform_tin_for_plans` 전면 재작성**:
  - **Per-vertex weight 누적**: 각 TIN 정점에 대해 모든 인근 도로 세그먼트의 영향을 `sum_wz += w × road_z`, `sum_w += w` 로 합산.
  - **smoothstep 감쇠**: cross_dist ≤ half_w 이면 w=1, half_w ~ half_w+transition_m 에서는 `w = 1 − 3u² + 2u³` (C1 연속). → 급격한 cliff 사라지고 **매끄러운 사면** 형성.
  - **Cut/Fill 오프셋**: terrain 샘플된 도로 중심선 Z에 `-offset_m`(cut) 또는 `+offset_m`(fill) 적용.
  - **Manual Z**: start_el/end_el 선형보간 (이전 동작 유지, 전이 적용).
  - 최종 정점 Z = `blend × Σ(w·z)/Σ(w) + (1−blend) × original_z`, `blend = min(sum_w, 1)` — 누적 영향 약한 곳은 원지형 유지 비중↑, 도로 중심은 완전 교체.
  - 곡선 구간 다중 세그먼트 영향도 선형 가중 평균으로 자연 처리.
- **검증** (100×100m 경사 TIN + 80m 수평 도로 Y=50):
  - **terrain** (offset=0): road_center Z=57.50 (지형과 정확 일치) / far Y=0: 55.00 (미영향) ✓
  - **cut 5m, transition 10m**: road_center=52.50 (=57.5−5) / edge Y=44(3m 밖): 53.76 / trans Y=40(7m 밖): 56.05 ✓
  - **cut 5m, transition 20m**: trans Y=40(7m 밖): 53.85 (더 넓은 전이가 더 오래 cut 영향 유지) ✓
  - **fill 3m**: road_center=60.50 (=57.5+3) ✓
  - far 정점 55.00 그대로 유지 — 원지형 보존 ✓
- **트레이드오프**:
  - `surface_overlay` (굴착/성토 폐합 영역)는 현재 영역 내부 일괄 offset만. 경계부 smoothstep 전이는 후속 개선.
  - `slope_ratio` YAML 파라미터는 이제 무시됨 (transition_m이 대체). 기존 `z_offset` YAML은 **추가 미세 조정**으로 누적.

### [fix] 래디얼 게이트 sill이 weir crest에 맞닿지 않던 기하 오류 (trunnion 위치 계산식 버그)

- **파일**: `gate_3d_builder.py`
- **증상**: 직전 수정으로 arc 방향은 바로잡혔지만, 게이트 sill이 의도한 (gate_y=1.5, sill_el)에 있지 않고 상류로 1.46m 튀어나간 (Y=0.045)에 위치. 사용자 지적: "수문 문을 닫아놓는 방향이 Weir Crest에 맞닿아야 하는데 반대로 설치됨".
- **근본 원인** (기하학적 일관성 위반):
  - 이전 코드: `trunnion_y = gate_y + radius * 0.7` (단순 휴리스틱)
  - 결과: 실제 (gate_y, sill_el)과 trunnion의 거리 = 7.05m, 그러나 radius = 8.75m → **원호가 (gate_y, sill_el)을 지나지 않음**. 원호 sill 끝점은 0.045로, 의도보다 1.46m 상류에.
  - 원호가 sill/top을 정확히 지나려면 trunnion_y는 `sqrt(R² − (gate_h/2)²)` 수직 offset을 만족해야 함.
- **변경**:
  - 신규 `_compute_gate_geometry()` 헬퍼: 원호 구속조건(trunnion → sill/top 거리 == R)에서 trunnion_y를 도출. `gate_y = gate_y + sqrt(R² − (gate_h/2)²)` 공식.
  - 제약 검증: `trunnion_el`이 `(sill_el + top_el)/2` 에서 ±0.5m 이상 벗어나면 midpoint로 강제(구속조건 상 midpoint여야 함).
  - `gate_y`를 **1.5 → 1.0**으로 조정 (ogee_profile 기본 weir crest Y=1.0과 정렬) → sill과 weir crest가 동일 Y 평면에서 맞닿음.
  - `_build_radial_gates`, `_build_hoist_housings` 모두 이 헬퍼 사용해 일관성 유지.
- **검증** (수문_1.dxf + 수문_2.dxf):
  - `distance(trunnion, sill) = 8.750m`, `R = 8.750m` → **오차 0.000m** ✓
  - `distance(trunnion, top) = 8.750m` → **오차 0.000m** ✓
  - Arc bounds: Y=[0.27, 1.00], Z=[46.70, 53.70] — sill/top이 `(Y=1.0, sill_el=46.70)` / `(Y=1.0, top_el=53.70)` 에 정확히 위치 ✓
  - Hoist Y=[7.27, 10.77], 중심 9.02 = trunnion_y ✓ (개폐장치가 트러니언거더 위에 정확 배치)
- **정직한 한계**:
  - `trunnion_el` midpoint 강제는 근사. 실제 도면이 非midpoint 설계면 원호 근사값 사용.
  - `gate_y = 1.0`은 기본 ogee_profile 기준. 사용자가 다른 weir crest Y 도면을 쓰면 어긋날 수 있음 — 필요 시 schema 파라미터로 노출 가능.
  - radius = gate_h * 1.25는 heuristic. 도면 실측과 차이 가능.

### [fix] 래디얼 게이트 호(arc) 방향 뒤집힘 + 개폐장치 위치 오류

- **파일**: `gate_3d_builder.py`
- **증상 (gate1.png)**: (1) 수문 arc가 gate_top(53.7m)을 훨씬 초과해 Z≈59m까지 솟아 월류부와 피어 상단을 전부 덮음, (2) 개폐장치(hoist) 박스가 보이지 않거나 너무 하류에 위치해 도면과 불일치.
- **근본 원인 1 — Arc 장경로 보간**:
  - `_make_radial_skin`에서 trunnion 기준 sill 각도(-150°)와 top 각도(+150°)를 `ang_sill*(1-t) + ang_top*t`로 선형 보간.
  - 두 각도가 ±180° 양쪽에 있어 **장경로(300°)** 로 돌아 arc가 0°(하류 방향)를 통과 → 중간점 Z=trunnion_el+radius=58.95m → gate_top 훨씬 초과.
  - **수정**: `if ang_top - ang_sill > π: ang_top -= 2π` (또는 반대)로 **짧은 경로(60°)** 강제. n_circ를 12→16으로 증가해 smoothness 향상.
  - 결과: arc Z 범위 [45.86, 54.54] (이전 ~58) — gate_top 근사한 올바른 높이.
- **근본 원인 2 — Hoist 위치 너무 하류**:
  - `_build_hoist_housings`가 `y0 = pier_length * 0.6 = 15m`에 배치. 도면은 개폐장치가 **트러니언거더 바로 위** (Y ≈ trunnion_y + 약간).
  - 게다가 over-tall arc가 hoist 박스를 덮어 시야에서 사라졌음.
  - **수정**: trunnion_y 계산식(`gate_y + radius*0.7 = 7.62m`)을 builder에서 재사용, 개폐장치 중심 `y_center = trunnion_y + 0.5`로 이동.
  - 결과: hoist Y 범위 [6.38, 9.88] — 트러니언 바로 위, 피어 상단에 올바르게 배치.
- **검증** (수문_1.dxf + 수문_2.dxf, el_gate_sill=46.7 / el_gate_top=53.7 / el_trunnion=50.2):
  - gate skin Z: [45.86, 54.54] (이전 [45~58.95]) ✓
  - hoist Y: [6.38, 9.88] (이전 [15, 18.5]) ✓
  - trunnion_y=7.62m 계산 일관 ✓
- **남은 한계/후속**:
  - arc radius 기본값 `gate_h * 1.25 = 8.75m` — 도면마다 실측값 다를 수 있음. 필요 시 schema에 radius 편집 파라미터 추가 가능.
  - 개폐장치 하우징은 단순 박스 + 평지붕. 도면의 실제 크레인·모터·플랫폼 디테일은 미반영.
  - 수문 3D 상세 모델 빌더 알고리즘 전반 재설계 과제 여전히 남음 (사용자 지적, 후속 턴).

### [fix] Phase B' 결과 sanity check + 공도교 위치 사용자 편집 파라미터 노출 (옵션 A+B)

- **파일**: `gate_parser.py`, `gate_3d_builder.py`, `structure_templates.py`
- **배경**: 이전 Phase B' pier 폴리곤 추출이 wing wall·트러니언 마운트 등 주변 수직선까지 "pier 영역"으로 묶어 **실제 pier_width 4.85m 대신 9~17m**의 왜곡된 pier가 빌드되고 있었음. Bridge bbox도 auto-populated 경로를 거쳐 의심스러운 값이 그대로 사용. 사용자 지적 E4.png.
- **변경 A — 빌더 sanity check 추가**:
  - `GateBuilder._validate_pier_polys(pier_polys, pier_width, pier_length, tol_ratio=0.5)` 신규 — 각 pier의 X-폭이 `pier_width * (1 ± 0.5)` 범위 외면 False 반환 → 전체 Phase B' pier 거부, parametric(4.85m 고정 폭)으로 자동 폴백.
  - `GateBuilder._validate_bridge_bbox(bbox, total_span, pier_length)` 신규 — width를 `total_span × 0.2~1.5`, height를 `pier_length × 0.05~1.2` 범위로 검증. 실패 시 해당 경로 거부.
  - `_build_piers` 경로 변경: `if len==n_gates+1 AND validate_pier_polys`일 때만 폴리곤 사용, 아니면 parametric.
  - `_build_service_bridge` 경로 3단계화:
    1. 사용자 override(x1>x0 & y1>y0) + sanity 통과 → 사용자 bbox
    2. 파서 `bridge_plan_bbox` + sanity 통과 → 추출 bbox
    3. 위 모두 실패 → parametric (기존 default)
    결과 source가 `[builder] bridge source=user|extracted|parametric`으로 로그에 명시됨.
- **변경 B — 공도교 위치 사용자 편집 파라미터**:
  - `GateParams` 신규 필드 `bridge_x_start/bridge_x_end/bridge_y_start/bridge_y_end: Optional[float]`.
  - 파서가 `bridge_plan_bbox` 추출 시 이 4개 필드에도 같은 값을 복사 → UI에 **자동 추출값이 기본 표시**.
  - `SpillwayGateTemplate.get_parameter_schema`에 4개 float 엔트리 추가 (단위 m, 범위 -50~500).
  - 템플릿 `parse`가 params.params dict에 4개 필드 pass-through (None → 0.0 대체 후 float).
  - 사용자가 UI에서 값 편집해 실제 도면 치수 입력 가능.
- **검증 시나리오** (실측 수문_1.dxf + 수문_2.dxf):
  - Phase B' pier 결과: W=[9.74, 8.88, 8.88, 17.24]m — **모두 sanity tol_ratio=0.5 (3.64~6.06) 초과** → 거부 → parametric 폴백으로 실제 4.84m uniform pier 4개 빌드 ✓
  - 4 bridge 시나리오 E2E:
    1. 자동(파서 auto-populated UI 값): `source=user`, bbox=(39.71, 0.10, 62.09, 8.55) — sanity 통과
    2. 사용자 수동 수정 (0~64.4, 0~7.5): `source=user`, bbox=수정값 ✓
    3. 사용자 이상값(999~1000): sanity 실패 → `source=extracted`, 파서 bbox 재사용 ✓
    4. 사용자 0 리셋: x1>x0 조건 실패 → `source=extracted` ✓
- **정직한 현황**:
  - Pier: Phase B' 알고리즘이 실측 도면에선 작동 안 함(sanity 통과 못함) → 당분간 parametric 4.85×25m uniform 사용. 결과가 도면과 정확히 일치하진 않지만 **최소 일관되고 기하학적으로 말이 됨**.
  - Bridge: 파서 auto bbox가 sanity 통과하면 사용, 아니면 사용자가 UI에서 실측 값 입력 가능.
  - **수문 3D 상세 모델 빌더 알고리즘 전반 재설계 필요**(사용자 지적) — 후속 턴 예정.
  - 이후 VLM 피드백 루프로 시각 검토 수행 예정.

### [fix] 수문 공도교 위치가 도면 무관하게 default로 전체 구조물을 가로지르던 치명적 미완성

- **파일**: `gate_parser.py`, `gate_3d_builder.py`, `structure_templates.py`
- **증상** (사용자 error_01.png, error_02.png): 공도교가 수문 바로 위를 가로지르며 간섭, 도면의 실제 공도교는 구조물 오른쪽 1/3에만 있음(X=48-71m 국소, 전체 span 64.4m). 사용자가 UI에서 공도교를 끌 방법조차 없음.
- **근본 원인**:
  1. Phase A에서 `has_service_bridge` 플래그는 추가했으나 **위치는 여전히 default 하드코드**. 빌더 `_build_service_bridge`가 `x0=-pier_w/2`부터 `x1=total_span+pier_w/2`까지 전체 폭을 가로지르는 deck 생성, Y는 `pier_length*0.3~0.55` 고정.
  2. CS-CONC-Bridge 레이어에 실제 bbox geometry(X=60329-82705mm, Y=48217-56668mm, 22.4×8.5m)가 있는데 파서가 검출 여부만 True/False로 쓰고 좌표는 무시.
  3. UI의 `get_parameter_schema`에 `has_service_bridge` 토글이 없어 사용자가 오검출 시 수동으로 끌 수 없음.
- **변경**:
  - `GateParams`에 신규 필드 `bridge_plan_bbox: Optional[tuple]` (x0, y0, x1, y1 in local m) + `bridge_deck_thickness_m: float = 1.2`.
  - 파서:
    - `_extract_bridge_bbox_mm(msp)` — `bridge/공도교/공도/service road` 토큰 레이어의 LINE/LWPOLYLINE bbox(DXF mm) 반환. `관리도로_수정` 같은 보조 레이어는 주요 토큰에서 제외해 노이즈 차단.
    - `_extract_plan_polygons`의 3단계 pier 추출 경로(closed_polylines / face_enumeration / vertical_clusters) 각각이 **실제 사용한 origin_mm을 `self._pier_origin_mm`에 기록** → bridge bbox가 pier와 동일 로컬 프레임으로 정규화되어 정합.
  - 빌더 `_build_service_bridge`:
    - `bridge_plan_bbox`가 설정돼 있으면 그 x0/y0/x1/y1를 deck·난간 bbox로 직접 사용
    - 없으면 기존 parametric default 폴백
    - Z는 여전히 `el_bridge_top` 파라미터 기반 (측면도 자동 추출은 후속 작업)
  - `SpillwayGateTemplate.get_parameter_schema`에 4개 부속 토글 추가(`int`, 1=켜기/0=끄기):
    - `has_service_bridge`, `has_hoist_housings`, `has_downstream_apron`, `has_water_surface`
    - 파서 자동 검출 결과가 default로 표시되지만, **사용자가 1↔0 편집해 오버라이드 가능**.
  - 템플릿 파라미터 dict에 `bridge_plan_bbox`, `bridge_deck_thickness_m` pass-through. `has_*` 플래그는 `int(bool(...))` 직렬화, `build_meshes`가 `bool(v)` 역변환해 dataclass에 반영.
- **검증** (실측 `수문_1.dxf + 수문_2.dxf`):
  - 자동 검출: `bridge_plan_bbox=(39.71, 0.10, 62.09, 8.55)m` (실제 도면 그대로). pier[2](X=38.6-47.5) ~ pier[3](X=57.5-74.7) 사이+약간, 폭 22.38m — **이전 "전체 폭 가로지름" 대비 도면 일치**.
  - 사용자가 `has_service_bridge=0`: 22 → 19 meshes (bridge deck 1 + 난간 2 제외, 정확).
  - 사용자가 `has_hoist_housings=0`: 22 → 16 meshes (3 gate × box+roof = 6 hoist 제외, 정확).
- **정직한 한계**:
  - Z(높이) 방향 bridge 위치는 여전히 `el_bridge_top` default(56m)에 의존. 측면도에서 bridge EL 자동 추출은 CS-CONC-Bridge 레이어가 측면도(`수문_2.dxf`)에 없어 현재 DXF로는 어려움 — 텍스트 annotation("공도교 EL.XX")에서 파싱하는 후속 작업 필요.
  - bbox는 사각형 근사. 실제 공도교가 굴곡진 형태면 약간의 오차 남음 (CAD에서 대부분 직사각 deck이라 허용).
  - `관리도로_수정` 같은 별도 보조 레이어는 의도적으로 제외 — bridge 주 geometry만 반영.

### [refactor] 아키텍처 리뷰 권장안 #1/#2 이행 — god class 완화 + has_* 헬퍼 통합

- **사유**: 스파게티화 방지를 위해 아키텍처 리뷰에서 식별한 우선순위 1·2 리팩터 실행. `egview_maker.py`가 5,027줄(전체 31%)의 god class 상태이고, 각 파서마다 `has_X` 검출 로직이 복붙될 조짐.
- **#1 — `optional_detector.py` (신규)**: 부속 컴포넌트 존재성 검출 로직을 구조물-독립 공통 모듈로 추출.
  - `ComponentSpec` dataclass — 컴포넌트별(레이어 토큰·텍스트 키워드·default·신호부재 처리 방침)을 선언적으로 표현
  - `ComponentReport` — 검출 결과 + 진단(geom_count / text_count / matched_layers)
  - `count_layer_geom(msp, tokens)` / `count_text_hits(msp, keywords)` / `detect_component(msp, spec)` / `detect_components(msp, specs)` / `summary_line(reports)` 공개 API
  - `gate_parser`의 `_detect_optional_components` 리팩터: 89줄 → 14줄 (`_get_component_specs` + `detect_components` 호출). 검출 동작 완전 동일.
  - 다른 구조물 파서(valve_chamber / intake_tower / retaining_wall)도 동일 패턴으로 이 모듈 활용 가능 — 향후 `ComponentSpec` 선언만 추가하면 끝.
- **#2a — `tile_downloader.py` (신규)**: XYZ 타일 다운로드·합성 로직 추출.
  - 순수 함수 `latlon_to_tile` / `tile_to_latlon` / `download_xyz_tiles(url_template, min_lat, min_lon, max_lat, max_lon, zoom, final_size, timeout_s, log_fn)`
  - `egview_maker._latlon_to_tile` / `_tile_to_latlon` / `_download_xyz_tiles` 3개 메서드(97줄) 제거, thin wrapper 1개로 교체.
- **#2b — `gemini_renderer.py` (신규)**: Gemini(Nano Banana) 조감도 AI 호출 로직 추출.
  - 공개 API `run_gemini_render(app, credential, prompt, use_vertex, location)` — app 인스턴스를 첫 인자로 받아 상태(capture_image/job_logger/log/after/...)에 접근
  - Vertex AI(gemini-3.x flash/pro) / API Key(gemini-2.5) / Legacy SDK 3가지 경로 + 모델 폴백 체인 + Harness 로깅 전부 포함
  - `egview_maker._run_gemini_render` 264줄 body → 2줄 delegate로 축소
- **결과**:
  - `egview_maker.py` **5,027 → 4,668줄** (−359줄, −7.1%)
  - 신규 모듈 3개 (`optional_detector.py` ~140줄, `tile_downloader.py` ~145줄, `gemini_renderer.py` ~250줄) — 각자 자기완결, 순환 import 없음
  - 동작 변경 0. 전체 회귀(AST 23 파일·import 18 모듈·gate 직접·템플릿 경유) 모두 통과, 이전과 mesh 수 동일(22).
- **남은 추가 리팩터 여지** (사용자 요청 시):
  - `_open_structure_review_dialog` (606줄) → `structure_review_dialog.py`: 가장 큰 잔여 덩어리. 앱 인스턴스 전달 방식으로 추출 가능.
  - `_open_layer_classifier` (142줄), `_open_elevation_dialog` (187줄), `_open_interactive_viewer` (131줄) 등 대형 UI 메서드
  - `create_tin_from_dxf` (112줄) → `tin_builder.py`
  - 다른 구조물 파서 3개도 `optional_detector` 활용으로 통합 (valve_chamber 먼저 제안)

### [feat] 수직 클러스터 + gap 패턴 기반 pier 복원 (실측 line-soup DXF 커버)

- **파일**: `gate_parser.py`, `structure_templates.py`
- **사유**: 직전 polygon_reconstructor(face enumeration)는 폐합 폴리라인 DXF에선 완벽 동작하나, 실측 DXF처럼 pier 경계가 독립 선분으로 그려진 경우 작은 부속 면만 복원되어 pier 추출 실패. **도메인 특화 heuristic**으로 해결.
- **알고리즘** (`_extract_piers_from_vertical_clusters`):
  1. plan 영역 수직 세그먼트(|dy| > 3·|dx|) 수집
  2. X 좌표 1D greedy 클러스터링 (tol=500mm)
  3. 각 클러스터 `(x_avg, y_min, y_max, total_len)` 계산, total_len ≥ max(pier_length×0.3, 3m) 인 것만 유지
  4. **핵심 insight**: 연속 클러스터 간 gap 분포에서 **상위 n_gates개 gap이 gate opening** — parametric pier_width에 의존하지 않음
  5. gate gap 인덱스로 클러스터를 n_gates+1 개 pier 영역으로 그룹화
  6. 각 영역의 leftmost·rightmost 클러스터가 pier 좌우 경계, Y 범위는 클러스터 교집합(또는 합집합 폴백)
  7. pier bbox 폴리곤 생성, 로컬 좌표 m로 정규화
- **3단계 추출 체인** (`_extract_plan_polygons`):
  1. **closed_polylines** — 폐합 LWPOLYLINE이 있으면 직접 사용 (이상적)
  2. **face_enumeration** — polygon_reconstructor로 face 열거 (tightly connected line soup)
  3. **vertical_clusters** — gap 패턴 (느슨한 실측 DXF, 새로 추가)
  - 각 단계에서 `n_gates+1`개 완전 추출되면 성공. 부분 추출은 parametric 폴백.
  - 사용된 방법을 `[plan_poly] method=...`로 로그에 기록.
- **template_id "spillway_gate" pass-through** (`structure_templates.py`):
  - `StructureParams ↔ GateParams` 변환에서 `pier_plan_polygons`, `plan_outline_polygon`, `has_service_bridge/hoist_housings/downstream_apron/water_surface` 누락 → 템플릿 경유 시 Phase A/B' 결과가 빌더에 전달 안 되던 버그. `params.params` 딕셔너리에 추가해 pass-through.
- **검증**:
  - 실측 `12995740-M40-001` & `여수로 수문1.dxf`: **4/4 pier 복원, method=vertical_clusters**, 22 meshes (pier 4개 + 공도교 deck/난간 2개 + 다른 부속).
  - Pier 치수: W 8.88~17.24m × L 12.57~24.66m (edge pier에서 outer 구조 포함으로 약간 커짐, 중간 pier는 일관). parametric default(4.85×25) 대비 **실제 도면 치수 직접 반영**.
  - 실측 `여수로 수문2.dxf`: 0/4 — 이 파일은 plan view 없이 측면단면만 있어 pier 평면 복원 원천 불가 (정상). 실제 사용은 (1/2)+(2/2) 함께 파싱이 정석.
  - 합성 폐합 DXF: method=closed_polylines, 4/4 (기존 경로 유지).
  - 템플릿 경유 (`REGISTRY.get("spillway_gate").parse+build_meshes`): pier_polys=4 전달 확인, 22 meshes 정상.
- **솔직한 한계**:
  - Edge pier (pier[0], pier[3])의 W가 중간 pier보다 커짐 — structure 외측 wing wall/anchor 등이 pier 영역에 포함되기 때문. 사용자 요청 시 "outer cluster 배제" 후처리 가능.
  - Pier 형상은 rectangle만 (nose 삼각부 미반영). Nose는 parametric 빌더의 `_make_pier_nose`로 별도 유지 고려 필요.
  - 3단계 폴백 체인은 현재 순차. 각 방법의 실패 이유(로그) 활용해 하이브리드(step별 partial 결과 병합) 개선 여지.

### [feat] Line-soup polygon reconstruction 구현 (Phase B' 완성)

- **파일**: `polygon_reconstructor.py` (신규), `gate_parser.py`
- **사유**: 이전 Phase B' 작업에서 개방선으로 그려진 DXF의 pier/outline 폴리곤 복원이 부분 성공(bbox 클러스터링)에 그침. 완전 기하 복원을 위해 **planar 그래프 face enumeration** 구현.
- **모듈 `polygon_reconstructor.py`**:
  - `reconstruct_polygons(segments, tol, min_area)` — 선분 집합 → 폐합 face 복원
  - 알고리즘: (1) 공차 `tol` 내 끝점을 그리드 해싱으로 단일 vertex ID로 묶음 → (2) 무방향 인접 리스트 구성 → (3) **leftmost-turn traversal**로 각 방향 간선마다 "들어온 방향 기준 왼쪽 최대 꺾임"을 따라 면 순환 열거 → (4) canonical form(최소 vertex id 시작 + 정/역 사전순 최소)으로 회전/반사 중복 제거 → (5) shoelace 면적 ≥ `min_area`만 유지, 면적 내림차순 반환
  - `_VertexStore`: 3×3 그리드 셀 탐색으로 O(1) 근접 vertex 검색
  - 안전장치: max_faces 상한, deg-0 정점 처리, cul-de-sac 감지
- **파서 통합 (`gate_parser._extract_piers_from_line_soup`)**:
  - 기존 "gate_centers_x 주변 bbox 클러스터링" 방식을 완전 교체
  - plan 영역 LINE/LWPOLYLINE 세그먼트 → `reconstruct_polygons(tol=5mm)` → 면적 내림차순
  - 최대 면 = outline, 외곽 면적 1.5%~45% + pier 기대 치수(폭 `pier_width × 0.5~2.0`, 길이 `pier_length × 0.5~2.0`) 범위 내 → pier 후보
  - **엄격 성공 기준**: `len(pier_polys) == n_gates + 1` 정확 일치할 때만 True 반환, 빌더가 폴리곤 경로 사용. 부분 추출 시 parametric 폴백 유지 (일관성 우선).
- **검증**:
  - **합성 DXF 테스트** (의도적으로 폐합 LWPOLYLINE으로 그린 3-gate + 4-pier): 4개 pier 모두 정확 복원 — W=5.00m × L=25.00m 4vertex, 빌더가 폴리곤 경로로 19 meshes 생성. **알고리즘 자체는 올바름**.
  - **단위 스모크** (`polygon_reconstructor.__main__`): 겹친 두 사각형(10×10, 3×3)에서 2개 face 정확 검출, 면적 100/9 일치.
  - **실측 DXF** (`SAMPLE_CAD/12995740-M40-001` 등 3개): plan 영역 340 세그먼트 → 6 face 복원되나 크기 0.6~4.9m²로 **pier 기대 치수(≥10m²) 미충족** → size-filter가 모두 배제 → pier_polys=0 → parametric 폴백. 회귀 동작 동일 (bridge 있음 22 meshes / 없음 19 meshes).
- **실측 DXF가 실패하는 이유** (솔직한 진단):
  - 이 DXF들은 pier 경계를 단일 폐합 폴리라인이 아닌 **여러 독립 선분**으로 그림. 게다가 수평 선(gate 상/하 edge)이 **pier 영역을 넘어 여러 gate 걸쳐 연속**되어, pier 주위에서 폐합 cycle이 형성되지 않음.
  - 즉, 알고리즘 한계가 아니라 **도면 표현 양식의 한계**. 해당 DXF에서 pier 폴리곤을 얻으려면 별도 고수준 전처리(수평선 트리밍 + 격자 교차점 기반 재조합) 필요.
  - 앞으로 폐합 폴리곤으로 그려진 구조물 도면(CAD 작성 관행 변경 또는 다른 형식)에선 즉시 동작.
- **남은 작업**:
  - 실측 DXF에서도 동작하려면 "**선 스윕 + 격자 정렬 기반 pier 복원**" 같은 도메인 특화 heuristic 필요 (후속).
  - 같은 Phase A~D를 valve_chamber / intake_tower / retaining_wall에도 적용 (사용자 승인 대기).

### [feat] Gate(수문) 구조물 Phase A~D 통합 — 도면 반영률 향상을 위한 4단계 작업

- **파일**: `gate_parser.py`, `gate_3d_builder.py`
- **배경**: 사용자 지적 — "모든 구조물이 default 값에 의존, 도면 수정이 빌드 결과에 반영 안 됨, 파서가 valve_chamber 수준이라 해도 형편없음". 대안으로 Phase A~D를 수문(gate)에 먼저 적용.

#### Phase A — 부속 컴포넌트 존재성 플래그 확대 (직전 공도교 fix 확장)

- `GateParams` 신규 필드: `has_hoist_housings: bool = True`(래디얼 게이트엔 통상 동반, default 유지), `has_downstream_apron: bool = True`, `has_water_surface: bool = True` (시각 맥락, 사용자 토글 가능). `has_service_bridge`는 직전 구현.
- `_detect_optional_components` 확장: 각 부속별 레이어 토큰(`hoist/권양/winch/gantry`, `apron/에이프런/stilling/물받이/감세`) + 텍스트 키워드 이중 검출. 헬퍼 `_count_layer_geom`·`_count_text_hits`로 규칙 통일.
- `GateBuilder.build_all`에서 각 `has_*` 플래그로 빌드 분기 → 도면에 없는 부속물 강제 생성 원천 차단.
- 권양기는 "신호 부재로 False 낮추는 로직"을 제거 (대부분 DXF가 별도 레이어 없이 본체 위에 그림 → false negative 방지). 사용자는 UI로 수동 조절.

#### Phase B' — Direct Geometry Extrusion (parametric → 실제 도면 기하)

- **문제**: 기존 `_build_piers`가 `pier_width × pier_length × pier_top_el` 스칼라로 박스만 생성 → 도면의 실제 기하 반영 0%.
- **신규 필드**: `plan_outline_polygon: list[(x,y)]` (외곽), `pier_plan_polygons: list[list[(x,y)]]` (각 교각). chamber-local 좌표, m 단위.
- **2단계 추출** (`_extract_plan_polygons`):
  1. 1차: 폐합 LWPOLYLINE 수집(closed flag 또는 기하학적 폐합 tol=5mm) → 면적 내림차순 정렬, 최대가 outline, 외곽의 1.5%~45% 크기가 pier 후보.
  2. 2차 폴백(`_extract_piers_from_line_soup`): DXF가 개방선 집합이면 plan 영역 LINE/LWPOLYLINE 세그먼트 수집 → plan bbox를 outline으로 사용, pier 중심 후보(gate_centers_x 기반)마다 **양쪽 끝점이 모두 pier 영역(±pier_width·1.1) 내인 세그먼트**만 모아 bbox → pier 직사각 폴리곤.
- **Shoelace 면적/기하 폐합 판정** 헬퍼 추가.
- **빌더 (`_build_piers`)**: `pier_plan_polygons`가 **완전 추출(count == n_gates+1)**일 때만 실제 기하로 extrude, 부분 추출은 일관성 우선으로 parametric 전체 폴백. 신규 `_extrude_polygon_xy(poly, z_bot, z_top)`로 임의 XY 폴리곤을 프리즘으로 extrude.
- **한계**: 실측 SAMPLE_CAD(94개 LWPOLYLINE 전부 open)에선 완전 폴리곤 재구성이 어려워 대부분 parametric 폴백 유지. 외곽 bbox는 추출 성공. **완전한 line-soup → polygon 재구성(endpoint graph + cycle detection)은 후속 작업**.

#### Phase C — VLM 피드백 루프 gate 호환 확인

- `structure_vlm_feedback`은 이미 구조물 무관하게 `params_to_dict()`로 dataclass 직렬화 + `render_meshes_topdown()` 호출.
- `apply_diff_to_params`의 valves/pipes 브랜치는 `hasattr(params, "valves")` 체크로 GateParams에선 안전 스킵 (스칼라 `param_updates`만 적용).
- 검증: gate DXF → 도면 PNG(116KB) + 빌드 top-down PNG(31KB) + params JSON(4080 chars) 생성 성공. 즉시 GUI "🤖 AI 검증" 버튼에서 사용 가능.

#### Phase D — view_detector 통합 (y_mid 휴리스틱 교체)

- 기존 `_parse_plan_file`은 CS-CONC-Spillway의 Y 범위 중앙값으로 plan/elevation 경계를 추정(휴리스틱).
- `view_detector.detect_view_regions()`로 정식 view 분리(plan / front / side) → plan view bounds(m)를 mm로 역변환해 plan 영역 점 필터링에 사용. view detector 실패 시 기존 y_mid 폴백 유지.
- 실측: `12995740-M40-001` → plan view 정확히 검출(x=[12.0,105.1]m, y=[42.6,80.1]m, W=93.1m, H=37.5m). 로그에 `[view]` 항목으로 기록.

#### 전체 검증

- AST 8 파일, import 9 모듈 모두 OK.
- Case A (bridge 있는 원본 DXF): 22 meshes, `bridge=O, hoist=O, apron=O, 수면=O`, plan 영역 정확 검출, 교각 부분 추출.
- Case B (bridge 없는 수문2): 19 meshes, `bridge=X` → 공도교 미생성 (직전 fix 유지).
- 템플릿 경유(`REGISTRY.get("spillway_gate")`) 파싱 + 빌드 19 meshes 정상.
- 수문1+2 조합 파싱: plan view + section view 모두 활용, ogee profile 추출 성공.

#### 남은 작업 (사용자 승인 필요)

- **Line-soup polygon reconstruction** (Phase B' 미완): endpoint graph + cycle detection으로 개방선으로 그려진 도면에서도 완전 폴리곤 복원. 성공 시 교각 4개 전부 실제 기하로 렌더.
- **valve_chamber / intake_tower / retaining_wall에 동일 Phase A~D 적용**: 각 구조물 4~6시간 규모. 사용자 승인 후 순차 진행.

### [refactor] 파일/클래스명 spillway → gate (여수로 ≠ 수문 혼동 해소)

- **사유**: 사용자 지적 — `spillway`는 *여수로(수로)* 를 뜻하지만, 현재 해당 파서/빌더가 다루는 구조물은 실제로 **수문(gate)** (래디얼/테인터 게이트 + 교각 + 권양기 등). 명칭이 의미를 가리고 코드 가독성을 해침.
- **변경**:
  - 파일: `spillway_parser.py` → `gate_parser.py`, `spillway_3d_builder.py` → `gate_3d_builder.py` (구 파일 삭제).
  - 클래스: `SpillwayParams`→`GateParams`, `SpillwayParser`→`GateParser`, `SpillwayBuilder`→`GateBuilder`.
  - 함수: `parse_spillway_dxf`→`parse_gate_dxf`, `build_spillway_meshes`→`build_gate_meshes`.
  - 단어 경계(`\b`) 기반 `re.sub`로 모든 import·참조 일괄 치환 (`structure_templates.py` 포함, 총 11개 라인).
- **의도적으로 유지한 것**:
  - `template_id = "spillway_gate"` (structure_v1.yaml 연동) — 템플릿 개념이 *"여수로의 수문"* 이라 `spillway_gate` 복합명이 정확. 외부 YAML·분류 키 변경 범위 최소화.
  - `SpillwayGateTemplate` 클래스명 (structure_templates.py) — 의미상 "여수로 수문 템플릿"이 맞음.
  - CHANGELOG 과거 기록 (`spillway_parser.py` 언급된 구 항목) — 역사적 기록이라 손대지 않음.
- **검증**:
  - AST 17 파일, import 22 모듈 모두 OK.
  - 실제 DXF E2E: `parse_gate_dxf` + `GateBuilder.build_all()` / `build_gate_meshes` / 템플릿 경유(`REGISTRY.get("spillway_gate").parse(...).build_meshes(...)`) 3경로 모두 성공.
  - 공도교 있는/없는 두 케이스 mesh 수 이전과 동일 (22 / 19) — 순수 이름 변경, 동작 무영향.

### [fix] 수문(spillway) 빌더가 공도교(service bridge)를 도면 무관하게 default 강제 생성하던 문제

- **파일**: `spillway_parser.py`, `spillway_3d_builder.py`
- **증상 (사용자 gate.png)**: 사용자가 공도교가 없는 수문 도면을 업로드해도 빌드 결과에 공도교가 항상 생성되어 수문과 시각적으로 겹치고 비현실적인 형상 출력. `el_bridge_top=56.000`이 default 값으로 사용됨.
- **근본 원인**:
  - `SpillwayBuilder.build_all()`이 `_build_service_bridge()`를 **무조건 호출**. 도면에 공도교 layer가 없어도 default 파라미터로 deck + 난간 2개를 생성.
  - `SpillwayParams`에 공도교 존재 여부를 표현하는 플래그가 없었음. 모든 도면이 동일하게 공도교 있음으로 가정.
- **이는 더 큰 패턴(다른 모든 구조물에서도 부속요소가 default로 강제 생성됨)의 일부지만, 사용자 요청에 따라 이번 turn에서는 spillway만 한정 수정**.
- **변경**:
  - `SpillwayParams`에 `has_service_bridge: bool = False` 필드 추가 (default를 False로 두어 미검출 시 안전 — default 강제 생성 방지).
  - 신규 `SpillwayParser._detect_optional_components(msp, params)`:
    - 부분일치 토큰 `("bridge","공도교","공도","관리도로","service road")`로 layer 이름 검사
    - 매칭된 layer에 `_GEOM_TYPES`(LWPOLYLINE/LINE/CIRCLE/ARC/SPLINE/3DFACE/SOLID/HATCH 등) 엔티티가 1개 이상 존재하면 `has_service_bridge=True`
    - 텍스트 키워드는 보조 신호만 — 범례·표 안의 단순 언급으로 인한 false-positive 방지
    - 검출 결과를 `raw_text_annotations`에 `[detect] ...` 항목으로 남겨 디버깅 용이
  - `_parse_plan_file` 시작부에서 호출.
  - `SpillwayBuilder.build_all()`이 `if getattr(params, "has_service_bridge", False)` 시에만 `_build_service_bridge()` 호출 (`getattr` 사용으로 구버전 params와 하위호환).
  - `SpillwayParams.summary()`에 `공도교: O/X` 표시.
- **검증** (실제 SAMPLE_CAD):
  - 케이스 A (`12995740-M40-001` — CS-CONC-Bridge 레이어 존재): `has_service_bridge=True`, 22 meshes (공도교 deck 1 + 난간 2 포함)
  - 케이스 B (`여수로 수문2.dxf` — bridge 레이어 없음): `has_service_bridge=False`, 19 meshes (공도교 누락 정상)
  - mesh diff = 3 (deck 1 + 난간 2) 정확히 일치.
- **남은 일반 패턴 작업** (다른 turn): 동일한 부속 요소 default 강제 생성 안티패턴이 valve_chamber(hatch/stairs/ground), intake_tower(stairs/walkway), retaining_wall(coping/drainage 등)에도 존재. 사용자 승인 후 같은 패턴(`has_X` 플래그 + 레이어 검출 + 빌드 분기)으로 일괄 적용 예정.

### [feat] 구조물 빌드 ↔ 원본 도면 VLM 피드백 루프 (Gemini Vision)

- **파일**: `structure_vlm_feedback.py` (신규), `egview_maker.py`
- **사유**: 구조물 파서/빌더가 80% 정도는 도면을 반영하지만, 누락(예: Y-split 도수관) ·오차(직경)·잉여 등을 자동으로 잡을 결정적 알고리즘이 어려움. 사용자 합의: GAN은 데이터·시간·재현성에서 비현실 → **VLM 피드백 루프**가 PoC에 가장 적합. 새 SDK·키·결제 0개 (기존 `google.genai` + `gcp-key.json` 그대로 재사용).
- **모듈 (`structure_vlm_feedback.py`)**:
  - `render_dxf_to_png(dxf_paths, output, size, dpi)` — `ezdxf.addons.drawing.matplotlib` 백엔드로 도면 평면 렌더.
  - `render_meshes_topdown(meshes, output, size)` — `pv.Plotter(off_screen=True)` + `view_xy()` + 평행 투영으로 빌드 결과 top-down 캡처.
  - `params_to_dict(params)` — dataclass/np 타입을 JSON-safe dict로 정규화.
  - `request_structure_diff(client, drawing_png, render_png, params_dict, structure_type, model)` — 두 이미지 + 파라미터 JSON을 Gemini에 전달, JSON 스키마(summary/match_score/param_updates/valves_missing/valves_incorrect/pipes_missing/pipes_incorrect/excess_notes)로 응답 강제(`response_mime_type=application/json`, `temperature=0.1`). 코드블록 제거 + 부분 JSON 복구 fallback.
  - `apply_diff_to_params(params, diff, selections)` — 사용자가 체크한 항목만 in-place 적용. 스칼라 필드는 setattr + 타입 변환, valves/pipes는 `Valve()`/`Pipe()` 새 인스턴스 append.
  - `build_genai_client(project, location, use_vertex, api_key)` — Vertex AI 우선, API Key 폴백. egview_maker 인증 경로와 동일.
  - `run_feedback_once(...)` — 위 단계를 한 번에 실행하는 편의 함수. `cache/vlm/{structure_type}/`에 비교 이미지 저장.
- **UI 통합 (`egview_maker.py`)**:
  - `_open_structure_review_dialog`의 하단 버튼행에 **"🤖 AI 검증"** (주황) 추가. 클릭 시 백그라운드 thread에서 `run_feedback_once` 실행 (UI 비차단).
  - 응답 수신 시 모달 toplevel `_show_diff_dialog` 자동 표시: match_score(색 코딩 ≥0.85 녹/≥0.6 주황/그 외 빨강), summary, 카테고리별 항목 체크박스(파라미터 업데이트 / 누락 밸브 / 누락 관로) + 참고용(잉여·필드 수정 제안).
  - "✓ 선택 항목 적용 + 재빌드" 버튼: 체크된 것만 적용 → 엔트리 위젯 갱신 → `tpl.build_meshes(state['params'])` 자동 호출 → status_var 업데이트.
  - 인증 우선순위: `gcp-key.json`의 project_id → `GCP_PROJECT_ID` 환경변수 → 사이드바 입력 → API Key. 모두 없으면 명확한 에러 다이얼로그.
- **모델/비용**:
  - 기본 `gemini-2.5-flash` (1회 호출 약 0.05~0.2센트). 필요 시 `gemini-2.5-pro` 또는 `gemini-3-pro`로 모듈 호출부에서 model 파라미터 변경.
  - `cache/vlm/{type}/drawing.png` + `render_topdown.png` 캐시(매 호출 덮어쓰기, 디버그용).
- **새 의존성**: 0개. `google.genai`, `ezdxf.addons.drawing`, `matplotlib`, `pyvista`, `numpy` 모두 기존 사용 중.
- **검증**:
  - AST/import OK (모듈 + egview_maker, 의존 21+개).
  - 렌더링 E2E: 사연댐 제수변실 DXF → 51KB 도면 PNG / 87 메시 빌드 → 21KB top-down PNG 생성 확인 (이미지 시각 비교에서 도면의 4 밸브·Y분기·우측 D1200 vs 빌드의 빈 챔버·우측 파이프만 차이가 명확히 드러나 VLM이 식별 가능한 형태).
  - 프롬프트 합성 5175자, params JSON 2.8KB.
  - `apply_diff_to_params` 합성 diff(chamber_width 14→13.5, valve M-999 추가) → applied=2, errors=0, valves 6→7 정상.
- **PoC 한계 / 향후 개선**:
  - `_set_by_path`는 평면 필드만 지원 (예: `valves[0].diameter` 형태는 미지원). 차후 dot/index 경로 파서 추가.
  - 한 번에 1 cycle만 — 자동 수렴 루프(N회 반복 + match_score 임계) 미구현. 사용자가 필요하면 검증 버튼 다시 누르면 됨.
  - 도면 PNG 렌더가 ezdxf 한계로 일부 형식 코드(MTEXT 색상 등) 단순화될 수 있음. 시각적 충실도가 분류에 영향 주면 dpi/size 상향 또는 PDF 경유 옵션 검토.
  - Gemini가 같은 좌표계 단위(m vs mm)를 정확히 따르도록 프롬프트에 강조했으나 응답에 단위 혼동 가능성 → `apply_diff_to_params`에서 명백한 outlier 자동 거부 로직(예: 직경이 chamber보다 큼) 후속 추가 여지.

---

## 2026-04-19

### [fix] 제수변실 파서: TEXT proximity grouping (라벨 분리·MTEXT 멀티라인·schedule 보강) + dedup

- **파일**: `valve_chamber_parser.py`
- **사유**: 직전 수정에서 MLEADER 기반 추출은 이 DXF엔 잘 동작했지만, **다른 도면에서 라벨이 분리된 경우**(예: "도수관" 단독 TEXT + "M-301" 단독 TEXT가 인접 위치에 있는 케이스, 또는 MTEXT가 `{도수관\\PM-301}` 형태로 멀티라인)는 여전히 분류 누락. 사용자 지적: "Fix valve/pipe classification (text proximity grouping)은 왜 안해?"
- **변경**: `_extract_valves_and_pipes`를 4가지 라벨 분포 케이스를 모두 다루도록 재작성:
  1. 단일 TEXT ("M-302 송수관 주밸브(1)") — 기존 처리
  2. MTEXT 멀티라인 ("{도수관\\PM-301}") — `_clean_mtext`로 포맷 코드 strip + `\\P`→줄바꿈 후 `re.search`로 매칭
  3. 인접 TEXT 분리 ("M-301" + "도수관"이 별개 엔티티) — `proximity_radius_m=5m` 내 다른 엔티티 텍스트 흡수
  4. 라벨 + schedule 표 ("M-301 도수관" 텍스트 인근에 별도 표에서 "강재도관 D2000mm"가 발견됨) — proximity 보강으로 직경 추출
- **분류 정책**: **own_txt 우선**, proximity 보강은 own에 분류·직경이 부족할 때만 실행 (그렇지 않으면 schedule의 다른 M-NNN 항목 텍스트를 흡수해 M-301 도수관이 "버터플라이밸브"로 오분류되는 회귀가 발생).
- **dedup**: 같은 M-NNN이 label column TEXT + 도면 내 MTEXT에 동시 등장해 Pipe/Valve 객체가 중복 생성되던 케이스를 `_dedupe(default_dia)`로 해결 — 같은 이름이면 직경이 default가 아닌 것을 유지.
- **검증** (동일 DXF):
  - M-301 도수관 직경 800mm(default) → **2000mm** (schedule 보강 성공)
  - M-301 중복 2개 → 1개 (dedup)
  - 6 valves / 4 pipes 정확 분류, 87 PolyData 빌더 정상.

### [fix] 제수변실 파서/빌더 — chamber 크기·밸브 위치·관로 모두 어긋나 도면 내용이 거의 반영되지 않던 문제

- **파일**: `valve_chamber_parser.py`, `valve_chamber_3d_builder.py`
- **증상**: 사용자가 `12996710-M43-002 신설 제수변실 설비 배치도.dxf`를 업로드해 미리보기 빌드했을 때, 4개 송수관 밸브(M-302~M-305)·출력 송수관(천상정수장/대암계통 D1,200)·하천유지용수 D80이 전부 사라지고 chamber도 27m × 9m(기본값)로 표시. 사용자 지적: "도수관이 밸브로 취급되어서 도면이 하나도 반영 안 된 것 같아".
- **근본 원인**:
  1. **Chamber 크기**: `_parse_single`에서 `chamber_width = max(default 27.0, plan_view.width)` → 평면도(14m)보다 큰 default가 항상 이김.
  2. **밸브/관로 위치**: 라벨 TEXT의 `insert` 좌표는 도면 라벨 컬럼(차트 옆)에 있어 실제 chamber 내부 위치가 아님. MLEADER(안내선) 끝점이 진짜 위치인데 파서가 MLEADER를 전혀 보지 않음.
  3. **출력 관로 누락**: "{천상정수장 D1,200}" "{대암계통 D1,200}" "{하천유지용수 D80}" 등 destination-only 라벨(M-NNN 없음)을 파이프로 인식하지 않음.
  4. **3D 빌더가 `params.pipes` 무시**: `_build_main_conduit`은 항상 chamber를 X축 관통하는 대형 파이프를 그렸고, `_build_transmission_pipes`는 `params.pipes`를 보지 않고 valves에서 임시로 Y축 파이프를 만들어 도면과 무관한 결과 생성.
  5. **직경 정규식 버그**: "D1,200"의 천 단위 콤마 때문에 `\d{2,4}` 매칭 실패 → 800mm 폴백.
  6. **밸브 방향 고정**: 모든 valves를 Y축 정렬로 그려, X축 흐름 chamber에서 방향 어긋남.
- **변경 (parser)**:
  - `_clean_mtext()` / `_mleader_endpoint()` / `_mleader_text()` 헬퍼 추가 — MTEXT 포맷 코드(`\C4;`/`\P` 등) 정리, MLEADER 끝점/본문 추출.
  - 신규 `_extract_from_mleaders(msp, plan_view, scale, params)`: 16개 MLEADER 순회 → 끝점이 plan_view 내부면 chamber-local 좌표로 변환 → 텍스트에서 M-NNN/"밸브"/"도수관"/"송수관"/destination+D 분류 → Valve/Pipe 객체 생성. 출력 파이프는 leader 끝점에서 chamber 외부로 자동 연장(local_x 부호로 ±X, |y|>|x|이면 ±Y).
  - `_parse_single`에서 chamber 크기를 `= plan_view.width` (default와의 max 제거).
  - MLEADER 결과를 우선, M-NNN 중복 시 텍스트 폴백 보조.
  - 직경 정규식: `(?:Φ|%%[cC]|\bD[\s=:]*)(\d[\d,.]*)` — D 뒤에 천 단위 콤마/마침표 모두 흡수, val≥10이면 mm 가정.
  - `_finalize`: center가 (0,0)인 valves만 균등 배치, start/end가 (0,0,0)인 pipes만 폴백 (이미 위치 잡힌 항목은 보존).
- **변경 (builder)**:
  - `_build_main_conduit`: `params.pipes`에 M-301/도수관이 있으면 스킵(이중 빌드 방지).
  - `_build_transmission_pipes`: valves에서 파생 → `params.pipes` 직접 순회로 교체. 각 pipe의 명시 start/end/diameter 사용. 대구경(≥0.3m)에 양 끝 플랜지.
  - 밸브 방향: 가장 가까운 pipe의 (end-start) 방향으로 `flow_dir` 계산 → 버터플라이 원통 축, 게이트 박스 가로/세로 방향 자동 결정.
- **검증 (E2E parser)**:
  - chamber: 14.0 × 9.0m (이전 27 × 9 fallback)
  - 밸브: M-303 (-3.71,-2.20) D500 / M-302 (-2.54,-2.27) D500 / M-305 (-3.71,+2.20) D500 / M-304 (-2.54,+2.27) D500 / M-306 (0,0) [side view라 폴백] — 도면의 좌측 2×2 격자 정확.
  - 관로: 천상정수장 D1,200 (3.95,+2.2)→(22.0,+2.2), 대암계통 D1,200 (3.95,-2.2)→(22.0,-2.2), 하천유지용수 D80 (3.64,0.01)→(22.0,0.01), M-301 도수관 (-15,0)→(-7,0).
  - 빌더: 84 PolyData 메시 (이전 < 20). AST/import 모두 OK.
- **남아있는 한계**:
  - M-301 도수관 **Y-split**(좌측 외부에서 1개 → chamber 진입 직전 2개 분기) 시각화 미구현. 현재는 단일 직선 진입선. 필요하면 `dxf_geometry`로 좌측 영역의 LINE 다발을 뽑아 분기점을 추정해 추후 추가.
  - M-306(하천유지용수 밸브)은 plan view에 자체 leader 없이 side view에만 표시되어 폴백 위치 (0,0). 같은 D80 pipe(MLEADER #1)의 중점에 자동 배치하도록 후속 개선 가능.

---

## 2026-04-18

### [feat] DEM 링을 TIN 컨벡스 헐에 스냅 + 뷰 버퍼(%) 사용자 입력 노출

- **파일**: `dem_extender.py`, `egview_maker.py`
- **사유**:
  1. 직전 구현은 링의 inner 경계가 **사각 bbox**여서 TIN 실제 형상(등고선 기반 불규칙 다각형)과 어긋남 → 낮은 앵글에서 **gap/찢김·T-vertex** 아티팩트 발생 가능.
  2. Step 2(위성) 버퍼가 하드코드 5%, Step 3(제어맵) 초기 카메라 거리가 하드코드 1.5x여서 **사용자가 조감도 범위를 조절할 수 없음**.
- **변경 A — Hull snap (`dem_extender.py`)**:
  - 추가: `_compute_tin_hull_projected()` (scipy.spatial.ConvexHull로 TIN XY 헐 + 각 정점의 TIN Z 반환), `_densify_polygon()` (엣지 세분화), `_generate_ring_points_hull()` (hull 바깥쪽만 격자로 채우고 세분화된 hull 경계 점을 공유 정점으로 추가).
  - `build_extended_terrain_ring`에 `use_hull_boundary=True` 파라미터(기본 True). 활성 시:
    - 링 inner 경계 = TIN 컨벡스 헐 (불규칙 다각형).
    - 헐 경계 점 Z는 DEM이 아니라 **TIN 원본 정점 Z의 선형 보간**으로 오버라이드 → seam에서 Z 완전 일치(gap·T-vertex 0).
    - 내부 삼각형 제거를 bbox contains → `matplotlib.path.Path` hull contains로 교체.
  - Hull 계산 실패 시 자동으로 bbox 경계로 폴백(ConvexHull이 collinear/<3 pts 예외).
  - `DemExtendResult.info`에 `boundary=hull(N정점)` 또는 `boundary=bbox` 명시.
- **변경 B — 뷰 버퍼(%) UI + Step 2/3 적용 (`egview_maker.py`)**:
  - 사이드바 row 24 서브 프레임(`dem_frame`)을 확장: (a) "뷰 버퍼 (%) [Step2/3]" 입력 `buffer_percent_var` (기본 5), (b) 기존 "지형 확장 (DEM)" 체크 + DEM 버퍼(m) 유지.
  - **Step 2 `btn_draping_callback`**: 하드코드 5% 삭제 → `buffer_percent_var`로 계산. DEM 확장 ON 시 위성 타일 BBOX는 `max(뷰 버퍼 %, DEM m 버퍼)` 적용(텍스처 커버리지와 DEM 링 extent 양쪽 충족).
  - **Step 3 `_open_interactive_viewer`**: 초기 카메라 거리 `diag * 1.5` → `diag * (1.3 + buf_pct/100)` (기본 1.35x, buf_pct=20%면 1.5x). 인터랙티브 뷰에서 사용자가 계속 자유 조정 가능.
- **검증**:
  - AST OK (두 파일), 의존 21 모듈 `importlib` 성공.
  - 유닛: 100-pt 합성 TIN → `_compute_tin_hull_projected` 13 정점, `_generate_ring_points_hull`가 외곽 격자에서 hull 내부 점 0개(strict), 경계 정점 42 포함 정상.
  - E2E (사연댐 부근 WGS84 → EPSG:5187, 합성 TIN 300 pts): `use_hull_boundary=True` → pts=1818/tris=3394/boundary=hull(16정점), `False` → pts=1744/tris=3234/boundary=bbox. 수직 datum 자동 보정 offset=+124.35m(198점 비교) 적용 확인. AWS terrarium 2 타일 캐시 히트.
- **트레이드오프**:
  - Hull 기반은 TIN이 컨벡스에 가까울 때 이상적. 매우 오목한(U자 협곡, 긴 강변) TIN이면 헐이 실제 커버리지보다 커서 빈 영역이 링에 포함될 수 있음 → 장기적으로 alpha shape/실제 boundary edge 추출로 업그레이드 여지.
  - 뷰 버퍼 %는 Step 3에서 초기 카메라 거리에만 영향(인터랙티브 뷰에서 여전히 휠로 재조정 가능). 고정 프레이밍이 필요하면 스냅샷 기능 추가 필요.

### [feat] DXF TIN 외곽을 실제 DEM으로 확장 (AI 환각이 아닌 사실 기반 배경 지형)

- **파일**: `dem_extender.py` (신규), `egview_maker.py`
- **배경/사유**:
  - 조감도에서 DXF 등고선 범위를 벗어나면 지형이 절벽처럼 끊기고 뒤쪽은 하늘색만 남음 → AI 프롬프트로 메워왔으나 AI 상상이라 매 실행 결과가 바뀜.
  - 사용자 요구: "내가 지형을 생성한 범위 그 이외도 실제 지형을 반영하고 싶어." 위성 영상은 버퍼로 확장하고, 그 확장 영역에 **실제 DEM Z값**을 붙여 3차원 배경 지형을 사실 기반으로 완성.
- **설계**:
  - 외곽 **도넛(ring) 메시**를 투영 CRS(예: EPSG:5187)에서 격자로 생성, 내부(DXF bbox)는 비워둠.
  - **AWS Open Terrain Tiles** (terrarium PNG, 무료·API 키 불필요, 글로벌 ~30m)를 기본 소스로 사용. URL `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png`. 디코딩 `elev = (R*256 + G + B/256) - 32768`.
  - **로컬 GeoTIFF 우선 오버라이드**: `cache/dem/local.tif`가 있고 `rasterio` 설치돼 있으면 우선 사용 (예: NGII 5m DEM 수동 배치 시 정확도 ↑).
  - **Seam 정합**:
    1. 수직 datum 자동 보정: inner 경계 근처 링 점의 DEM Z vs 최근접 TIN Z 중앙값 차이(offset)를 모든 DEM Z에서 차감 → 댐 사업지의 공사 전후 Z 차이도 자동 흡수.
    2. Feathering: inner 경계로부터 `feather_m`(기본 80m) 이내 점은 TIN 최근접 Z와 선형 블렌드해 seam 매끄럽게.
  - **위성 텍스처 정합**: DEM 확장 활성 시 Step 2의 위성 타일 버퍼를 5%가 아닌 DEM 버퍼(`dem_buffer_m`)와 동일하게 확장. TIN+외곽 메시에 *결합 bbox* 기준 UV 매핑을 적용해 위성 픽셀이 양쪽 메시에 일관 정렬.
  - **캐시**: BBOX+zoom 해시로 `cache/dem/terrarium_{hash}.png` 저장 → 재실행 시 네트워크 호출 없음.
- **UI 변경** (`egview_maker.py`):
  - 사이드바 "와이어프레임 보기"(row 23) 아래 row 24에 서브 프레임 추가: 체크박스 **"지형 확장 (DEM)"** + 버퍼 거리 입력 `dem_buffer_var` (기본 1000m, 단위 m).
  - Step 1(TIN 재생성) 시 `total_mesh / tin_extension_mesh / tin_extension_textured` 캐시 무효화.
  - Step 2 `btn_draping_callback`: `dem_extend_var` 활성 시 `build_extended_terrain_ring` 호출 → `self.tin_extension_mesh` 저장. 위성 타일 BBOX도 동일 버퍼로 확장.
  - `show_3d_preview`: 확장 메시가 있으면 플로터에 추가 렌더(텍스처 모드=위성, 비텍스처 모드=Elevation 컬러맵, 외곽은 scalar bar 숨김·show_edges=False).
- **새 의존성**: 없음 (기존 `requests / PIL / numpy / pyproj / pyvista / scipy`만 사용). `rasterio`는 로컬 GeoTIFF 옵션에서만 필요(선택).
- **검증**:
  - AST 파싱 OK (`egview_maker.py`, `dem_extender.py`).
  - 의존 모듈 21개 `importlib.import_module` 모두 성공.
  - 유닛 스모크: `_generate_ring_points`(내부 제외 정상), `_sample_grid_bilinear`(3x3 예상값 일치), `_terrarium_decode`(128,0,0→0.0 / 200,0,0→18432.0) 정상.
  - E2E 네트워크 테스트: 사연댐 부근 WGS84(129.10, 35.54) 중심 1×1km, 버퍼 500m로 실행 → AWS terrarium 2×2 타일 다운로드 성공(118KB), 링 메시 생성(pts=2364, tris=4000), 캐시 파일 저장 확인.
- **트레이드오프/주의**:
  - 기본 소스(AWS terrarium/SRTM 기반)는 수평 ~30m·수직 ~10m 정확도 → 먼 배경 문맥용으로는 충분하나 DXF 5m 등고선만큼 상세하지 않음. 더 정확한 한국 5m/1m DEM이 필요하면 NGII에서 받아 `cache/dem/local.tif`로 배치(rasterio 설치 필요).
  - 첫 실행만 네트워크 사용(수 MB), 이후 캐시 재사용으로 런타임 부하 최소.

### [chore] egview_maker.py 실행에 불필요한 파일/폴더를 `_unused/`로 분리

- **대상**: 프로젝트 루트 정리
- **사유**: egview_maker.py 진입점 및 전이 의존성과 무관한 파일이 루트에 누적되어 탐색/유지보수 난이도가 올라감. 사용자 요청(“조감도까지 뽑는데 필요 없는 파일들 식별해서 별도 폴더로 이동”).
- **방법**: egview_maker.py의 직접/전이 import(구조물 파서·빌더, harness, geo_referencing 등)와 런타임 로드 자산(`gcp-key.json`, `structure_types/`, `prompt_templates/`)만 루트에 유지. 나머지는 `_unused/`로 이동.
- **이동 대상**:
  - 독립 스크립트: `nano_banana2.py`, `structure_ui.py`, `test_gate_render.py`
  - 백업 폴더: `REF_BY_SEOK/`(harness/ 중복본)
  - 런타임 생성물(이미지/로그/DB): `capture_for_ai.png`, `capture_textured.png`, `depth_map.png`, `lineart_map.png`, `guide_composite.png`, `satellite_temp.png`, `rendered_birdseye.png`, `egview_diagnostic.log`, `egview_harness.log`, `egview_jobs.db`, `Build_log.txt`
  - 보조 파일/폴더: `ai_studio_prompt.txt`, `install.cmd`, `scratch/`, `SCREENSHOT_lOG/`, `지형도 베이스맵/`
- **잔존(루트 유지)**: `egview_maker.py`, `detail_parser.py`, `dxf_geometry.py`, `filename_classifier.py`, `geo_referencing.py`, `structure_placement.py`, `structure_templates.py`, `view_detector.py`, `view_reconstructor.py`, `spillway_{parser,3d_builder}.py`, `intake_tower_{parser,3d_builder}.py`, `valve_chamber_{parser,3d_builder}.py`, `retaining_wall_{parser,3d_builder}.py`, `harness/`, `structure_types/`, `prompt_templates/`, `gcp-key.json`, `CHANGELOG.md`, `__pycache__/`
- **미이동 (핸들 점유)**: `SAMPLE_CAD/` — 다른 프로세스(파일 탐색기/CAD 뷰어 등)가 내부 파일 핸들을 잡고 있어 이동 실패. 해당 프로그램 종료 후 수동으로 `_unused/`로 옮기면 됨.
- **검증**: `egview_maker.py` AST 파싱 OK, 직접/전이 의존 모듈 20개 `importlib.import_module` 성공(`OK`) 확인.

---

## 2026-04-17

### [fix] AI 렌더링(Step 4)이 "GCP Project ID 필요" 오류로 실행 불가

- **파일**: `egview_maker.py`
- **원인**: `gcp-key.json` 서비스 계정 키가 프로젝트 루트에 있는데도 코드에서 이를 사용하지 않고 `gcloud auth application-default login` + 수동 GCP Project ID 입력을 기대. 또한 Gemini 3.x 이미지 모델은 `location="global"` 전용인데 기본값이 `us-central1`, 모델 목록도 2.x 계열만 있어서 최신 Nano Banana 2 미사용.
- **변경**:
  - `EGViewApp.__init__`에서 `gcp-key.json` 자동 로드: `GOOGLE_APPLICATION_CREDENTIALS` 환경변수 설정 + `project_id` 추출해 `self._gcp_key_project_id`에 저장.
  - GCP Project ID 기본값을 `self._gcp_key_project_id` → env `GCP_PROJECT_ID` 순으로 폴백.
  - `vertex_location` 기본값을 `us-central1` → `global`로 변경.
  - `btn_ai_render_callback`에서 key가 비어도 `_gcp_key_project_id`로 자동 채움.
  - `_run_gemini_render`에서 Vertex 모델 폴백 체인을 `[gemini-3.1-flash-image-preview@global, gemini-3-pro-image-preview@global, gemini-2.5-flash-image@us-central1, ...]` 순으로 구성. 모델별 location이 다르면 client 재생성.
  - 인증 실패 메시지를 서비스 계정 기반으로 업데이트 (`aiplatform.user` 권한 확인 안내).
  - `response_modalities=["IMAGE"]`만 사용 (사용자 지정).
- **검증**: `gcp-key.json`(project_id=`gen-lang-client-0637559466`)으로 실제 Vertex AI 호출 성공 — `gemini-3.1-flash-image-preview` → 1024×1024, `gemini-3-pro-image-preview` → 1408×768 이미지 생성 확인.

### [fix] 수문(spillway) 3D 메쉬의 앞뒤 방향이 180° 뒤집히는 버그

- **파일**: `structure_placement.py`, `spillway_parser.py`, `egview_maker.py`
- **원인**: `fit_meshes_to_quad`가 절대 각도(`atan2(tin_edge01)`)로 회전하고, 사용자 picks는 시계방향(CW)인데 회전 행렬은 수학적 CCW 컨벤션. 결과적으로 mesh `+Y`(builder 하류)가 quad의 `edge 3→0`(사용자 상류) 방향으로 매핑되어 180° 뒤집힘.
- **변경**:
  - `fit_meshes_to_quad`에 `detail_quad_pts`, `plan_frame_angle_deg`, `flip_y_for_cw_quad=True` 파라미터 추가.
  - 회전 계산식: `q_angle = tin_angle - detail_angle - plan_frame_angle_rad` (상대 회전).
  - 회전 전 mesh Y 반전으로 CW picks 컨벤션 보정.
  - `SpillwayParams.plan_frame_angle_deg` 필드 추가, `_parse_plan_file`에서 plan 영역 점들의 PCA 주축각 자동 계산 (`-90..+90` 정규화).
  - `egview_maker._add_template_structures_to_plotter`에서 `tr.ref_plan`과 `params.plan_frame_angle_deg` 전달.
- **검증**: 시뮬레이션 — BEFORE: mesh upstream → TIN 하단(flipped); AFTER: mesh upstream → TIN 상단(user 의도 일치). 기울어진 detail(30°) + TIN(90°)에서도 mesh upstream이 TIN top에 정확히 안착.

### [fix] 굴착 TIN이 부자연스럽고 구조물 바닥이 TIN을 관통하는 문제

- **파일**: `egview_maker.py`, `structure_placement.py`
- **원인(1)**: `_excavate_tin_for_structures`가 AABB + 5% padding으로 축정렬 사각형을 하드 drop(`-= exc_depth`) → 수직 절벽, 경사 원지반이 통째로 내려가 굴착 바닥이 여전히 경사짐. **원인(2)**: 경사진 floor + 평면 구조물 바닥 → 낮은 코너에서 구조물이 TIN 관통(error6/7.png).
- **변경**:
  - `_excavate_tin_for_structures` 전면 재작성.
    - 폴리곤: `placement_transform.ref_tin` 4점 quad 우선 (AABB 폴백) — 회전/실제 footprint 반영.
    - 평탄 pad: `pad_z = median(원Z 내부) - exc_depth`로 폴리곤 내부 통일 → 구조물 바닥(평면) ↔ TIN(평면) 정합.
    - smoothstep 전이: `transition_w = max(exc_depth*1.5, 2.0)`m 구간에서 `t²(3-2t)` blend → 수직 절벽 제거.
    - Delaunay 재계산: pad+전이 영역에 격자 densification(간격 `poly_size/18`) 후 전체 Delaunay 재구성 → 조밀한 삼각망, 매끈한 사면.
  - `_signed_distance_to_polygon` 헬퍼 함수 추가 (egview_maker.py 상단).
  - `_get_structure_footprint_polygon` 헬퍼 메서드 추가 (ref_tin / AABB 분기, 레거시 world→local 변환).
  - `fit_meshes_to_quad`/`apply_placement`에 `pad_surface_z`, `embed_offset=0.02` 파라미터 추가. 굴착 구조물은 pad Z 직접 사용, `dz = z_surface - aggregate_z_min - embed_offset`로 구조물 바닥을 TIN 표면보다 2cm **아래**에 위치 → 아래→위 시점에서 TIN이 underside 가림.
  - `info["_excavation_pad_z"]`에 pad Z 저장 후 placement 단계로 전달.
- **검증**: 합성 지형(50x50, ±0.35m 경사)에서 pad Z std=0 (완전 평탄), 경계 smoothstep 정상, Delaunay 재구성 성공.

### [fix] Geo-Referencing 위치 설정 창에 계획평면도(TIN DXF)가 표시되지 않는 문제

- **파일**: `geo_referencing.py`
- **원인**: `extract_tin_shapes` → `extract_structural_geometry` → `detect_unit_scale` 자동감지가 KATEC 절대좌표(X=217000대)를 **mm로 오판**해 ×0.001 스케일. 반면 `create_tin_from_dxf`는 raw 좌표를 그대로(m로) 사용 → 두 좌표계가 1000배 차이. `shift_origin=self.origin` 차감 후 shape가 `(-217308, -330576)` 근처로 튕겨 axis 밖에 그려짐 = 빈 캔버스.
- **변경**:
  - `extract_tin_shapes`에서 `extract_structural_geometry(..., unit_override="m")` 고정.
  - 자동감지 폴백 버그(diag 500~2000 구간에서 분기 미스로 기본값 "mm" 리턴)는 `detect_unit_scale`에 남아있으나 TIN 경로는 우회.
- **검증**: 수정 후 shape 99.6%(3353/3365)가 TIN 로컬 바운드 내 정렬, 나머지 12개는 DXF 내 원격 엔티티(axis override로 화면에 영향 없음).

---

## 2026-04-17 이전 (히스토리 미기록 구간)

초기 개발 이력은 본 CHANGELOG 도입 이전이라 직접 기록이 없습니다. 주요 컴포넌트와 당시 구조는 다음과 같습니다 (`Build_log.txt`의 사용자 요청 흐름과 현재 코드베이스 스냅샷으로부터 재구성):

- **1차(초기)**: TIN 생성(`create_tin_from_dxf`) + 위성 타일 draping(`btn_draping_callback`) 기반의 기본 지형 뷰어.
- **2차**: 구조물 상세 DXF 업로드 + 템플릿 기반 3D 빌더(`spillway_3d_builder`, `intake_tower_3d_builder`, `valve_chamber_3d_builder`, `retaining_wall_3d_builder`) 도입.
- **3차**: 미리보기→위치설정(Geo-Referencing 4점 매칭)→확정 3단계 구조물 검토 프로세스(`_open_structure_review_dialog`). Umeyama 2D similarity로 `PlacementTransform` 산출.
- **4차**: 구조물 TIN 반영 파이프라인(`_excavate_tin_for_structures` 굴착 + `_add_template_structures_to_plotter` 최종 배치).

이후 수정부터는 본 CHANGELOG에 모두 기록됩니다.
