# S-CANVAS 성능 베이스라인 (Phase 0 — 진단)

본 문서는 read-only 정적 분석 결과로, 실제 측정 없이 코드 패턴/복잡도/I/O 경계를
근거로 추정한 핫스팟 후보 목록이다. 실측 단계(Phase 1)에서 본 문서의
"측정 instrumentation 패치"를 코드에 일시 삽입해 사용자가 실 도면으로 측정한 뒤
3.측정 후 비교 표를 채우면 된다.

기준 출처:
- `.claude/agents/performance-guardian.md` (함정 1~7 — 메인스레드 블로킹 / 폴리곤 폭발 /
  매 프레임 재생성 / I/O 직렬화 / GI 과다 / 텍스처 메모리 폭발 / 구조물 누적)
- 사용자 피드백 #11: "위성지도 결합·구조물 빌드 시 CPU 대폭 증가 → ms 단위 추적·최적화"

---

## 1. 추정 핫스팟

| # | 경로/시나리오 | 파일:라인 | 의심 카테고리 | 근거 |
|---|---|---|---|---|
| H1 | XYZ 위성 타일 직렬 다운로드 | `tile_downloader.py:98-124` | **Network-bound**, 메인스레드 블로킹 (함정 1+4) | 이중 for 루프로 `requests.get` 직렬 호출. 줌17 + 1km×1km bbox = 약 16~50타일, 타일당 100~600ms, 총 5~30초. `btn_draping_callback`이 메인스레드에서 호출 → GUI 동결. |
| H2 | DEM (terrarium) 타일 직렬 다운로드 | `dem_extender.py:138-150` | **Network-bound** (함정 4) | 동일 직렬 루프. `fetch_terrarium_grid`는 z=13, buffer=1000m면 보통 4~16타일이지만 캐시 미스 시 GUI 동결. |
| H3 | TIN densify Phase C (10→1m 점진 격자) | `scanvas_maker.py:4405-4455` | **CPU-bound** (numpy + scipy), 메인스레드 (함정 1+2) | `for _step in (10..1)` 안에서 `ConvexHull` 재계산 + meshgrid + `MplPath.contains_points` + cKDTree 쿼리 + 매 단계 DEM bilinear 샘플. 큰 도면(2km×2km)에서 10단계 × 수만점 = 수 초. |
| H4 | TIN densify Phase B (긴 edge 중심 추가) | `scanvas_maker.py:4458-4477` | **CPU-bound** (Delaunay), 메인스레드 | 임시 Delaunay 1회 추가. 정점 ~10만 시 0.5~2초. |
| H5 | TIN bbox gap 채움 (Step 1.5-a) | `scanvas_maker.py:5089-5263` | **CPU-bound** + 잠재 Network (함정 1+4) | Phase C와 동일 알고리즘 재실행. 캐시된 `_dem_elev_grid`가 있으면 CPU만, 없으면 추가 fetch. v6 벽 컷 numpy 벡터화는 빠름. |
| H6 | 최종 Delaunay (TIN 생성 후) | `scanvas_maker.py:4502, 5216, 3343` | **CPU-bound**, 메인스레드 | scipy `Delaunay`는 O(n log n)이지만 numpy 출신이 아닌 native Qhull → GIL 안 풀림. 수십만 정점 시 1~3초. |
| H7 | DEM 링 메시 빌드 — outer smooth blend / Laplacian | `dem_extender.py:600-707` | **CPU-bound** (cKDTree + Python loop) | 라인 625, 696의 `for k, nb in enumerate(nbrs)` Python 루프. 격자점 1000개 + 이웃 평균 = 0.5~2초. numpy 벡터화 가능. |
| H8 | Step 1.5 경계 재보간 cKDTree | `scanvas_maker.py:5249-5329` | CPU, 가벼움 | 단일 cKDTree + np.where. 빠름(<200ms). 무시 가능. |
| H9 | `_excavate_tin_for_structures` Python 루프 | `scanvas_maker.py:2983-2993, 3025-3031` | **CPU-bound**, 메인스레드 (함정 1) | `for i in band_idx` / `for i, d in enumerate(grid_d)` 순수 Python. 구조물 5개 + 각 격자 1000점 = 0.5~2초. numpy 벡터화 즉시 가능. |
| H10 | `_composite_material_textures` PIL 픽셀 합성 | `scanvas_maker.py:3923-3996` | I/O + CPU, 메인스레드 | PIL `Draw.polygon` × 도로 수, 노이즈 텍스처 추가. 2048×2048에서 100~500ms. |
| H11 | 위성 텍스처 final resize LANCZOS 2048 | `tile_downloader.py:147` | CPU, 메인스레드 | 큰 합성 이미지를 한 번 리사이즈. 200~600ms. 무시 가능 그러나 일부 PC에서 GIL 동안 다른 일 못 함. |
| H12 | 캡처 단계: PyVista off_screen plotter 3회 생성 | `scanvas_maker.py:5849-5867` | **GPU+CPU**, 메인스레드 (함정 1+3) | `_capture_from_camera` / `_capture_depth_from_camera` / `_capture_lineart_from_camera` 각각 새 `pv.Plotter(off_screen=True)` 생성, 메시 add, 그리고 screenshot. 한 번에 1.5~5초. **세 번 직렬** = 4.5~15초 GUI 동결. |
| H13 | show_3d_preview 의 merge + extract_surface + compute_normals | `scanvas_maker.py:5485-5500` | CPU, 한 번 발생 (한정적) | `feature_angle=180.0` 전체 메시 노멀 재계산은 큰 메시(~50만 cells)에서 1~3초. 매번 호출되지만 1회/프리뷰 오픈이라 함정 3 해당 안 함. |
| H14 | `_add_template_structures_to_plotter` 로깅 + bounds 진단 | `scanvas_maker.py:5640-5760+` | CPU, 메인스레드 | 매 구조물마다 `np.concatenate([m.points])` 두 번(raw, placed). 50개 구조물 × 메시 20개 → 1000회 concat. 100~400ms. |
| H15 | `download_xyz_tiles` 최종 PIL `merged.crop().resize()` | `tile_downloader.py:146-147` | CPU, 메인스레드 | bbox 크롭 후 LANCZOS 2048 리사이즈. 가벼움. |
| H16 | `pv.read_texture("satellite_temp.png")` | `scanvas_maker.py:5393, 5894, 6281` | I/O, 메인스레드 | 매 capture/preview마다 디스크 재읽기. 200~500ms × 4회 = 1~2초 낭비. **재사용/캐싱 가능**. |
| H17 | `enable_eye_dome_lighting()` 매 plotter | `scanvas_maker.py:5563, 5914, 6339, 6359` | GPU, 60FPS 영향 (함정 5) | EDL은 비용이 보통이지만 SSAO와 누적되면 30FPS로 떨어짐. 큰 메시에선 주의. |
| H18 | `_build_plan_overlay_meshes` 모든 계획선 매번 재생성 | `scanvas_maker.py:3599-3700+` | CPU, show_3d_preview 호출 시마다 (함정 3) | 메시는 변하지 않는데 매 프리뷰 오픈마다 재빌드. 캐싱 가능. |
| H19 | TIN 생성 시 ezdxf entity 순회 | `scanvas_maker.py:4187-4226` | I/O+CPU | 6개 entity 타입 × 모든 modelspace 엔티티. 큰 DXF(수만 등고선)에서 2~10초. |

