Files
s-canvas/CHANGELOG.md
HYUNJUNGLEE f62214934d
Some checks failed
CI / Ruff + Test (Py3.11 + Py3.13) (3.11) (push) Failing after 10s
CI / Ruff + Test (Py3.11 + Py3.13) (3.13) (push) Failing after 10s
Phase 1 (#11): perf instrumentation — harness/perf.py + 3 hotspot wraps
신규 모듈 — harness/perf.py (54 LOC):
- perf_block(label) 컨텍스트 매니저 — 블록 단위 wall-clock + CPU 시간을 ms 단위로 측정.
- set_perf_log(sink) — 외부 sink 등록 (예: app.log). 등록 후 [PERF] 라인이 logger
  외에도 그 sink 에 라우팅됨.
- 출력 형식: [PERF] {label}: wall={NN}ms cpu={NN}ms ({CPU|I/O/Net}-bound).
- cpu/wall > 0.5 면 CPU-bound 로 분류, 그 외 I/O/Net-bound (GIL 풀린 시간 비율).

Setup — scanvas_maker.py 2곳:
- import 블록 (~line 58): from harness.perf import perf_block, set_perf_log;
  ImportError 시 contextlib.contextmanager 노옵 폴백 (모듈 누락 환경 대응).
- SCanvasApp.__init__ (~line 613): set_perf_log(self.log) 등록.

Hotspot wraps — scanvas_maker.py 3곳 (PERFORMANCE_BASELINE.md 매핑):
- TIN densify Phase C (line ~4430) → H3: with perf_block("TIN densify Phase C (10m→1m)").
- 위성 타일 다운로드 (line ~5384) → H1: with perf_block("위성 타일 다운로드+병합").
- 제어맵 캡처 x3 + composite (line ~5864) → H12: with perf_block("control map capture x3 + composite").

검증:
- python -m py_compile scanvas_maker.py harness/perf.py 통과.
- AST parse OK (39 top-level statements).
- ruff Green 정식 검증은 글로벌 ruff 설치 후 (uv pip install -e ".[dev]"; ruff check).

CHANGELOG.md 에 #11 perf instrumentation 항목 추가 (2026-05-08).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:44:03 +09:00

1968 lines
181 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-05-08
### [merge] Gitea s-canvas 원격(raw upload, 184185c)과 로컬 lint+Phase 0 history 통합
- **상황**: 원격 `https://gitea.hmac.kr/HYUNJUNGLEE/s-canvas.git` 에 사용자가 site에서 raw upload 한 1회 commit (`184185c`)이 존재. 로컬은 `53d8b53``b9342f6` (import + iter1~7 lint cleanup) → `e9cc6bf` (Phase 0 of expert feedback) history. **공통 조상 없음 (unrelated histories)**.
- **분석**: 원격에만 있는 파일 0개 (원격은 로컬의 부분집합). 양쪽 다 있고 내용 다른 파일 29개 (raw upload vs lint-applied 버전). 로컬에만 있는 파일 80+ (Phase 0 산출물 + workspace/`_unused/` 등).
- **전략**: `git merge --allow-unrelated-histories -X ours`. 충돌 시 로컬 우선으로 lint cleanup 보존.
- **결과**: 머지 commit `8c6d7f0`. 자동 머지된 31파일 중 README.md 만 실질 변경 (로컬 0줄 → 원격 404줄 README 흡수 — 빈 파일 vs 내용있음은 `-X ours` 적용 외 단순 합병). 나머지 source/config 28개는 로컬 lint 버전 유지.
- **푸시**: 머지 후 fast-forward push 가능. 원격 history 손실 없이 통합.
### [feat] #11 perf instrumentation — `harness/perf.py` 신규 + `scanvas_maker.py` 5곳 wire
- **사용자 피드백 #11**: "로딩이 오래 걸리는 부분(위성지도 결합·구조물 빌드 시 등)은 CPU 이용률이 대폭 증가하는 프로세스를 ms 단위로 추적해서 원인을 규명하고 최적화하는 조치 필요".
#### 신규 모듈 — `harness/perf.py` (54 LOC)
- `perf_block(label)` — 컨텍스트 매니저. `with perf_block("XYZ tiles"): ...` 형태로 블록 실행 시간(wall + CPU)을 ms 단위로 측정.
- `set_perf_log(callable)` — 외부 sink 등록 (예: `set_perf_log(app.log)` 시 GUI 로그 패널에도 표시).
- 출력 형식: `[PERF] {label}: wall={NNN}ms cpu={NNN}ms ({CPU|I/O/Net}-bound)`. `cpu/wall > 0.5` 면 CPU-bound로 분류.
#### Wire 5곳 — `scanvas_maker.py` (commit `c94b4a7`)
1. **import 블록 (~line 58)**: `from harness.perf import perf_block, set_perf_log` + ImportError 시 `@contextlib.contextmanager` 노옵 폴백 → `harness/` 모듈 누락 환경에서도 안전.
2. **`SCanvasApp.__init__` (~line 613)**: `set_perf_log(self.log)` 등록 — perf 측정 라인이 GUI 텍스트박스에도 표시됨.
3. **TIN densify Phase C (line ~4430)**: `with perf_block("TIN densify Phase C (10m→1m)")` 로 10단계 점진 격자 루프 감쌈 (PERFORMANCE_BASELINE.md H3).
4. **위성 타일 다운로드 (line ~5384)**: `with perf_block("위성 타일 다운로드+병합")``_download_xyz_tiles()` 감쌈 — 사용자 피드백 #11이 명시한 "위성지도 결합" 핫스팟 (H1).
5. **제어맵 캡처 파이프라인 (line ~5864)**: `with perf_block("control map capture x3 + composite")` 로 textured + depth + lineart 3-stage 캡처 + composite 감쌈 (H12).
#### 출력 예 (실제 측정 시)
```
[PERF] 위성 타일 다운로드+병합: wall=12340.5ms cpu=860.3ms (I/O/Net-bound)
[PERF] TIN densify Phase C (10m→1m): wall=2150.7ms cpu=2080.4ms (CPU-bound)
[PERF] control map capture x3 + composite: wall=4520.1ms cpu=3760.8ms (CPU-bound)
```
#### 검증
- `python -m py_compile scanvas_maker.py harness/perf.py` 통과. AST parse OK (39 top-level statements).
- ruff Green 정식 검증은 다음 세션 (글로벌 ruff 미설치, `uv pip install -e ".[dev]"``ruff check`).
#### 다음 라운드 (#11 추가)
- 사용자 실제 도면으로 [PERF] 출력 1회 측정 → PERFORMANCE_BASELINE.md "측정 후 비교 표" 채움.
- 측정 결과 기반 추가 hotspot wrap (H7·H9·H13·H18 등) + 최적화 (numpy 벡터화 / 스레드 풀 / GIL 해제).
---
## 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` 컷이 TINDEM 접합부 삼각형을 제거
- **부가 원인 — 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) + (1blend) × 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.55) / 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에 모두 기록됩니다.