---

## 2. 측정 instrumentation 패치

각 핫스팟에 삽입할 컨텍스트 매니저. **본 라운드에서는 삽입하지 않음**(read-only).
사용자가 Phase 1 측정 시 임시로 삽입 → 측정 후 즉시 제거.

### 2.1 공통 컨텍스트 매니저 (`scanvas_maker.py` 상단에 추가)

```python
import time
from contextlib import contextmanager

@contextmanager
def _perf(label, log_fn=print):
    """일회성 측정. 시작/끝 ms 출력. CPU vs wall-time 둘 다."""
    t_wall = time.perf_counter()
    t_cpu = time.process_time()
    try:
        yield
    finally:
        dt_wall = (time.perf_counter() - t_wall) * 1000
        dt_cpu = (time.process_time() - t_cpu) * 1000
        log_fn(f"  [PERF] {label}: wall={dt_wall:.1f}ms cpu={dt_cpu:.1f}ms "
               f"({'CPU' if dt_cpu/max(dt_wall,1e-3) > 0.5 else 'I/O/Net'}-bound)")
```

판별: `cpu/wall > 0.5` → CPU-bound, 그 외 → I/O/Network-bound (GIL 풀린 시간).

### 2.2 H1: XYZ 타일 다운로드 (`tile_downloader.py:98`)

```python
# 기존 for 루프 직전
with _perf(f"XYZ tiles {cols}x{rows}={cols*rows}", log_fn):
    for ty in range(y_min, y_max + 1):
        ...  # 기존 루프
```

### 2.3 H2: terrarium fetch (`dem_extender.py:138`)

```python
with _perf(f"terrarium fetch {cols}x{rows} z{zoom}", log_fn):
    for ty in range(y_min, y_max + 1):
        ...
```

### 2.4 H3: Phase C 점진 densify (`scanvas_maker.py:4414`)

```python
with _perf(f"Phase C densify (10->1m, n_pts={len(pts)})", self.log):
    for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
        with _perf(f"  step {_step}m", self.log):
            try:
                hull_c = _ConvexHull(pts[:, :2])
            except Exception:
                break
            ...  # 기존 루프 본문
```

### 2.5 H4: Phase B Delaunay (`scanvas_maker.py:4458`)

```python
with _perf(f"Phase B Delaunay (n={len(pts)})", self.log):
    tri_tmp = Delaunay(pts[:, :2])
```

### 2.6 H5: Step 1.5-a 채움 (`scanvas_maker.py:5172`)

```python
with _perf("Step 1.5-a fill (point progressive 10->1m)", self.log):
    current_abs = pts_abs.copy()
    ...  # 기존 점진 densify 루프
```

### 2.7 H6: 최종 Delaunay (`scanvas_maker.py:4502`)

```python
with _perf(f"final Delaunay (n_pts={len(pts)})", self.log):
    tri = Delaunay(pts[:, :2])
```

### 2.8 H7: DEM 링 build (`dem_extender.py` 함수 진입부)

```python
def build_extended_terrain_ring(...):
    with _perf(f"build_extended_terrain_ring (buffer={buffer_m}m, "
               f"step={grid_step_m or 'auto'})", log_fn):
        ...  # 함수 본문 전체
```

내부 단계 분리:

```python
with _perf("  Phase 1: ring point gen", log_fn): ...
with _perf("  Phase 2: WGS84 transform + DEM sample", log_fn): ...
with _perf("  Phase 3: outlier + spike filter", log_fn): ...
with _perf("  Phase 4: feathering + Laplacian", log_fn): ...
with _perf("  Phase 5: Delaunay + cut", log_fn): ...
```

### 2.9 H9: `_excavate_tin_for_structures` (`scanvas_maker.py:2939` 루프 내)

```python
for info in self.structure_registry.values():
    with _perf(f"  excavate {info['name']}", self.log):
        ...
```

### 2.10 H10: `_composite_material_textures` (`scanvas_maker.py:3886`)

```python
def _composite_material_textures(self, satellite_img, ...):
    with _perf(f"composite_materials img={satellite_img.size}", self.log):
        ...
```

### 2.11 H12: 캡처 3종 (`scanvas_maker.py:5849`)

```python
with _perf(f"capture_textured {out_w}x{out_h}", self.log):
    self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
with _perf(f"capture_depth", self.log):
    self.depth_map = self._capture_depth_from_camera(out_w, out_h)
with _perf(f"capture_lineart", self.log):
    self.lineart_map = self._capture_lineart_from_camera(out_w, out_h)
```

### 2.12 H13: show_3d_preview merge (`scanvas_maker.py:5485`)

```python
with _perf(f"unified merge+normals (TIN {target_mesh.n_points} + "
           f"DEM {ext_mesh.n_points})", self.log):
    merged = target_mesh.merge(ext_mesh, merge_points=True, tolerance=0.01)
    merged = merged.extract_surface() if not isinstance(merged, pv.PolyData) else merged
    ...
    merged.compute_normals(feature_angle=180.0, ...)
```

### 2.13 H19: ezdxf entity 순회 (`scanvas_maker.py:4187`)

```python
with _perf(f"DXF entity ingest", self.log):
    for entity in msp.query('LWPOLYLINE'):
        ...
    # 모든 6개 타입 루프 동일 컨텍스트 안
```

### 2.14 60FPS 게이트 (PyVista 인터랙티브 뷰어용)

`_open_interactive_viewer` 안 (`scanvas_maker.py:5884` 이후, `p.show()` 직전):

```python
# performance-guardian.md의 FPSLogger 그대로
class _FPSLogger:
    def __init__(self, plotter):
        self.last_t = time.perf_counter()
        self.frames = 0
        plotter.iren.add_observer("RenderEvent", self.on_render)
    def on_render(self, *_):
        self.frames += 1
        now = time.perf_counter()
        if now - self.last_t > 1.0:
            fps = self.frames / (now - self.last_t)
            self.log(f"  [FPS] {fps:.1f}")
            self.frames = 0
            self.last_t = now

self._fps_logger = _FPSLogger(p)  # 가비지 컬렉션 방지
```

---

## 3. 측정 후 비교 표 템플릿

사용자가 실 도면(예: 사연댐 1km×1km, ~10만 측점)으로 Phase 1 측정 후 채움.

### 3.1 시나리오 A — Step 1 (TIN 생성)

| 핫스팟 | 측정 wall (ms) | 측정 cpu (ms) | 카테고리 | 60FPS 영향 | 비고 |
|---|---|---|---|---|---|
| H19 ezdxf 순회 | | | | | |
| H3 Phase C densify | | | | | |
| H4 Phase B Delaunay | | | | | |
| H6 최종 Delaunay | | | | | |
| 합계 (Step 1 전체) | | | | | 목표 < 5초 |

### 3.2 시나리오 B — Step 1.5 (DEM 확장)

| 핫스팟 | wall | cpu | 카테고리 | 비고 |
|---|---|---|---|---|
| H2 terrarium fetch (캐시 미스) | | | Network | |
| H2 terrarium fetch (캐시 히트) | | | I/O | |
| H5 Step 1.5-a fill | | | CPU | |
| H7 build_extended_terrain_ring | | | CPU | |
| H8 경계 재보간 | | | CPU | |
| 합계 | | | | 목표 < 8초 |

### 3.3 시나리오 C — Step 2 (위성지도 결합)

| 핫스팟 | wall | cpu | 카테고리 | 비고 |
|---|---|---|---|---|
| H1 XYZ 타일 fetch | | | Network | 직렬 16~50타일 |
| H10 material composite | | | CPU | |
| H11 LANCZOS resize | | | CPU | |
| H16 read_texture | | | I/O | |
| H13 unified merge | | | CPU | |
| 합계 | | | | 목표 < 10초 |

### 3.4 시나리오 D — Step 3 (캡처 4종)

| 핫스팟 | wall | cpu | 카테고리 | 비고 |
|---|---|---|---|---|
| H12 capture_textured | | | GPU+CPU | |
| H12 capture_depth | | | GPU+CPU | |
| H12 capture_lineart | | | GPU+CPU | |
| H10/_compose_guide_image | | | CPU | |
| 합계 | | | | 목표 < 6초 |

### 3.5 시나리오 E — 인터랙티브 뷰어

| 측정 | 값 | 목표 | 비고 |
|---|---|---|---|
| 평균 FPS (회전 중) | | ≥ 60 | EDL ON |
| TIN n_cells | | ≤ 100K | 단일 메시 |
| DEM ring n_cells | | ≤ 100K | 단일 메시 |
| 통합 메시 n_cells | | ≤ 500K | 함정 7 한도 |
| 구조물 누적 n_cells | | ≤ 200K 추가 | 합계 ≤ 500K |

---

## 4. 최적화 후보

### H1 (XYZ 타일 직렬 fetch) — **가장 큰 이득**
- **`ThreadPoolExecutor(max_workers=8)`로 병렬화** (함정 4 정석 해법). 타일 IP 분산은 이미 `_SUBDOMAINS` 사용 중.
- 직렬 30초 → 병렬 4~6초 예상.
- **메인스레드 분리**: `btn_draping_callback`을 `threading.Thread(daemon=True).start()` 패턴으로 감싸 GUI 블록 제거 (함정 1).
- Disk 캐시 추가 (BBOX 해시 키, terrarium_grid처럼).

### H2 (terrarium 직렬 fetch)
- 동일하게 `ThreadPoolExecutor`. 캐시 히트면 무관.
- 캐시 디스크 접근만 비동기로 풀어도 200ms 절감.

### H3 (Phase C 10→1m 점진 densify)
- 현재 10단계 모두 실행 → **early-exit**: hull이 bbox의 99% 이상 덮으면 break.
- meshgrid 결과 cKDTree 거리 검사 → numpy `np.in1d` + bbox 마스크로 더 빠르게.
- 매 step마다 ConvexHull 재계산 (Python+Qhull) 회피: 첫 hull로 한 번만 계산해도 충분(점이 추가될수록 hull은 단조 확장).
- **lazy 로드**: Phase C는 사용자가 "TIN 이용 범위" 선택했으면 skip (이미 로직 있음 — 5025줄).

### H4 (Phase B centroid 추가)
- numpy 벡터화 완료, 추가 최적화 거의 불필요. 큰 도면에서 임계값(50m → 100m)으로 절반 줄일 수 있음 (시각 차이 미미).

### H5 (Step 1.5-a)
- H3와 동일 패턴 → 동일 처방. 추가로 cached `_dem_elev_grid` 재사용은 이미 됨 (네트워크 절감).

### H6 (최종 Delaunay)
- scipy Delaunay는 GIL 풀려서 BackgroundThread + `app.after(0, callback)` 가능 (함정 1).
- 큰 도면용 옵션: `qhull_options="Qbb Qc Qz"` 더 빠름.

### H7 (DEM ring outer smooth blend / Laplacian)
- `for k, nb in enumerate(nbrs)` Python 루프 → numpy 벡터화. 평균은 padded array 또는 scipy `ndimage.generic_filter` 가능.
- 0.5~2초 → 50~200ms 예상.

### H9 (`_excavate_tin_for_structures` 루프)
- `for i in band_idx` / `for i, d in enumerate(grid_d)` → 순수 numpy:
  ```python
  d = signed_d[band_idx]
  t = np.clip(d / transition_w, 0, 1)
  blend = t*t*(3 - 2*t)
  inside = d <= 0
  work_pts[band_idx, 2] = np.where(inside, pad_z,
                                   pad_z * (1-blend) + orig_pts[band_idx, 2] * blend)
  ```
- 100~500ms → <50ms. **즉시 가능**.

### H10 (material composite)
- PIL 그대로 두되 **메인스레드 분리** (background thread + after(0)).
- 노이즈 레이어는 큰 PNG 캐싱 (한 번만 생성).

### H12 (캡처 3종 직렬)
- 같은 카메라/뷰포인트라 plotter **재사용** 가능: 한 번 생성 → screenshot 3번 (color/depth/lineart) → close.
  - 단 lineart는 `show_edges=True` 다른 mesh, depth는 `add_mesh(color="white")` 다른 마테리얼. 이거 토글이 PyVista에서 가능한지 확인 필요(아마 `actor.GetProperty().SetEdgeVisibility(...)`로 됨).
- 4.5~15초 → 1.5~5초 예상.
- **메인스레드 분리** + 진행률 표시 (함정 1).

### H13 (unified merge + compute_normals)
- 캐싱: `unified_mesh`를 self에 저장, `tin_mesh`/`tin_extension_mesh` 변경 시에만 무효화. 매 `show_3d_preview` 호출마다 1~3초 재계산 → 한 번 후 재사용.

### H16 (`pv.read_texture` 4회 디스크 재읽기)
- `self._cached_texture = None`. draping 시 한 번 읽고 caller에서 재사용. capture/preview에서는 `self._cached_texture`를 직접 사용.

### H17 (EDL 누적)
- 60FPS 검사 후 떨어지면 EDL OFF 옵션 제공. 사용자가 "고품질 / 성능" 토글.

### H18 (overlay 매번 재생성)
- `self._cached_overlay_meshes = None`. layer_geometries 변경 시에만 재빌드.

### H19 (ezdxf 순회)
- 6개 타입 루프 → 한 번 전체 순회 후 dispatch 가능. 그러나 ezdxf `msp.query`가 빠르게 인덱싱 → 큰 차이 없을 수 있음. 측정 후 결정.

### LOD (전체 씬 폴리곤 폭발 대응 — 함정 2/7)
- 현재 코드에 `n_cells > 100_000` 체크 / `decimate(target_reduction=0.7)` 없음. 사용자 "큰 도면 100배"에 무방비.
- **`SceneBudgetTracker` 추가** (`cities_placement_widget.py`에 이미 있다면 두 워크플로 공통 게이트로 끌어올림).
- TIN/DEM ring 메시 추가 직전:
  ```python
  if mesh.n_cells > 100_000:
      self.log(f"  메시 단순화: {mesh.n_cells} → ", end="")
      mesh = mesh.decimate(target_reduction=0.7)
      self.log(f"{mesh.n_cells}")
  ```

---

## 5. 60 FPS 게이트 검증 방법

`_open_interactive_viewer` 안에 §2.14 FPSLogger 삽입.
사용자 액션:
1. Step 3 클릭 → 인터랙티브 뷰어 열림
2. 10초간 마우스로 회전/줌
3. 콘솔에 `[FPS] 58.3` 같은 라인이 1초마다 찍힘
4. 평균값을 §3.5에 기록

게이트 기준:
- **평균 ≥ 60 FPS** → 통과
- **30 ≤ 평균 < 60** → EDL OFF / 메시 decimate / DEM ring step 키움
- **평균 < 30** → 메시 폴리곤 폭발 의심 → §4 LOD 항목 즉시 적용

---

## 6. 우선순위

### P1 (즉시 — 사용자가 측정 보고 후 다음 라운드)
- **H1**: XYZ 타일 ThreadPoolExecutor 병렬화 + threading.Thread 분리 (사용자 피드백 #11 정확히 매치, 가장 큰 체감 이득)
- **H12**: 캡처 3종 plotter 재사용 + threading 분리 (Step 3 GUI 동결 제거)
- **H9**: 굴착 루프 numpy 벡터화 (즉시 가능, 위험 0)
- **H16**: 텍스처 4회 디스크 재읽기 → 캐싱 (한 줄 수정)

### P2 (다음 — 측정으로 회귀 위험 검증 후)
- **H2**: terrarium 병렬화 + threading
- **H3/H5**: Phase C/Step 1.5-a early-exit + ConvexHull 1회 캐싱
- **H7**: DEM ring Laplacian / outer blend numpy 벡터화
- **H13**: unified merge 결과 캐싱
- **H18**: overlay 메시 캐싱

### P3 (장기 — 큰 도면/구조물 누적 시나리오용)
- LOD/decimate 게이트 도입 (함정 2/7 대비)
- SceneBudgetTracker 통합 (Cities + DXF 워크플로 공통)
- EDL/SSAO ON/OFF 토글 (함정 5 대비, 저사양 PC)
- 텍스처 4K → 2K 다운샘플 옵션 (함정 6 대비)

---

## 7. 작업 흐름 (Phase 1 측정용)

1. 사용자: §2의 instrumentation 패치를 임시로 삽입 (한 번에 다 넣지 말고 P1 핫스팟부터).
2. 사용자: 실 도면(사연댐 권장, 1~2km bbox)으로 Step 1~3 시나리오 실행.
3. 결과 콘솔 로그를 `outputs/perf_baseline_run_YYYYMMDD.log` 저장.
4. §3 표를 채움 → 어디가 정말 느린지 정량 확인.
5. P1 항목부터 패치 라운드 진행.
6. 패치 후 동일 시나리오 재측정 → 회귀 확인.
