Phase 0 of expert feedback (#1~#11): infrastructure + design + 1차 fixes

Implementations (즉시 동작):
- #1 crash logging: harness/crash_logger.py (sys.excepthook + threading +
  faulthandler, 회전 파일 logs/scanvas.log). main 진입점 통합.
- #2 smooth curves (1차): gate_3d_builder ogee profile를 arc-length parametric
  CubicSpline로 4× densify (8pt→32pt, 36→132 cells, 60 FPS 안전).
- #3 TIN colormap: matplotlib "terrain"의 파란색 범위 제거 → 짙은갈색→황토→
  모래→능선 LinearSegmentedColormap. 9 사이트 교체. 회귀 테스트 추가.
- #5 uv: pyproject.toml + UV_GUIDE.md. base/[py313]/[dev]/[build] extras + hatchling.
- #6,#7,#8 dev cycle infra: .pre-commit-config.yaml (ruff+secrets+위생),
  .gitea/workflows/ci.yml (Py3.11+3.13 matrix), tests/test_regressions.py
  (18 회귀 테스트, iter=1~7 fix 박제), CONTRIBUTING.md (Red→Green 알고리즘).

Design docs (다음 세션 마이그레이션 청사진):
- #4 UI/UX 전면 수정: UI_REDESIGN_PLAN.md (12 popup→1 inspector, vtkTkRenderWidget
  embedding 게이트, 4 phase × 7 sessions).
- #10 Core/Plugin: ARCHITECTURE_PLAN.md (Core 14 / Plugin 7 구조물 + 2 렌더 + 1 QA,
  STRUCTURE_REGISTRY 확장, manifest 기반 디스커버리).
- #11 perf hotspots: PERFORMANCE_BASELINE.md (19 핫스팟, P1: 타일 직렬DL 5~30s,
  캡처 직렬 4.5~15s, numpy 벡터화 가능 Python loops, 텍스처 4회 반복read).

Behavior preservation: ruff 0 errors, pytest 17 passed/1 skipped(bpy),
import 33/33 OK on Py3.13.13.

Item #2 P2/P3 곡선, #4 UI 마이그레이션, #10 Phase 1 추출, #11 P1 최적화는 차기 세션.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:45:30 +09:00
parent b9342f6726
commit e9cc6bfcf4
15 changed files with 2617 additions and 15 deletions

86
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,86 @@
# Gitea Actions — Red → Green 게이트.
# 피드백 #6: "Git을 이용해서 개발 cycle (Red→Green)이 완료되면 Git(s-canvas)에 자동
# 업로드 프로세스 구축 (ruff, pytest 등을 적극 활용)"
#
# 트리거: push 모든 브랜치, PR. main 브랜치는 추가 보호.
#
# Stage 구성:
# 1. ruff (린트) — 30초 미만
# 2. py_compile (전체 syntax) — 10초 미만
# 3. pytest (회귀 + 빠른 단위) — 1~2분
# 4. (선택) coverage report
#
# 모두 통과 = Green. main 브랜치는 Green 통과 시에만 push 허용 (Gitea repo 설정).
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
name: Ruff + Test (Py3.11 + Py3.13)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv (fast Python pkg manager)
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Setup Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install deps
run: |
uv venv .venv --python ${{ matrix.python-version }}
uv pip install --python .venv -e ".[dev]"
# Py3.13은 호환 핀 별도
if [ "${{ matrix.python-version }}" = "3.13" ]; then
uv pip install --python .venv -e ".[py313,dev]"
fi
- name: Ruff lint
run: |
source .venv/bin/activate
ruff check --output-format=github
- name: py_compile (전체 .py)
run: |
source .venv/bin/activate
python -c "
import py_compile, pathlib, sys
errs = []
for p in sorted(pathlib.Path('.').rglob('*.py')):
if any(s in str(p) for s in ('venv', '_unused', '__pycache__', '.bak')):
continue
try:
py_compile.compile(str(p), doraise=True)
except py_compile.PyCompileError as e:
errs.append((p, str(e)[:200]))
for p, m in errs:
print(f'::error file={p}::{m}')
sys.exit(1 if errs else 0)
"
- name: pytest (회귀)
run: |
source .venv/bin/activate
pytest -ra --tb=short -m "not slow and not integration"
- name: pytest (slow + integration, allow failure)
if: ${{ matrix.python-version == '3.13' }}
continue-on-error: true
run: |
source .venv/bin/activate
pytest -ra --tb=short -m "slow or integration"

55
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,55 @@
# Pre-commit hooks — local Red→Green 게이트 (피드백 #6/#7/#8).
# 사용법:
# uv pip install pre-commit # 또는 pip install pre-commit
# pre-commit install # .git/hooks/pre-commit 등록
# pre-commit run --all-files # 수동 전체 실행
#
# 이후 모든 `git commit`이 자동으로 ruff + 기본 위생 검사 통과해야 commit됨.
# 피드백 #6 "Red → Green 완료 시에만 Git 자동 업로드" 의 첫 단계.
repos:
# Ruff — 린트 + 자동 수정 (project ruff.toml 적용)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
# ruff format은 black 호환 — 기존 스타일 보존을 위해 비활성으로 시작.
# 활성화 원하면 stages=[manual] 제거 + 한 번 전체 적용.
stages: [manual]
# 기본 위생
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(_unused/|.*\.bak.*)
- id: end-of-file-fixer
exclude: ^(_unused/|.*\.bak.*|.*\.png|.*\.dxf|.*\.pdf|.*\.mp4|.*\.gif)
- id: check-yaml
- id: check-toml
- id: check-added-large-files
args: [--maxkb=20480] # 20MB 한도 — Design/SAMPLE_CAD 큰 파일은 이미 git에 있음
- id: check-merge-conflict
- id: detect-private-key
# 비밀 누출 방지 (gcp-key.json 같은 파일 차단)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: ^(_unused/|tests/|.*\.bak.*|venv.*/|\.git/)
# 로컬 hook — pytest 빠른 회귀만 (느린 통합 테스트 제외)
# pytest는 pre-commit-hooks가 아닌 local stage로 등록 — pre-commit pull 안 함.
# 사용자가 더 엄격한 게이트 원하면 stages: [pre-push] 활성.
default_install_hook_types: [pre-commit]
default_stages: [pre-commit]
# pytest를 pre-push 스테이지에 두면 commit 빨라지고 push 직전에만 테스트.
# `pre-commit install --hook-type pre-push` 추가로 활성.
ci:
autofix_commit_msg: 'chore(pre-commit): autofix'
autoupdate_commit_msg: 'chore(pre-commit): autoupdate hook revs'

630
ARCHITECTURE_PLAN.md Normal file
View File

@@ -0,0 +1,630 @@
# S-CANVAS Core/Plugin 분리 설계 (Phase 0 — 분석)
> **상태**: 분석/설계 단계 (Phase 0). 코드 변경 없음.
> **작성**: library-architect subagent (read-only survey).
> **기준 일자**: 2026-05-08
> **이전 단계**: 사용자 피드백 #10 — "코드 구조를 Core(핵심), plug-in(추가)으로 구분 필요".
---
## 0. 설계 원칙
이 계획은 다음 4가지를 동시에 만족해야 한다.
1. **현재 동작 깨지 않음**`python scanvas_maker.py` 가 모든 단계에서 그대로 동작.
2. **점진적 마이그레이션** — 한 번에 모든 파일을 옮기지 않음. 2~3 세션에 걸쳐.
3. **`STRUCTURE_REGISTRY` 확장(replace 아님)** — 이미 잘 짜인 싱글톤 레지스트리를
plugin entry-point 의 첫 번째 사용처로 만든다.
4. **library-architect.md 의 Phase 2 비전과 정렬**`manifest.json` 기반 동적
디스커버리, `.scanvas-lib` ZIP 배포가 최종 목표.
> **비목표**: PyPI 엔트리포인트, 온라인 마켓, Phase 3 의 "꿈" 단계는 본 문서 범위가
> 아니다. 로컬 폴더/ZIP 디스커버리만 다룬다.
---
## 1. 현재 구조 진단
### 1.1 모듈 그래프 (의존 방향)
```
┌──────────────────────────────┐
│ scanvas_maker.py (~7000 LOC)│ ← GUI 진입점, 거의 모든 것 import
│ CustomTkinter SCanvasApp │
└─────────────┬────────────────┘
┌─────────────┬───────┼───────┬─────────────┬─────────────┐
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
resource_paths splash dem_extender geo_referencing tile_downloader dxf_geometry
│ (공유)
┌────────────────────────────────────────────────────────────────────────┘
structure_templates.py ── REGISTRY (싱글톤)
│ ├── SpillwayGateTemplate ──→ gate_parser, gate_3d_builder
│ ├── IntakeTowerTemplate ──→ intake_tower_parser, intake_tower_3d_builder
│ ├── ValveChamberTemplate ──→ valve_chamber_parser, valve_chamber_3d_builder
│ ├── DetailedRetainingWallTemplate ──→ retaining_wall_parser, retaining_wall_3d_builder
│ ├── BuildingTemplate / BridgeTemplate / TunnelPortalTemplate (placeholder)
│ └── GenericStructureTemplate
structure_placement / structure_vlm_feedback / filename_classifier
view_detector / view_reconstructor / polygon_reconstructor / detail_parser / optional_detector
렌더링 백엔드 (선택 가능):
├── gemini_renderer.py (Gemini Vertex/API)
└── blender_renderer.py (Blender 헤드리스 + bpy 빌더)
└── gate_3d_builder_bpy.py + apply_blender_patch.py + fix_bpy_import.py
+ validate_gate_params.py + params_to_json.py
품질·이력:
harness/
├── seed_manager.py
├── quality_validator.py
├── prompt_registry.py
└── logger.py (SQLite ORM JobRecord)
런타임 자산 (읽기):
Design/, prompt_templates/, structure_types/
사용자 데이터 (쓰기, %LOCALAPPDATA%\S-CANVAS\):
cache/dem/, scanvas_jobs.db, scanvas_*.log
```
### 1.2 Core 후보 vs Plugin 후보
판정 기준: "이걸 빼면 GUI 가 떠도 1·1.5·2 단계 파이프라인이 되는가?"
| 모듈 | 분류 | 사유 |
|---|---|---|
| `scanvas_maker.py` | **Core** | GUI 엔트리·이벤트 루프·상태 보유 |
| `splash.py` | **Core** | 부팅 1회 호출, 의존성도 cv2/PIL 만 |
| `resource_paths.py` | **Core** | 모든 IO 경로의 진실. 대체 불가 |
| `dxf_geometry.py` | **Core** | 모든 파서/빌더의 공통 전처리 |
| `geo_referencing.py` | **Core** | 4점 매칭은 파이프라인 필수 |
| `dem_extender.py` | **Core** | 1.5 단계의 핵심. AWS 의존이지만 옵션 분기 있음 |
| `tile_downloader.py` | **Core** | 2 단계 (위성 결합) 필수 |
| `polygon_reconstructor.py` | **Core (util)** | 여러 파서가 공유 |
| `optional_detector.py` | **Core (util)** | 부속 컴포넌트 검출, 파서 공통 |
| `filename_classifier.py` | **Core (util)** | 키워드 → template_id 매핑, GUI 가 직접 호출 |
| `detail_parser.py` | **Core (util)** | 모든 구조물 파서가 공유하는 치수 추출 |
| `view_detector.py` / `view_reconstructor.py` | **Core (util)** | template 의 `try_view_based_*` 가 호출 |
| `structure_placement.py` | **Core (util)** | 파이프라인의 "위치인식 → 굴착 → 배치" 4단계 중 핵심 |
| `harness/*` | **Core** (선택적) | 이미 try/except 로 import 보호되어 있음. 항상 로드는 하지만 없어도 동작 |
| `structure_templates.py` | **Core (host)** + Plugin | `StructureTemplate` ABC 와 `TemplateRegistry` 는 Core, 7개 구체 클래스는 Plugin |
| `gate_*`, `intake_tower_*`, `valve_chamber_*`, `retaining_wall_*` (parser+builder 쌍) | **Plugin (structure)** | 각각 1종의 구조물. 빠져도 다른 구조물은 작동 |
| `gate_3d_builder_bpy.py` | **Plugin (structure-renderer variant)** | Blender 백엔드용 같은 구조물의 변종 |
| `validate_gate_params.py` | **Plugin (helper)** | gate-bpy 와 같이 묶여야 의미가 있음 |
| `apply_blender_patch.py`, `fix_bpy_import.py` | **Plugin (tooling)** | 일회성 패치 도구. 마이그레이션 대상 아님 |
| `params_to_json.py` | **Core (util)** | 모든 파서 결과를 JSON 직렬화. plugin 도 사용 |
| `gemini_renderer.py` | **Plugin (render)** | AI 렌더 백엔드의 한 종류. 다른 백엔드로 교체 가능 |
| `blender_renderer.py` | **Plugin (render)** | 같은 위치, 다른 엔진 |
| `structure_vlm_feedback.py` | **Plugin (qa)** | Gemini Vision 기반 QA. 빠져도 빌드 자체는 됨 |
| `structure_types/*.yaml`, `prompt_templates/*.yaml` | **데이터** | 자체로는 코드 아님. 각 plugin 이 자기 yaml 동봉 가능 |
### 1.3 발견된 사실
- `STRUCTURE_REGISTRY`**이미** 잘 설계된 plugin host 다 (Singleton + ABC + dict).
단지 `_register_defaults()` 가 import-bound 하드코딩이라는 점만 동적으로
바꾸면 된다.
- `harness/`**이미 plugin-스타일** (`try: from harness... except ImportError`).
이 패턴을 다른 옵션 모듈(`structure_vlm_feedback`, 렌더러들)이 모방한다.
- `scanvas_maker.py` 의 import 블록(line 31-80) 자체가 **암묵적 디스커버리**다:
각 모듈을 try-import 해서 가용성 플래그(`HARNESS_AVAILABLE`,
`STRUCTURE_TEMPLATES_AVAILABLE`, `DEM_EXTENDER_AVAILABLE`,
`STRUCTURE_VLM_AVAILABLE` 등)를 세운다. → 명시적 plugin manager 로 통합 가능.
- 구조물 plugin 은 `parser + builder` 2-파일 쌍이 표준. 일관성 양호.
- Blender 트랙은 `*_bpy.py` 접미사로 구분되어 있지만, 같은 디렉토리에 섞여 있다.
---
## 2. 제안 레이아웃
### 2.1 디렉토리 트리 (목표)
```
D:\2026\PROGRAM\1_S-CANVAS\
├── scanvas_maker.py # 메인 진입점 (그대로)
├── splash.py # (그대로)
├── resource_paths.py # (그대로)
├── core/ # ★ 신규 패키지
│ ├── __init__.py
│ ├── plugin_manager.py # ★ 플러그인 로더 (신규)
│ ├── manifest.py # ★ Manifest dataclass + 검증 (신규)
│ ├── interfaces.py # ★ Plugin abstract bases (신규)
│ │
│ ├── geo/ # 좌표·DXF 공통
│ │ ├── __init__.py
│ │ ├── dxf_geometry.py ← 이동
│ │ ├── geo_referencing.py ← 이동
│ │ ├── tile_downloader.py ← 이동
│ │ ├── dem_extender.py ← 이동
│ │ └── polygon_reconstructor.py ← 이동
│ │
│ ├── parsing/ # 공통 파서·검출
│ │ ├── __init__.py
│ │ ├── detail_parser.py ← 이동
│ │ ├── view_detector.py ← 이동
│ │ ├── view_reconstructor.py ← 이동
│ │ ├── optional_detector.py ← 이동
│ │ └── filename_classifier.py ← 이동
│ │
│ ├── structures/ # 구조물 호스트 (ABC + Registry)
│ │ ├── __init__.py
│ │ ├── base.py ← structure_templates.py 의 추상부
│ │ ├── registry.py ← TemplateRegistry + REGISTRY
│ │ ├── placement.py ← structure_placement.py
│ │ └── params_io.py ← params_to_json.py
│ │
│ └── harness/ ← 그대로 (이미 패키지)
│ ├── __init__.py
│ ├── seed_manager.py
│ ├── quality_validator.py
│ ├── prompt_registry.py
│ └── logger.py
├── plugins/ # ★ 신규 패키지 (디스커버리 루트)
│ ├── __init__.py
│ │
│ ├── structures/ # ── 구조물 라이브러리 ──
│ │ ├── spillway_gate/
│ │ │ ├── manifest.json # ★ 신규
│ │ │ ├── plugin.py # SpillwayGateTemplate (구 structure_templates.py 의 클래스)
│ │ │ ├── parser.py ← gate_parser.py
│ │ │ ├── builder_pyvista.py ← gate_3d_builder.py
│ │ │ ├── builder_blender.py ← gate_3d_builder_bpy.py
│ │ │ ├── validate.py ← validate_gate_params.py
│ │ │ ├── parameters.json # ★ 신규 (UI 폼 자동 생성용)
│ │ │ └── samples/ # ★ 신규 (1~3개 sample params)
│ │ │
│ │ ├── intake_tower/
│ │ │ ├── manifest.json
│ │ │ ├── plugin.py
│ │ │ ├── parser.py ← intake_tower_parser.py
│ │ │ └── builder_pyvista.py ← intake_tower_3d_builder.py
│ │ │
│ │ ├── valve_chamber/ … (동일 구조)
│ │ ├── retaining_wall/ … (동일 구조)
│ │ │
│ │ ├── building/ # 현재 placeholder. 같은 레이아웃에 진입
│ │ ├── bridge/
│ │ └── tunnel_portal/
│ │
│ ├── render/ # ── 렌더 백엔드 ──
│ │ ├── gemini/
│ │ │ ├── manifest.json
│ │ │ └── plugin.py ← gemini_renderer.py
│ │ └── blender/
│ │ ├── manifest.json
│ │ ├── plugin.py ← blender_renderer.py
│ │ └── tooling/ ← apply_blender_patch.py, fix_bpy_import.py
│ │
│ └── qa/ # ── 품질/피드백 ──
│ └── vlm_feedback/
│ ├── manifest.json
│ └── plugin.py ← structure_vlm_feedback.py
├── structure_types/ # (그대로) — yaml은 plugin 외부 공유 데이터
│ └── structure_v1.yaml
├── prompt_templates/ # (그대로)
│ └── prompt_v1.yaml
├── Design/ # (그대로)
├── user_plugins/ # ★ 신규 — 사용자 설치 라이브러리 (외부 ZIP 풀어 놓는 곳)
│ └── (ex: SpillwayGate_v2.scanvas-lib 풀린 폴더)
├── _unused/ # (그대로) — 격리된 옛 코드
└── venv313/ # (그대로)
```
### 2.2 핵심 결정
- **Core 는 단일 파이썬 패키지**(`core/`) 로 통일. 모든 공유 유틸·ABC·레지스트리는
여기 들어간다.
- **Plugins 는 디렉토리 한 개 + manifest.json 한 개** = 한 plugin. 파일 수는
그 안에서 자유롭게.
- **두 디스커버리 루트**:
1. `plugins/` (소스 트리, 번들 plugin) — 항상 로드
2. `user_plugins/` (사용자 데이터 폴더) — 옵트인. ZIP 압축 해제 위치
- **렌더링 백엔드도 plugin** — Gemini/Blender 가 동급. 미래에 ComfyUI·Stability 등을
추가할 수 있게.
---
## 3. 플러그인 인터페이스
### 3.1 Abstract Bases (`core/interfaces.py`)
```python
# core/interfaces.py
from abc import ABC, abstractmethod
from pathlib import Path
import pyvista as pv
# (1) 구조물 plugin — 기존 StructureTemplate 의 외부 노출 이름
class StructurePlugin(ABC):
plugin_id: str # "spillway_gate" — manifest.id 와 일치
template_id: str # registry 키 (legacy 호환을 위해 별도 유지)
name_ko: str
description: str
icon_hint: str = ""
required_files: tuple[int, int, int] = (1, 2, 1)
supports_view_based: bool = True
@abstractmethod
def get_parameter_schema(self) -> "list[ParamField]": ...
@abstractmethod
def parse(self, dxf_paths: list[str]) -> "StructureParams": ...
@abstractmethod
def build_meshes(self, params) -> list[tuple[pv.PolyData, str, float]]: ...
def validate_params(self, params) -> tuple[bool, str]:
return True, "" # 기본 통과
# (2) 렌더 백엔드 plugin
class RenderPlugin(ABC):
plugin_id: str # "gemini", "blender"
name_ko: str
requires_credentials: bool
@abstractmethod
def is_available(self) -> tuple[bool, str]:
"""라이브러리/외부 의존이 갖춰졌는지 검사."""
@abstractmethod
def render(self, app, *, control_map: Path, prompt: str, **kwargs) -> Path:
"""제어맵 + 프롬프트 → PNG 경로."""
# (3) QA plugin
class QAPlugin(ABC):
plugin_id: str
@abstractmethod
def evaluate(self, *, build_meshes, original_dxf, current_params) -> dict:
"""JSON-able 결과 (missing/incorrect/excess 등)."""
# (4) 데이터 클래스 (기존 그대로 노출)
# StructureParams, ParamField — core/structures/base.py 에서 재export
```
> **핵심**: `StructurePlugin` 은 **현재의 `StructureTemplate` 와 100% 호환** 되도록
> 같은 메서드 시그니처를 유지. 즉 기존 클래스에 부모 하나만 바꾸는 것으로 충분.
### 3.2 Manifest 스키마 (`core/manifest.py`)
library-architect.md 의 비전을 그대로 차용하되, **현재 단계에 필요한 필드만**:
```json
{
"schema_version": "1.0",
"id": "spillway_gate",
"name": "여수로 래디얼 수문",
"name_en": "Spillway Radial Gate",
"kind": "structure",
"category": "hydraulic_structure",
"subcategory": "gate",
"version": "1.2.0",
"author": "Saman Corp.",
"license": "Proprietary",
"description": "ogee 여수로 + 래디얼 수문 + 공도교 + 개폐장치",
"min_scanvas_version": "0.5.0",
"entry": {
"type": "python_module",
"module": "plugin",
"class": "SpillwayGatePlugin",
"registry_id": "spillway_gate"
},
"parameters_schema": "parameters.json",
"samples": ["samples/sample_default.json"],
"metadata": {
"complexity": "high",
"polygon_estimate": 35000,
"supports_pyvista": true,
"supports_blender": true,
"requires_gpu": false
}
}
```
`kind` 값:
- `structure``StructurePlugin` 로딩, `STRUCTURE_REGISTRY` 에 등록
- `render``RenderPlugin` 로딩, `RENDER_REGISTRY` 에 등록
- `qa``QAPlugin` 로딩
`Manifest` 데이터클래스는 `core/manifest.py` 에서 정의하고, JSON 스키마 검증을
거친 뒤 plugin manager 에 넘긴다.
---
## 4. 디스커버리 메커니즘
### 4.1 단계별 디스커버리 정책
`core/plugin_manager.py``discover()` 알고리즘:
```python
# 의사코드
def discover(roots: list[Path]) -> dict[str, LoadedPlugin]:
found = {}
for root in roots:
for plugin_dir in root.iterdir():
if not plugin_dir.is_dir():
continue
mf = plugin_dir / "manifest.json"
if not mf.exists():
continue
try:
manifest = Manifest.load_validated(mf)
except ValidationError as e:
log.warning(f"[plugin] {plugin_dir.name}: invalid manifest: {e}")
continue
if not _version_compatible(manifest.min_scanvas_version):
log.warning(f"[plugin] {plugin_dir.name}: needs >= {manifest.min_scanvas_version}")
continue
try:
cls = _import_entry(plugin_dir, manifest.entry)
instance = cls()
except Exception as e:
log.warning(f"[plugin] {plugin_dir.name}: failed to load: {e}")
continue
found[manifest.id] = LoadedPlugin(manifest, instance, plugin_dir)
return found
```
**디스커버리 루트 우선순위**:
1. `<asset_root>/plugins/` — 번들 plugin (소스 트리 또는 PyInstaller `_MEIPASS`)
2. `<user_data_dir>/user_plugins/` — 사용자 설치 plugin
3. `(미래)` ZIP 경로 직접 — `*.scanvas-lib` 파일 압축 해제 후 (2) 로 이동
같은 `manifest.id` 가 둘 이상 발견되면 **버전 비교 후 최신 우선**, 동률이면
**user_plugins 우선** (사용자 override).
### 4.2 진입점 로딩
`plugin_dir / manifest.entry.module + ".py"` 에서 `manifest.entry.class` 를 import.
파이썬 import path 충돌 회피를 위해 `importlib.util.spec_from_file_location` 으로
**고유 모듈 이름**(`scanvas_plugin_<id>_<sha8>`) 을 부여한다.
### 4.3 등록
- `kind == "structure"``core/structures/registry.py``REGISTRY._templates[mf.id] = instance`
(기존 `_register_defaults()` 가 하던 일)
- `kind == "render"``core/render/registry.py` (신규) 의 `RENDER_REGISTRY[mf.id] = instance`
- `kind == "qa"``QA_REGISTRY[mf.id] = instance`
`scanvas_maker.py` 의 부팅 코드 1줄만 추가:
```python
from core.plugin_manager import bootstrap_plugins
bootstrap_plugins() # discover → register, 모든 REGISTRY 가 이 시점 이후 사용 가능
```
---
## 5. 마이그레이션 단계
### Phase 1 — Core 추출 (1 세션, 2~3시간)
**범위**: 디렉토리 이동 + import 경로 갱신만.
**작업**:
1. `core/` 패키지 생성 + 빈 `__init__.py`
2. 다음 파일들을 `core/<subdir>/` 로 git mv (또는 단순 이동):
- `dxf_geometry.py`, `geo_referencing.py`, `tile_downloader.py`,
`dem_extender.py`, `polygon_reconstructor.py``core/geo/`
- `detail_parser.py`, `view_detector.py`, `view_reconstructor.py`,
`optional_detector.py`, `filename_classifier.py``core/parsing/`
- `structure_placement.py`, `params_to_json.py``core/structures/`
- `harness/``core/harness/`
3. 각 파일의 상대 import 갱신 (e.g., `from view_detector import ...`
`from core.parsing.view_detector import ...`)
4. `scanvas_maker.py` 의 import 블록 갱신
5. **`structure_templates.py` 는 아직 그대로 둔다.** 거대 파일이라 분할은
Phase 2 에서.
**Risk**: 순환 import. `dxf_geometry``view_detector` 같은 사이클이 잠재.
**Mitigation**: 이동 전 `python -c "import scanvas_maker"` smoke test. 한 모듈씩
이동하면서 매번 검증.
**Rollback**: Phase 1 은 단순 이동이라 git revert 로 즉시 복구.
**Backwards compat**: 다음 5개 파일을 **shim 으로 유지**:
```python
# /dxf_geometry.py (1줄짜리 shim)
from core.geo.dxf_geometry import * # noqa: F401, F403
from core.geo.dxf_geometry import __all__ # noqa: F401
```
→ 외부 사용자(있다면)나 일회성 스크립트가 깨지지 않음. Phase 3 에 제거.
---
### Phase 2 — Structure plugin 추출 (1~2 세션)
**범위**: `structure_templates.py` 의 7개 구체 클래스를 `plugins/structures/<id>/`
로 분할 + manifest.json 작성.
**작업** (1 plugin 씩, 가장 단순한 것부터):
1. `plugins/structures/spillway_gate/` 생성
2. `gate_parser.py`, `gate_3d_builder.py`, `gate_3d_builder_bpy.py`,
`validate_gate_params.py` 를 그 안으로 이동
3. `structure_templates.py``SpillwayGateTemplate` 클래스를 추출하여
`plugins/structures/spillway_gate/plugin.py` 로 이동, 부모를 `StructurePlugin`
으로 변경
4. `manifest.json`, `parameters.json`, `samples/sample_default.json` 작성
5. `core/structures/registry.py``_register_defaults()` 에서 `SpillwayGateTemplate`
import 를 제거 — plugin manager 가 대신 등록
6. **레거시 import 호환**: `gate_parser.py` 등 기존 경로 import 가 있는 파일
(현재 `gemini_renderer.py`, `apply_blender_patch.py`, `params_to_json.py` 등)을
확인하여 새 경로로 갱신. 외부 사용자용 shim 은 아래 backward compat 섹션 참조.
7. 동일 절차로 IntakeTower → ValveChamber → RetainingWall → Building/Bridge/Tunnel
→ Generic 순서.
**Risk**: 6번 항목. 특히 `from gate_parser import GateParams` 같은 import 가
plugin 외부 코드에서 발견될 가능성.
**Mitigation**: 각 plugin 추출 후 grep 으로 잔존 import 확인. shim 추가.
**Rollback**: plugin 단위로 git tag 를 찍어 두면 한 plugin 만 되돌리기 가능.
---
### Phase 3 — Render/QA plugin 추출 + 사용자 plugin 디스커버리 (1 세션)
**범위**:
1. `gemini_renderer.py``plugins/render/gemini/plugin.py` (RenderPlugin 인터페이스 적용)
2. `blender_renderer.py``plugins/render/blender/plugin.py`
3. `structure_vlm_feedback.py``plugins/qa/vlm_feedback/plugin.py`
4. `core/plugin_manager.py``bootstrap_plugins()``user_data_dir() / "user_plugins"`
를 두 번째 디스커버리 루트로 사용하도록 활성화
5. `*.scanvas-lib` (ZIP) → `user_plugins/<id>/` 풀어주는 헬퍼 `import_library_zip(path)`
6. GUI 사이드바에 "라이브러리 가져오기" 버튼 (드래그 앤 드롭 → import_library_zip)
**Risk**: PyInstaller 번들에서 `_MEIPASS` 안의 plugin 디렉토리를 importlib 가
정확히 찾는지. **Mitigation**: Phase 1 의 smoke test 를 PyInstaller 빌드 후에도
재실행. spec 파일에 `--add-data "plugins/;plugins/"` 추가.
**Rollback**: plugin manager bootstrap 호출 1줄을 주석 처리하면 즉시 옛 동작
복귀.
---
## 6. Backward Compatibility
### 6.1 기존 import 가 살아남는 방법
| 옛 경로 | 새 경로 | shim 전략 |
|---|---|---|
| `from gate_parser import GateParams, parse_gate_dxf` | `plugins.structures.spillway_gate.parser` | **별도 shim 모듈 안 둠**. 호출자 모두 내부 코드라 직접 갱신 |
| `from gate_3d_builder import GateBuilder` | `plugins.structures.spillway_gate.builder_pyvista` | 위와 동일 |
| `from structure_templates import REGISTRY as STRUCTURE_REGISTRY` | `from core.structures.registry import REGISTRY as STRUCTURE_REGISTRY` | `structure_templates.py` 를 **shim 한 줄**로 축소 |
| `from harness.logger import ...` | `from core.harness.logger import ...` | `/harness/__init__.py` 안에 `from core.harness import *` |
| `from dxf_geometry import ...` | `from core.geo.dxf_geometry import ...` | 루트의 같은 이름 .py 를 1줄 shim 으로 유지 |
| `from gemini_renderer import run_gemini_render` | `plugins.render.gemini.plugin` | 루트 shim |
### 6.2 shim 수명
- Phase 1·2 동안: shim 적극 사용 → 단계마다 외부 진입점이 안 깨짐.
- Phase 3 이후 1 release: shim 유지하되 `DeprecationWarning` 한 줄 발사.
- Phase 3 + 1 release 후: shim 제거.
### 6.3 데이터 호환
- `scanvas_jobs.db` (SQLite) — 스키마 변경 없음. ORM 모듈 위치만 이동.
- `prompt_templates/*.yaml`, `structure_types/*.yaml` — 파일 위치/포맷 변경 없음.
- 사용자 입력 파라미터 (StructureParams) — dataclass 형태 그대로.
---
## 7. 영향 받는 파일 (예상)
### 7.1 이동 (총 ~25 파일)
```
[Phase 1, Core 추출, 14 파일]
dxf_geometry.py, geo_referencing.py, tile_downloader.py, dem_extender.py,
polygon_reconstructor.py
detail_parser.py, view_detector.py, view_reconstructor.py,
optional_detector.py, filename_classifier.py
structure_placement.py, params_to_json.py
harness/seed_manager.py, harness/quality_validator.py,
harness/prompt_registry.py, harness/logger.py
[Phase 2, Structure plugin, 8 파일 + 7 manifest]
gate_parser.py, gate_3d_builder.py, gate_3d_builder_bpy.py, validate_gate_params.py
intake_tower_parser.py, intake_tower_3d_builder.py
valve_chamber_parser.py, valve_chamber_3d_builder.py
retaining_wall_parser.py, retaining_wall_3d_builder.py
+ structure_templates.py 에서 7 클래스 추출 분리
[Phase 3, Render/QA plugin, 3 파일]
gemini_renderer.py
blender_renderer.py (+ apply_blender_patch.py, fix_bpy_import.py 동반)
structure_vlm_feedback.py
```
### 7.2 신규 (총 ~30 파일)
```
core/__init__.py, core/plugin_manager.py, core/manifest.py, core/interfaces.py
core/geo/__init__.py, core/parsing/__init__.py, core/structures/__init__.py
core/structures/base.py, core/structures/registry.py
core/render/__init__.py, core/render/registry.py
core/qa/__init__.py, core/qa/registry.py
plugins/__init__.py
plugins/structures/<id>/manifest.json × 7
plugins/structures/<id>/parameters.json × 7
plugins/structures/<id>/samples/sample_default.json × 7
plugins/render/gemini/manifest.json
plugins/render/blender/manifest.json
plugins/qa/vlm_feedback/manifest.json
```
### 7.3 수정 (그대로 두지만 import 갱신)
```
scanvas_maker.py — import 블록 + bootstrap_plugins() 호출 1줄 추가
structure_templates.py — 7 클래스 추출 후 shim 으로 축소 (선택)
splash.py — 변경 없음
resource_paths.py — user_plugins() 헬퍼 추가
```
### 7.4 삭제 후보 (이미 `_unused/` 가 있으므로 동일 정책)
```
apply_blender_patch.py, fix_bpy_import.py
→ plugins/render/blender/tooling/ 로 이동 (아카이브 성격)
```
### 7.5 변경 없음
```
Design/, prompt_templates/, structure_types/, cache/, venv313/, _unused/
```
---
## 8. 검증 체크리스트 (각 Phase 완료 시)
- [ ] `python scanvas_maker.py` 가 정상 부팅 (스플래시 → GUI)
- [ ] DXF 로드 → Step 1 TIN 생성 → Step 1.5 DEM 확장 → Step 2 위성 결합 → Step 3 캡처
- [ ] 7종 구조물 모두 `STRUCTURE_REGISTRY.list_choices()` 에 나타남
- [ ] 1종 구조물 빌드 (예: spillway_gate) 후 PyVista 미리보기 정상
- [ ] Step 4 AI 렌더 (Gemini 또는 Stability) 1회 성공
- [ ] `harness` SQLite 작업 이력 1건 기록 확인
- [ ] PyInstaller `pyinstaller scanvas.spec` 빌드 성공 (Phase 3 종료 시)
---
## 9. 미결 / 추가 논의 필요
1. **새 카테고리 트리** (`hydraulic_structure / transportation / building / landscape /
terrain`) 와 **기존 `structure_v1.yaml` 의 type 키** 의 연결 — yaml 의 `terrain`,
`road`, `embankment` 같은 type 은 카테고리가 아니라 *render mode* 다. 따라서
카테고리는 **manifest 에서만** 사용하고, yaml type 은 별개로 둔다.
2. **PyPI 엔트리포인트 활용 여부** — 본 계획은 manifest 스캔만 채택. 진짜 외부
배포가 필요해지면 별도 phase 4 로 검토.
3. **테스트 전략** — 현재 코드에 unit test 가 거의 없다. Phase 1 이전에 최소
smoke test (`python scanvas_maker.py --self-test`) 를 추가해 마이그레이션 안전망
확보 권장.
---
## 10. 요약 (의사결정자용 한 페이지)
- **Core**: GUI · 파이프라인 · DXF I/O · TIN · DEM · 위성타일 · harness · structure ABC/Registry.
→ `core/` 단일 패키지로 격리.
- **Plugin**: 7종 구조물 빌더 · 2종 렌더(Gemini/Blender) · 1종 QA(VLM).
→ `plugins/<kind>/<id>/manifest.json + plugin.py` 표준.
- **호스트**: 기존 `STRUCTURE_REGISTRY` (싱글톤) 가 그대로 plugin host. 단지
`_register_defaults()` 를 `plugin_manager.discover()` 로 교체.
- **마이그레이션**: 3 phase, 각 1~2 세션, 각 phase 완료 시 동작 검증 가능.
- **호환성**: 모든 기존 import 는 1~2줄 shim 으로 살아남음 → 사용자 임팩트 0.
- **최종 비전**: 사용자가 `*.scanvas-lib` ZIP 을 GUI 에 드롭 → 즉시 새 구조물 사용.
Phase 3 끝나면 도달.

127
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,127 @@
# S-CANVAS 개발 사이클 (Red → Green)
> **피드백 #6/#7/#8**: 사용자가 명시한 개발 관리 원칙. 이 문서는 그 원칙을 코드화.
## 1. 개발 사이클 — Red → Green → Push
```
[로컬] [원격]
코드 작성/수정
ruff check (자동 fix) ← Red 단계: 린트 위반 0
pytest (회귀 + 단위) ← Red → Green 전환: 모든 테스트 통과
git commit ← pre-commit hook이 ruff 재검사
git push ← Gitea Actions가 ruff + pytest 재실행
↓ 모두 Green 시에만 main에 머지
[성공 → 다음 작업]
[실패 → Red 복귀]
```
**핵심 원칙**: Red가 켜진 상태로는 push 금지. main은 항상 Green.
## 2. 새 버그 발견 시 (피드백 #7)
> "버그가 식별되면 수정 후 재발하지 않도록 하는 알고리즘도 필요"
**알고리즘**:
1. 버그 재현 코드 → `tests/test_regressions.py`**먼저** 추가 (Red 상태).
2. pytest 실행 → 새 테스트 fail 확인 (재현 확인).
3. 수정 코드 작성 → pytest 통과 확인 (Green).
4. commit (테스트 + 수정 함께).
5. 이후 누군가가 같은 버그를 재도입하려 하면 → CI에서 즉시 catch.
이게 "재발 방지 알고리즘". 메모만 적는 게 아니라 **실행 가능한 가드**로.
## 3. 코드 퀄리티 장치 (피드백 #8)
| 단계 | 도구 | 무엇을 차단 |
|---|---|---|
| 작성 중 | ruff (LSP/IDE) | 실시간 린트 |
| commit 시 | pre-commit | ruff + 위생 + 비밀 누출 |
| push 시 | Gitea Actions | ruff + py_compile + pytest 매트릭스 |
| 코드 리뷰 | code-reviewer subagent | 비전 위반/구조 함정 |
## 4. 시작하기
```powershell
cd D:\2026\PROGRAM\1_S-CANVAS
# uv 환경
uv venv .venv313 --python 3.13
.\.venv313\Scripts\activate
uv pip install -e ".[py313,dev]"
# pre-commit 등록
pre-commit install
pre-commit install --hook-type pre-push # push 직전 pytest 추가 게이트
# 테스트 한 번 돌려서 환경 확인
pytest -ra
ruff check
```
## 5. 새 기능/버그 수정 워크플로
```powershell
# 0. 기능 브랜치
git checkout -b feature/my-thing
# 1. (버그 수정인 경우만) 회귀 테스트 먼저 추가 → Red 확인
# tests/test_regressions.py 에 새 함수 추가
pytest tests/test_regressions.py::test_new_thing # → fail (정상)
# 2. 코드 작성/수정
# 3. ruff 자동 수정
ruff check --fix
# 4. pytest 통과 확인 (Green 도달)
pytest -ra
# 5. commit (pre-commit이 ruff 재검사)
git add -p
git commit -m "fix: <설명>"
# 6. push (pre-push hook이 pytest 재실행)
git push origin feature/my-thing
# 7. PR 만들기 → Gitea Actions가 매트릭스 검증 → main 머지
```
## 6. 자동 push (피드백 #6 — 본 라운드 미구현, 다음 라운드)
현재는 사람이 `git push` 실행. 다음 라운드 후보:
- **옵션 A (보수)**: `pre-push` 훅에서 ruff + pytest pass 시 자동 `git push` (사람 한 번 더 확인 필요).
- **옵션 B (적극)**: 로컬 `watch` 데몬 — Green 도달 직후 자동 push.
- **옵션 C (안전)**: feature 브랜치만 자동 push, main은 항상 PR.
옵션 C 권장. main 보호는 Gitea repo settings → Branch Protection 으로 강제.
## 7. 서브에이전트 활용
`.claude/agents/` 안 7개 specialized agent — Claude Code 세션에서 자동/명시 호출:
- `product-vision-keeper` — 큰 결정 전 검토
- `ux-designer` — UI 변경
- `performance-guardian` — 성능 회귀 의심
- `library-architect` — 새 구조물/플러그인
- `pyvista-renderer` — 3D 시각 품질
- `code-reviewer` — 코드 변경 후 비판
- `INSTALL_AND_USE.md` — 설치/사용 가이드
`.claude/agents/INSTALL_AND_USE.md` 참고.
## 8. 산출물 (이번 라운드 신규)
- `harness/crash_logger.py` — 크래시 로그 + faulthandler (#1)
- `scanvas_maker.py` `_TIN_EARTH_CMAP` — 파란색 없는 TIN 컬러맵 (#3)
- `pyproject.toml` + `UV_GUIDE.md` — uv 마이그레이션 (#5)
- `ARCHITECTURE_PLAN.md` — Core/Plugin 분리 설계 (#10)
- `PERFORMANCE_BASELINE.md` — 19 핫스팟 + 측정 plan (#11)
- `UI_REDESIGN_PLAN.md` — single-window 재설계 (#4)
- `tests/` + `.pre-commit-config.yaml` + `.gitea/workflows/ci.yml` + 본 문서 — 개발 사이클 인프라 (#6/#7/#8)
- `~/.claude/projects/.../memory/feedback_no_ab_pingpong.md` — A/B 무한루프 금지 영구 룰 (#9)

200
CURVE_SMOOTHING_PLAN.md Normal file
View File

@@ -0,0 +1,200 @@
# Curve Smoothing 전략 (Phase 0 — 분석 + 1차 패치)
작성: pyvista-renderer subagent
배경: 사용자 피드백 #2 — "곡선은 float 형태이므로, 매끄럽지 않게 보일 수 밖에 없으니, 매끄럽고 우수한 품질을 위한 고민 필요"
문제: ogee 프로파일·radial gate 스킨·실린더 등 모든 곡선이 polyline/저해상도 디스크리션으로 빌드되어 줌인 시 직선 분절(faceting)로 보임.
---
## 1. 거친 곡선 카탈로그
| 부위 | 파일:라인 | 현재 점/단편 개수 | 시각 품질 (1-5) | 우선순위 |
|---|---|---|---|---|
| **Spillway ogee 프로파일** | `gate_3d_builder.py:78` (`_build_spillway_body`) | DXF 추출 5~50점, 기본값 8점 | **2** (정점에서 꺾임 명확) | **P1** ← 1차 패치 |
| **Radial gate skin (Tainter)** | `gate_3d_builder.py:428` (`_make_radial_skin`, `n_circ=16`) | 17 ribs × 2 = 34점 | 3 (옆면 폴리곤 가시) | P2 |
| **Pier nose (삼각 물가르기)** | `gate_3d_builder.py:307` (`_make_pier_nose`) | 평면 삼각형 1개 | 4 (의도된 sharp edge) | P4 (변경 불필요) |
| **Gate arm (tube)** | `gate_3d_builder.py:480` (`tube(n_sides=8)`) | 8각형 단면 | 3 | P3 |
| **Intake-tower jack/crane 실린더** | `intake_tower_3d_builder.py:191,302` (`Cylinder(resolution=16)`) | 16각형 | 3 | P3 |
| **Valve/handle/flange 실린더** | `valve_chamber_3d_builder.py:200,221,251,265,302,365,378` (`resolution=8~20`) | 8~20각형 | 2~3 (handle 16, flange 16, weep 12, anchor 8 — anchor가 특히 거침) | P2 |
| **Anchor bar (옹벽)** | `retaining_wall_3d_builder.py:212` (`Cylinder(resolution=8)`) | 8각형 (수십~수백 개) | 2 (다수 객체라 누적 거침) | P2 |
| **Weep hole** | `retaining_wall_3d_builder.py:302` (`resolution=12`) | 12각형 | 3 | P3 |
| **Backfill 경사면 (옹벽)** | `retaining_wall_3d_builder.py:_build_backfill` | 8 vertex 박스 | 4 (경사 자체 OK, normal 이슈 없음) | P4 |
| **Polygon reconstructor 외곽선** | `polygon_reconstructor.py` 전반 | 원본 LINE 그대로 | 3 (사용처에 따라 다름) | P3 |
**핵심 인사이트**:
- "곡선이 거침"은 **두 종류**로 나뉜다:
1. **Sweep 단면 곡선** (ogee 프로파일) — 점 개수가 적어 extrude 시 단면 자체가 각짐 → **Spline densify**가 정공법.
2. **회전체 다각형화** (Cylinder, tube) — `resolution=N` 인자로 단순 증가 가능 → **저비용 win**.
- `mesh.smooth_taubin()`은 사후 적용으로 normal을 부드럽게 만들지만, **각진 면 자체를 점 추가로 늘려주지는 않음** (정점 수 동일). 이미 직선인 polyline에는 효과 제한.
- `mesh.subdivide(nsub=2)`는 cell이 4배 증가 — 단순한 box face들에 적용하면 GPU 낭비. **곡면 의심 영역에만 선택 적용**해야.
---
## 2. 권장 smoothing 전략 매트릭스
| 부위 | 전략 | 새 점 개수 | 예상 cell 증가 | 성능 영향 | 1차 패치 포함? |
|---|---|---|---|---|---|
| **Ogee profile** | `scipy.interpolate.CubicSpline` (parametric, x→z 단조 아니므로 arc-length 매개화) | 8~50 → ×4 (32~200) | sweep cell ×4 ≈ +수백 | 무시 가능 | **✓ 1차 적용** |
| Radial gate skin | `n_circ` 16 → 32 또는 CubicSpline ang | 34 → 66 | +30 cell | 무시 | P2 |
| Cylinder (jack, valve body) | `resolution` 16 → 32 | +16 cells/cyl | 객체당 무시 | P3 |
| Anchor bar | `resolution` 8 → 16 (개수 많음 주의) | +8 cells × N개 | 격자 200개면 +1.6K cells | 60 FPS 영향 가능 — **격자 cap 200 유지 필수** | P3 |
| 임의 곡면 메시 (사후) | `mesh.smooth_taubin(n_iter=10, pass_band=0.1)` | 정점 수 유지 | +0 | 무시 (CPU 1회) | P3 |
| Box face (subdivide 불필요) | — | — | — | — | 적용 안 함 |
| 폴리곤 외곽 (chamber, pier) | Chaikin / B-spline 보간 | ×2~×4 | side cells ×2~4 | 가능 (큰 다각형은 100K cell 위험) | P2 — **선택 적용** |
---
## 3. 1차 패치 — Ogee profile spline (gate_3d_builder.py)
### 변경 위치
`_build_spillway_body()` 내부, `closed_pts_2d` 생성 전에 ogee profile (x, z)를 **CubicSpline로 4× 보간**.
### 핵심 함수 추가
```python
def _densify_profile(profile_2d, n_factor=4, n_min=4):
"""(x, z) 프로파일 점을 arc-length parametric CubicSpline로 보간.
이유: ogee는 단조 함수가 아닐 수 있고(상류 옹벽 수직부에서 x=동일이 여러 z),
parametric (s, x), (s, z) 곡선이 안전하다.
n_factor: 출력 점 개수 = max(n_factor * len(profile), n_min)
"""
import numpy as np
from scipy.interpolate import CubicSpline
if profile_2d is None or len(profile_2d) < 4:
return list(profile_2d) if profile_2d else []
pts = np.asarray(profile_2d, dtype=float)
# arc-length 누적 거리(매개변수 s)
diffs = np.diff(pts, axis=0)
seg_len = np.sqrt((diffs ** 2).sum(axis=1))
s = np.concatenate([[0.0], np.cumsum(seg_len)])
if s[-1] <= 1e-9:
return list(profile_2d)
# 정규화 [0, 1]
s_norm = s / s[-1]
cs_x = CubicSpline(s_norm, pts[:, 0], bc_type='natural')
cs_z = CubicSpline(s_norm, pts[:, 1], bc_type='natural')
n_out = max(n_factor * len(profile_2d), n_min)
s_new = np.linspace(0.0, 1.0, n_out)
return list(zip(cs_x(s_new).tolist(), cs_z(s_new).tolist()))
```
### `_build_spillway_body()` 본문 수정
변경 전 `profile = p.ogee_profile` 직후에:
```python
profile = self._densify_profile(p.ogee_profile, n_factor=4)
```
### Diff 형식
```diff
@@ class GateBuilder ... _build_spillway_body
def _build_spillway_body(self):
"""Ogee 프로파일을 span 방향으로 extrude하여 본체 생성."""
p = self.params
- profile = p.ogee_profile
+ # 매끄러운 곡면을 위한 CubicSpline 보간 (사용자 피드백 #2 대응)
+ profile = self._densify_profile(p.ogee_profile, n_factor=4)
if len(profile) < 3:
return
@@
+ @staticmethod
+ def _densify_profile(profile_2d, n_factor: int = 4, n_min: int = 4):
+ """(x, z) 프로파일 점을 arc-length parametric CubicSpline로 보간.
+ ogee는 단조 함수가 아닐 수 있어 parametric (s, x), (s, z) 곡선 사용.
+ """
+ if profile_2d is None or len(profile_2d) < 4:
+ return list(profile_2d) if profile_2d else []
+ try:
+ from scipy.interpolate import CubicSpline
+ except Exception:
+ return list(profile_2d)
+ pts = np.asarray(profile_2d, dtype=float)
+ diffs = np.diff(pts, axis=0)
+ seg_len = np.sqrt((diffs ** 2).sum(axis=1))
+ s = np.concatenate([[0.0], np.cumsum(seg_len)])
+ if s[-1] <= 1e-9:
+ return list(profile_2d)
+ s_norm = s / s[-1]
+ cs_x = CubicSpline(s_norm, pts[:, 0], bc_type='natural')
+ cs_z = CubicSpline(s_norm, pts[:, 1], bc_type='natural')
+ n_out = max(n_factor * len(profile_2d), n_min)
+ s_new = np.linspace(0.0, 1.0, n_out)
+ return list(zip(cs_x(s_new).tolist(), cs_z(s_new).tolist()))
```
### Cell 수 영향 추정
- 기본 ogee: 8점 → 보간 후 32점 → closed_pts_2d = 34점 (앞·뒤 바닥 각 1개 추가)
- prism cells = 측면 strip 34×2 + 양 끝 fan (32+32) = 68 + 64 = 132 cells
- 변경 전: 8점 → 10점 → 20 + 16 = 36 cells
- **+96 cells 증가**, 단일 메시 100K 임계 대비 미미함.
DXF에서 50점 추출된 경우: 200점 보간 → cells ≈ 800 — 여전히 안전.
---
## 4. 적용 후 검증 방법
1. **Compile + Import 테스트**
```sh
python -m py_compile gate_3d_builder.py
python -c "from gate_3d_builder import GateBuilder; print('ok')"
```
2. **메쉬 빌드 smoke test** (옵션)
```sh
python gate_3d_builder.py # __main__ block에 이미 있음
```
`meshes[0]` (spillway body)의 `n_points`, `n_cells`가 변경 전 대비 약 4배 증가 확인.
3. **시각 비교** (사용자 측에서)
- `pl.add_mesh(spillway_body, show_edges=True)` 로 wireframe 비교.
- Ogee 정점 부근(crest)에서 직선 분절이 곡선으로 부드럽게 변경되었는지 확인.
- capture_image PNG로 before/after 저장 → AI 입력에서도 차이 확인.
4. **60 FPS 게이트** (performance-guardian 확인)
- 단일 spillway body cells < 1K 유지: P1 OK.
- 전체 scene 실시간 회전 시 FPS 측정 (PyVista plotter `pl.add_text(f"FPS: {pl.iren.get_event_observer_count()}")`).
---
## 5. P2/P3 후보 (다음 라운드)
### P2 — Radial gate skin (`_make_radial_skin`)
- `n_circ = 16 → 32` (1줄 변경)
- Cell 수 +32 — 무시.
- 또는 CubicSpline로 ang_sill ↔ ang_top 사이 36점 균등.
### P2 — Polygon reconstructor 결과 (chamber, pier polygon)
- `polygon_reconstructor.py`에서 반환된 polygon points를 빌더가 사용하기 전에 **Chaikin 알고리즘** 1~2회 적용해 corner를 라운딩.
- 단, sanity check (`_validate_pier_polys`)와 호환 유지 (bbox 기반이므로 OK).
- **주의**: 직각 corner를 의도한 pier에는 적용 부적절. layer 이름이나 geometry hint로 곡면 의심 폴리곤만 선별.
### P3 — Cylinder resolution 일괄 상향
- `gate_3d_builder.py:tube(n_sides=8)` → 16
- `intake_tower_3d_builder.py:Cylinder(resolution=16)` → 24
- `valve_chamber_3d_builder.py:Cylinder(resolution=8~20)` → 16~32
- `retaining_wall_3d_builder.py:Cylinder(resolution=8)` → 12 (격자 200개라 cell 폭증 우려; 8 → 12면 +800 cells, 안전)
### P3 — `pl.add_mesh(..., smooth_shading=True)`
- VTK side에서 normal averaging만 켜면 무료로 면이 매끄러워 보임. 정점 수 변화 없음.
- 단, 박스(직각 corner)에 적용 시 corner가 둥글어 보여 이상해짐 → **곡면 mesh에만 선별 적용**.
### P3 — `mesh.smooth_taubin(n_iter=10, pass_band=0.1)` post-build
- ogee/radial skin 등에 추가 적용 가능. CPU에서 1회 — 인터랙션 무관.
### P4 — NURBS-level (장기)
- 수문 trunnion radial sweep을 진짜 parametric surface로 정의 → VTK `vtkParametricSpline`.
- 효과 크지만 빌더 구조 변경 큼. Phase 1+에서 검토.
---
## Appendix: 왜 ogee를 1차 적용 대상으로?
- 사용자 줌인 시 **가장 먼저 보는 부분이 spillway 본체 단면** (ogee 곡선이 spillway의 시그니처 형상).
- DXF 파서가 추출하는 점 수 자체가 적은 케이스(8~20점)가 흔함 → **직선 분절이 가장 명확히 보임**.
- 위험 낮음: extrude 함수(`_extrude_2d_profile`, `_triangulate_prism`) 그대로, 입력 점 수만 늘어남.
- 효과 즉각: 다음 빌드 결과부터 곡면이 부드러워짐.

397
PERFORMANCE_BASELINE.md Normal file
View File

@@ -0,0 +1,397 @@
# 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. 패치 후 동일 시나리오 재측정 → 회귀 확인.

374
UI_REDESIGN_PLAN.md Normal file
View File

@@ -0,0 +1,374 @@
# S-CANVAS UI/UX 전면 재설계 (Phase 0 — 디자인)
> **사용자 피드백 #4 인용**
> "느리고, 조작이 어렵게 느껴지므로, UI/UX를 전면 수정할 필요가 있음(기존 구조에 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용)"
>
> 요지: (1) 인라인 로그 패널 제거 → 백엔드 파일 (2) 단계마다 새 팝업창 → 단일 창 인스펙터.
본 문서는 **Phase 0(읽기 전용 분석 + 청사진)**. 이번 라운드에서는 코드 한 줄도 수정하지 않는다.
---
## 1. 현재 UI 진단
### 1.1 메인 셸 구조 (`scanvas_maker.py`)
- **클래스**: `SCanvasApp(ctk.CTk)` — 단일 창. line 160.
- **창 크기**: 1200x900, light theme, blue color theme. line 165167.
- **메인 레이아웃**: 2-column grid.
- **Left (col 0)**: `sidebar_container` (270px 고정) + `sidebar_frame` (CTkScrollableFrame 250px). line 274283.
- 섹션: 로고 → SETTINGS (위성 소스/Vworld 키/AI 엔진/GCP/Vertex Loc/CRS) → WORKFLOW (Step1, 1.5, 2, 3, 4, 구조물 빌드, 상세 치수, 3D 다시 열기) → OPTIONS (와이어프레임, 뷰 버퍼 %, DEM 확장 m, 테마) → SAMAN 푸터.
- **Right (col 1)**: `main_frame` (corner_radius=15, transparent). line 558562.
- row 0 weight=3 → `map_frame` (TkinterMapView, 위성 지도 미리보기).
- row 1 weight=1 → `textbox` (CTkTextbox, **height=120, 인라인 로그 패널**). line 582583.
- row 2 → `status_bar` (28px, ● READY 인디케이터 + status_text). line 586593.
### 1.2 인라인 로그 (제거 대상)
- 표면화 위치: `main_frame.row=1`, `self.textbox` 한 위젯.
- 호출지점: `self.log(message)`**180회**. line 691696.
- 동작: `datetime` timestamp prefix → `textbox.insert("end", ...)` → auto-scroll.
- 별도로 `self._diag(...)` (구조물 분류 진단)와 `harness.logger.setup_logging(log_file=harness_log_path())`는 **이미 백엔드 파일**로 흘러가고 있음 (line 228, 698710). 즉 인프라 절반은 이미 존재.
- 결론: `harness_log_path()` (`%LOCALAPPDATA%\S-CANVAS\scanvas_harness.log`) 표준에 `self.log()`도 포함시키면 됨. 새 파일 만들 필요 없음.
### 1.3 팝업 다이얼로그 카탈로그 (제거/이식 대상)
**`CTkToplevel(...)` 12개** + **`messagebox.*` 63회** + **`filedialog.*` 다수**.
| # | 위치 (line) | 트리거 | 제목 | 크기 | 이식 우선순위 |
|---|---|---|---|---|---|
| T1 | 766 | Step 1 DXF 로드 후 자동 | `DXF 레이어 분류` | 900×650 | **HIGH** — 첫 인상 |
| T2 | 1419 | 사이드바 `구조물 상세 3D 빌드` | `구조물 상세 3D 빌드 (템플릿)` | 1100×650 | HIGH |
| T3 | 1596 | T2 내부 `상세 빌드` 버튼 | 빌드 진행 다이얼로그 | — | MED |
| T4 | 1889 | T3 내부 옵션 | `렌더 옵션` (서브) | — | LOW (T3 흡수) |
| T5 | 2044 | T3 내부 VLM 결과 | AI 검증 결과 | — | LOW (인라인 토스트) |
| T6 | 2366 | 사이드바 `간단 치수 추가` | 상세도면 업로드 | — | MED |
| T7 | 2486 | T6 후속 | 치수 확인/편집 | 650×500 | MED |
| T8 | 2723 | Step 1 후속 | `계획선 고도 설정` | 1280×560 | HIGH — 워크플로 핵심 |
| T9 | 4624 | 사이드바 `🎯 TIN 이용 범위` | TIN core 선택 (matplotlib 내장) | 1100×920 | **CRITICAL** — interactive canvas |
| T10 | 6537 | Step 4 시작 | `렌더링 옵션` (시간대/화질) | 380×360 | HIGH |
| T11 | 6897 | Blender 렌더 결과 | 결과 이미지 뷰어 | 동적 | MED |
| T12 | 6970 | AI 렌더 결과 | 결과 이미지 뷰어 | 동적 | MED |
**`messagebox` 63회 분포**: 거의 모두 (a) 모듈 미설치 경고, (b) 전제조건 안내("먼저 Step N을 수행하세요"), (c) 최종 완료/실패 메시지. 진짜 **위험한 결정**(askyesno) 은 line 2191/2213/2223/2238 (구조물 빌드 시 덮어쓰기 확인) — 4건.
### 1.4 PyVista 3D 뷰포트 — 가장 큰 함정
**현재**: `pv.Plotter(title=...).show()` 를 호출 → **별도 OS 윈도우** 가 뜸 (VTK render window). line 1769, 5463, 5567, 5888, 6228 등 6개 호출지.
- 메인 CustomTkinter 창과 **물리적으로 다른 창**. 사용자가 "한 화면에서" 보길 원하는 핵심 위반.
- `pyvistaqt`/`QtInteractor` 임포트는 코드베이스에 **없음** (전체 검색 0건).
- Tk + VTK 임베딩 표준 경로: `vtkmodules.tk.vtkTkRenderWidget` (Python 3.13 + PyVista 0.43+ 환경에서 동작 확인 필요) **OR** PyQt 의존성 추가 + `pyvistaqt.QtInteractor`.
### 1.5 사용자 워크플로 막힘 지점 (5분 룰 위반)
1. **첫 화면이 위성지도** — 사용자는 DXF 작업이 목적인데 첫 화면에 빈 한국 지도가 떠 있음. 다음 단계가 사이드바 `1. TIN 생성 (DXF)` 임이 아이콘/색으로 강조되지 않음.
2. **각 Step 후 새 창 강제** — Step 1 → T1 팝업(레이어 분류) → T8 팝업(고도 설정) → 닫힘 → 사이드바 클릭 → Step 2 → 위성 지도 갱신 → Step 3 → T9-likely 팝업 → Step 4 → T10 팝업. **창 45개를 ALT-TAB** 으로 오가야 한다.
3. **3D 뷰가 별도 창** — 위성지도(메인)와 3D(VTK 별창)가 분리. 사용자는 두 창 사이에서 컨텍스트 잃음.
4. **로그 패널 차지 면적** — 메인 영역 25% (`main_frame` row 1 weight=1, weight=3 vs 1) 가 로그. 사용자가 보지도 않는 텍스트가 차지.
5. **상태바가 정보 부족**`● READY` + 짧은 텍스트 1줄. 진행률 게이지 없음 (단순 set_status 호출).
6. **에러 = messagebox 63회** — 매 실패마다 모달 창. 사용자 흐름 차단.
---
## 2. 새 레이아웃 (text mockup)
### 2.1 메인 셸 (단일 창)
```
┌──────────────────────────────────────────────────────────────────────┐
│ S-CANVAS ─ □ ✕ │
├────────┬─────────────────────────────────────────┬───────────────────┤
│ Sidebar│ Main Canvas │ Inspector │
│ (240) │ (flex, weight=1) │ (340) │
│ │ │ │
│ ┌────┐ │ ┌───────────────────────────────────┐ │ ┌───────────────┐ │
│ │ S │ │ │ │ │ │ Step 1: DXF │ │
│ │CANVAS│ │ │ │ │ │ 로드 │ │
│ └────┘ │ │ [PyVista 3D Viewport] │ │ ├───────────────┤ │
│ │ │ (or fallback │ │ │ │ │
│ Pipeline│ │ TkinterMapView when no │ │ │ DXF 파일: │ │
│ │ │ TIN yet) │ │ │ [filepath...] │ │
│ ① DXF │ │ │ │ │ [📂 찾아보기] │ │
│ ② GeoR │ │ │ │ │ │ │
│ ③ TIN │ │ │ │ │ CRS: ▼ EPSG.. │ │
│ ④ Strct│ │ │ │ │ │ │
│ ⑤ Rndr │ │ │ │ │ ┌──────────┐ │ │
│ │ └───────────────────────────────────┘ │ │ │ 시작 → │ │ │
│ ── │ ┌─Tab strip──────────────────────────┐ │ │ └──────────┘ │ │
│ Settings│ │ [3D] [위성지도] [DXF 미리보기] │ │ │ │ │
│ • Theme│ └───────────────────────────────────┘ │ │ ── 도움말 ─── │ │
│ • API │ │ │ Step 1은 ... │ │
│ • CRS │ │ └───────────────┘ │
├────────┴─────────────────────────────────────────┴───────────────────┤
│ ● Ready Step 1/5: DXF 로드 대기 [▮▮▮▮▮▯▯▯ 50%] [📋 log] │
└──────────────────────────────────────────────────────────────────────┘
```
### 2.2 영역 정의
| 영역 | 폭 | 책임 | 구현 위젯 |
|---|---|---|---|
| **Sidebar** (left) | 240px 고정 | 네비게이션 (5단계 rail) + 글로벌 settings 토글 | `CTkScrollableFrame` 분리: 위 = pipeline rail, 아래 = settings (collapsed by default) |
| **Main Canvas** (center) | flex | 항상 켜진 3D 뷰포트 + 보조 탭 (지도/DXF) | top: VTK 임베디드 위젯, bottom: 탭 strip |
| **Inspector** (right) | 340px | **활성 step 의 폼**. step 바뀌면 내용만 swap (창 X) | `CTkFrame` + step 별 `_build_inspector_step{N}()` 메서드 |
| **Status Bar** (bottom) | 32px | ● 인디케이터 + 현재 단계 + 진행률 + log 버튼 | 기존 `status_bar` 확장 |
### 2.3 사이드바 — 5단계 rail (텍스트만)
```
PIPELINE
─────────
✓ ① DXF 로드 ← 완료 (체크, 녹)
▶ ② GeoRef ← 활성 (▶, 청록 강조)
○ ③ TIN + DEM ← 미진행 (회색)
○ ④ Structures
○ ⑤ Render (AI)
─────────
SETTINGS ▼ (collapsible)
• API Key
• CRS
• Theme
─────────
SAMAN © Footer
```
- 클릭 시: inspector 내용을 해당 step 폼으로 swap. 창 안 띄움.
- 진행 상태 아이콘: ✓ (완료) / ▶ (활성) / ○ (대기) / ✕ (실패) — 일관된 4가지.
### 2.4 Inspector — step 별 폼
각 step 의 inspector 는 다음 표준을 따른다 (5분 룰 강제):
```
┌───────────────────────┐
│ ① DXF 로드 │ ← h1 (18pt bold)
├───────────────────────┤
│ │
│ [현재 step 의 입력] │ ← 결정 ≤ 3개
│ │
│ ┌─ 시작 → ──────┐ │ ← primary button (#16A085, height=40)
│ └────────────────┘ │
│ │
│ ── 도움말 ── │ ← caption (10pt gray)
│ "DXF 파일을 골라 │
│ CRS 를 확인하세요" │
└───────────────────────┘
```
**크리티컬 케이스**: step 9 (TIN core 선택) 의 matplotlib interactive canvas — 이건 inspector 가 아니라 **메인 canvas 영역에 임시 오버레이** 로 띄워야 함 (창 신설 금지). PyVista 뷰포트 위에 matplotlib FigureCanvasTkAgg 를 잠시 덮고, 확정 시 다시 PyVista 로 복귀.
### 2.5 Status Bar — 미니 로그 인디케이터
```
● Running Step 3/5: 제어맵 추출 중 [▮▮▮▮▮▮▯▯ 70%] 📋 log (3 new)
```
- ● 색: ready=녹(#2ECC71), running=청(#3498DB), warn=주황(#D35400), error=빨(#E74C3C).
- 진행률: 8칸 ASCII bar, 단계별 percent.
- `📋 log (N new)` 버튼 클릭 → **log drawer** (창 아님, 메인 canvas 위로 슬라이드 다운, 350px) 열림. 닫기 ✕ 로 즉시 사라짐. 내용은 `harness_log_path()` 의 tail 200줄.
---
## 3. 컴포넌트 매핑 (현재 → 신규)
| 현재 (popup) | 신규 (single-window) | 처리 방식 |
|---|---|---|
| T1 `DXF 레이어 분류` (900×650) | Inspector — Step 1 의 sub-form (스크롤) | 탭 또는 expandable section |
| T2 `구조물 상세 3D 빌드` (1100×650) | Inspector — Step 4 main form | sidebar `④ Structures` 클릭 시 swap |
| T3 빌드 다이얼로그 | Inspector 내부 진행 영역 + status bar 진행률 | inline progress |
| T4 옵션 서브창 | T3 폼의 expandable "고급" 섹션 | 같은 inspector 안 |
| T5 AI 검증 결과 | **inline toast** (canvas 우상단 8s) | 비모달 |
| T6 상세도면 업로드 | Step 4 sub-form 내 파일 슬롯 | Drag&drop or browse |
| T7 치수 확인 (650×500) | Step 4 sub-form 내 confirm panel | 같은 inspector 폼 안 |
| T8 `계획선 고도 설정` (1280×560) | Inspector — Step 3 sub-form (Step 2.5 스타일) | scrollable form |
| T9 TIN core 선택 (interactive) | **Main canvas overlay** (matplotlib) | 임시 mode swap, ESC=취소 |
| T10 렌더링 옵션 (380×360) | Inspector — Step 5 main form | sidebar `⑤ Render` 클릭 |
| T11/T12 결과 이미지 뷰어 | Main canvas 의 신규 탭 `[결과]` | tab swap, 우측에 메타 |
| 현재 (messagebox) | 신규 |
|---|---|
| `showerror` (≈30회) | inline toast (빨, 8s) + status bar 빨 |
| `showwarning` (≈18회) | inline toast (주황, 6s) |
| `showinfo` (≈12회) | inline toast (청, 4s) |
| `askyesno` 4건 (덮어쓰기 확인) | **유지** (모달, 진짜 데이터 손실 위험 시만) |
| 현재 (log) | 신규 |
|---|---|
| `self.log()` 180회 → CTkTextbox | `self.log()` API 유지 + 내부에서 `logging.getLogger("scanvas").info(...)` 로 routing → `harness_log_path()` |
| `main_frame` row 1 (CTkTextbox) | **삭제**. 그 공간을 main canvas 가 차지 |
| status bar 의 `📋 log` 버튼 | log drawer (slide-down panel) — 개발자만 클릭 |
---
## 4. 마이그레이션 단계 (4 phase)
### Phase A — Backend log 일원화 (1 session, 코드 ≈ 60 lines)
- **목표**: `self.log()` 가 textbox 가 아니라 logger 로 흐르게. UI 구조는 그대로.
- **변경**:
1. `self.log(msg)` 내부 → `logging.getLogger("scanvas").info(msg)` 호출 추가 (textbox 도 유지 — 안전망).
2. `setup_logging(log_file=harness_log_path())` 가 이미 line 228 에 있음. 이걸 `HARNESS_AVAILABLE=False` 분기에서도 fallback 으로 작동하게 보강.
3. status bar 의 `● READY` 옆에 `📋 log` 버튼 prototype 추가 (클릭 → 별도 창으로 textbox 띄움 — Phase D 에서 drawer 로 교체).
- **위험**: 거의 없음. log drawer 가 Phase D 에 있어서 백엔드 라우팅만 먼저.
- **결과 게이트**: `harness_log_path()` 에 모든 메시지가 쌓이는지 grep 으로 확인.
### Phase B — Single-window shell (2 sessions, 코드 ≈ 350 lines)
- **목표**: 3-column grid (sidebar | main canvas | inspector) + status bar 골격. 기능은 placeholder.
- **변경**:
1. `__init__` 의 grid 를 `grid_columnconfigure(0, weight=0); (1, weight=1); (2, weight=0)` 3-column 으로.
2. 신규 `self.inspector_frame` (340px, 우측). 현재 사이드바 의 WORKFLOW 섹션을 inspector 의 step1 placeholder 로 옮김.
3. 사이드바를 **rail** 모드로 단순화: 5단계 rail + collapsible SETTINGS.
4. `main_frame` 에서 textbox 삭제 → 그 자리에 PyVista placeholder (지금은 빈 frame, Phase C 에서 채움).
5. `_show_inspector(step_id)` 메서드 추가: step_id 로 해당 폼 swap.
- **위험**: window resize 깨짐 — `grid_rowconfigure(0, weight=1)``weight=0` 분리 정확히 해야. inspector 폭 340 고정 + sidebar 240 고정 + canvas weight=1.
- **결과 게이트**: 빈 inspector 가 step 클릭에 따라 라벨만 바꾸는 데모. 기능 미작동.
### Phase C — Step inspector forms 이식 (3 sessions, 코드 ≈ 800 lines)
- **목표**: T1, T2, T8, T10 (HIGH 4개) 의 내용을 inspector 폼으로 옮김. 팝업은 아직 코드에 남겨둠 (병행 동작).
- **변경**:
1. **Step 1 inspector** ← T1 `DXF 레이어 분류` 의 scroll_frame + dropdown 을 inspector 폼으로 복제.
2. **Step 3 inspector** ← T8 `계획선 고도 설정` 의 10-column scroll 을 inspector 폼으로. 폭이 좁으니 (340 < 1280) 한 row 당 2-line 으로 wrap.
3. **Step 4 inspector** ← T2 `구조물 상세 3D 빌드` 의 row-per-structure 를 inspector accordion 으로.
4. **Step 5 inspector** ← T10 `렌더링 옵션` (시간대/화질).
5. inline toast 위젯 (CTkFrame 우상단 절대 배치, after(N, destroy)) — messagebox 대체용.
- **위험**: T8 의 10-column 이 340px 에 안 들어감 → row 별 expandable section 으로 디자인 변경 필요. T9 (interactive matplotlib) 는 Phase D 까지 팝업 유지.
- **결과 게이트**: 사이드바 5단계 모두 클릭 가능 + inspector 가 step 마다 정확한 폼 표시 + Step 1~5 회귀 테스트 통과.
### Phase D — 팝업 제거 + log drawer + 회귀 (1 session, 코드 ≈ 200 lines)
- **목표**: 모든 `CTkToplevel(...)` 12개 제거 (T9 main canvas overlay 로 교체) + log drawer 완성.
- **변경**:
1. T1, T2, T8, T10 의 `CTkToplevel` 호출 코드 삭제 (Phase C 에서 inspector 가 이미 동작 중).
2. T3, T4, T5, T6, T7 의 호출지 → inspector 의 sub-state 로 바뀜.
3. T9 → main canvas 위에 matplotlib FigureCanvasTkAgg 를 띄우고 ESC/확정 시 사라짐. PyVista 모드 ↔ matplotlib 모드 swap.
4. T11, T12 → main canvas 의 신규 `[결과]` 탭 으로.
5. log drawer: status bar `📋 log` 클릭 → 메인 canvas 위에 slide-down (높이 350) panel + 닫기 ✕.
6. messagebox 63회 → inline toast (showerror/warning/info) + askyesno 4건만 유지.
- **위험**: T9 의 matplotlib pick event 가 main canvas 에서 동작 안 할 수 있음 — pyvista 와 matplotlib 가 같은 Tk frame 안에서 mode swap 시 grab 충돌.
- **결과 게이트**: `Grep CTkToplevel` 결과 0개, `Grep "messagebox.show"` 결과 4개 미만 (askyesno 만), full Step 1~5 시나리오 manual 테스트.
### 누적 일정
- Phase A: 1 session
- Phase B: 2 sessions
- Phase C: 3 sessions
- Phase D: 1 session
- **총 7 sessions**.
---
## 5. 5축 / Cities / Workflow 영향
### 5.1 5축 비전 영향
- **축 1 (AI 물리 조감도)**: ✅ 영향 중립. AI 렌더 트랙(D003) 손대지 않음 — Step 5 inspector 는 기존 T10 폼의 lift-and-shift.
- **축 2 (Cities-Skylines like, 5분 룰)**: ✅✅ 강한 개선. 단일 창에서 사이드바 → inspector → canvas 가 한눈에. 새 사용자가 ALT-TAB 안 해도 됨.
- **축 3 (심미성)**: ✅ 일관성 향상. 12개 팝업의 제각각 폰트/패딩이 1개 inspector 표준으로 통일. 8px 그리드 강제.
- **축 4 (라이브러리)**: ⚠️ 중립. STRUCTURE_REGISTRY 패턴 유지. 단, inspector 폼이 새 구조물 타입 추가 시 자동 확장돼야 함 (data-driven, 코드 수정 X).
- **축 5 (성능)**: ✅ 팝업 생성/파괴 오버헤드 제거 (window create/destroy 는 비용). 단, **PyVista 임베딩이 Tk 에서 60FPS 유지될지 검증 필요** — 이게 Phase B 의 게이트.
### 5.2 두 워크플로 호환
- **Workflow A (Engineering, DXF 기반)**: ✅ Step 1~5 이 그대로 사이드바 rail. 기존 사용자 학습곡선 단발성.
- **Workflow B (Cities, 도면 없이)**: ✅ `cities_placement_widget.py` 가 별도 위젯 — Step 1 inspector 의 toggle 또는 사이드바 별도 모드 버튼으로 attach. 메인 canvas 공유 가능.
---
## 6. 위험 / 트레이드오프
| 위험 | 영향 | 완화 |
|---|---|---|
| **PyVista Tk 임베딩 미검증** | 치명. Phase B 의존. | Phase B 진입 전 5-line 프로토타입으로 `vtkTkRenderWidget` 동작 확인. 실패 시 Plan B = 메인 canvas 는 BackgroundPlotter 별창 유지하되 status bar 위에 thumbnail 동기화. |
| **사용자 학습 곡선** | 중. 12개 팝업 다 사라짐 → "어디로 갔지?" | 첫 실행 시 5초 onboarding tooltip ("기존 팝업이 우측 inspector 로 이동했습니다"). |
| **window resize 깨짐** | 중. CustomTkinter 약점. | `grid_columnconfigure(1, weight=1)` 만 flex, 0/2 는 fixed. 최소 폭 900px (사이드바 240+canvas 320+inspector 340). |
| **백엔드 log 의 디버깅 불편** | 저. inline log 가 사라져 개발자 불편. | log drawer (status bar 버튼 1클릭, 350px) 가 즉시 표시. 파일 위치 status bar 우클릭 메뉴에 노출. |
| **T9 matplotlib mode swap 복잡도** | 중. PyVista ↔ matplotlib 같은 frame 안 swap. | Phase D 까지 T9 만 팝업 유지하는 안전 fallback. 사용자에게 "1회만 별창" 양해. |
| **Phase B-C 사이 코드 중복** | 저. 팝업 + inspector 병존. | Phase D 끝에 일괄 삭제. 중간 git tag 유지. |
---
## 7. 디자인 토큰 (ux-designer.md 기준 강제)
```python
# 색상 (현재 사용 중 + 신규 0개)
COLOR_PRIMARY = "#16A085" # rail 활성, primary button
COLOR_PRIMARY_HOVER = "#117A65"
COLOR_SUCCESS = "#2ECC71" # ● ready, ✓ 완료
COLOR_INFO = "#3498DB" # ● running
COLOR_WARN = "#D35400" # toast warning
COLOR_DANGER = "#E74C3C" # ● error, ✕ 실패
COLOR_NEUTRAL_LIGHT = "#F4F6F7" # canvas bg (light)
COLOR_NEUTRAL_MID = "#95A5A6" # caption text
COLOR_NEUTRAL_DARK = "#2C3E50" # body text
# 폰트 (5단계만)
FONT_H1 = ctk.CTkFont(size=18, weight="bold") # inspector 제목
FONT_H2 = ctk.CTkFont(size=14, weight="bold") # inspector 섹션 헤더, sidebar PIPELINE 헤더
FONT_BODY = ctk.CTkFont(size=12) # form 라벨, status text
FONT_BUTTON = ctk.CTkFont(size=11, weight="bold") # buttons
FONT_MICRO = ctk.CTkFont(size=10) # caption, log timestamp
# 8px 그리드
PAD_XS = 8
PAD_SM = 16
PAD_MD = 24
PAD_LG = 32
# 폭/높이
SIDEBAR_W = 240
INSPECTOR_W = 340
STATUS_BAR_H = 32
LOG_DRAWER_H = 350
MAIN_MIN_W = 320 # window 최소 폭 = 240+320+340 = 900
# CustomTkinter 한계 인지 → 하지 말 것
# (1) 부드러운 애니메이션 (slide-down log drawer 는 즉각 show/hide)
# (2) 그라디언트
# (3) drop shadow (border 색 변화로 흉내)
```
---
## 8. 영향 받는 파일 (Phase 별 예상)
### Phase A (백엔드 log)
- `D:\2026\PROGRAM\1_S-CANVAS\scanvas_maker.py` (line 691696, 228, 595)
- `D:\2026\PROGRAM\1_S-CANVAS\harness\logger.py` (이미 작성됨, 변경 없음 검증만)
### Phase B (single-window shell)
- `D:\2026\PROGRAM\1_S-CANVAS\scanvas_maker.py` (line 268600 layout 전면)
- 신규 메서드 `_build_sidebar_rail()`, `_build_main_canvas()`, `_build_inspector()`, `_build_status_bar()`, `_show_inspector(step_id)`
- PyVista 임베딩 프로토타입 (별도 5-line 파일에서 검증 후 본 코드 통합)
### Phase C (inspector forms)
- `D:\2026\PROGRAM\1_S-CANVAS\scanvas_maker.py` (T1/T2/T8/T10 본문을 `_inspector_step{N}` 로 이전)
- 신규: `_inline_toast(level, msg)` 헬퍼 (≈ 30 lines)
### Phase D (팝업 제거)
- `D:\2026\PROGRAM\1_S-CANVAS\scanvas_maker.py` (line 766, 1419, 1596, 1889, 2044, 2366, 2486, 2723, 4624, 6537, 6897, 6970 의 `CTkToplevel(self)` 12 개소 정리)
- 신규: `_log_drawer.py` (slide-down panel) — 또는 inline class
### 무영향 (보호 영역)
- `harness/`, `gemini_renderer.py`, `blender_renderer.py` — AI 렌더 백엔드 (D003)
- `dem_extender.py`, `geo_referencing.py`, `dxf_geometry.py` — 데이터 레이어
- `structure_templates.py`, `structure_placement.py` — 라이브러리 (축 4)
- `cities_placement_widget.py` — Workflow B (별도 트랙)
---
## 9. 결정 게이트 (사용자/검수자 확인 요청 항목)
다음은 **사용자 결정** 이 필요한 사항. 코드 작성 전 확정:
1. **PyVista 임베딩**: `vtkmodules.tk.vtkTkRenderWidget` (Tk 네이티브, 외부 의존성 없음) vs `pyvistaqt.QtInteractor` (Qt 의존성 추가) — 어느 쪽?
- 권장: **vtkTkRenderWidget** (D001 결정 정신: 외부 의존성 최소).
2. **inspector 폭 340px**: 1280×560 폭의 T8 폼이 row 당 2-line wrap 으로 들어가는 게 OK 인가, 아니면 inspector 폭을 480px 까지 늘릴 것인가?
3. **사이드바 SETTINGS 접기**: 기본 collapsed 인가 expanded 인가? (5분 룰 = collapsed 권장 — 첫 사용자에게 옵션 폭격 X)
4. **Workflow B 통합**: cities_placement_widget 을 사이드바 별도 toggle 로 둘 건지, 아니면 Step 1 inspector 의 sub-tab 인지?
---
## 10. 결론
**디자인 권장**: 진행 가능. 7 sessions 분량.
**핵심 가치 제안**:
- 팝업 12개 → inspector 1개 (사용자가 보는 창의 수: 5+ → 1).
- 인라인 로그 패널 차지 면적 (메인 25%) → log drawer (필요 시만, 0%).
- messagebox 63회 → inline toast (비모달, 흐름 끊김 X).
**가장 큰 위험**: PyVista 임베딩의 기술적 미검증. Phase B 의 게이트로 5-line 프로토타입 먼저.
**가장 큰 보상**: 5분 룰 합격. 사용자가 ALT-TAB 없이 한 화면에서 워크플로 완주.
— Phase 0 끝. 구현은 사용자 GO 사인 후 Phase A 부터.

141
UV_GUIDE.md Normal file
View File

@@ -0,0 +1,141 @@
# uv 사용 가이드 — S-CANVAS
> **피드백 반영 (#5)**: pip 대신 uv 권장.
> uv = Rust로 작성된 ultra-fast Python 패키지 매니저. pip의 10~100배 빠름.
## 1. uv 설치 (Windows)
PowerShell:
```powershell
irm https://astral.sh/uv/install.ps1 | iex
```
또는 (winget이 가능한 경우):
```powershell
winget install --id=astral-sh.uv -e
```
또는 pip로:
```powershell
pip install uv
```
설치 후 새 PowerShell 열고 확인:
```powershell
uv --version
```
## 2. 프로젝트 초기화
S-CANVAS 디렉토리에서:
```powershell
cd D:\2026\PROGRAM\1_S-CANVAS
# 기본 (Py3.9~3.12) 환경 만들기
uv venv .venv --python 3.11
.\.venv\Scripts\activate
uv pip install -e .
# 또는 Python 3.13 환경
uv venv .venv313 --python 3.13
.\.venv313\Scripts\activate
uv pip install -e ".[py313]"
# 개발 도구 함께 설치
uv pip install -e ".[py313,dev]"
```
## 3. uv lock — 재현 가능 환경
```powershell
uv lock # uv.lock 생성/갱신 (의존성 트리 freeze)
uv sync # uv.lock 기준으로 환경 동기화 (== install)
uv sync --frozen # lock 변경 없이만 동기화 (CI 권장)
```
`uv.lock`은 git에 커밋 (다른 머신에서 동일 환경 재현).
## 4. 자주 쓰는 명령
| 작업 | uv 명령 | pip 등가 |
|---|---|---|
| 패키지 추가 | `uv pip install foo` | `pip install foo` |
| dev 패키지 추가 | `uv pip install -e ".[dev]"` | `pip install -e ".[dev]"` |
| 환경 동기화 | `uv sync` | `pip install -r requirements.txt` |
| 패키지 제거 | `uv pip uninstall foo` | `pip uninstall foo` |
| 의존성 트리 보기 | `uv pip tree` | `pip list` (트리는 pipdeptree) |
| 캐시 비우기 | `uv cache clean` | (없음) |
## 5. 기존 pip 환경에서 마이그레이션
기존 `venv313/`을 두고 새 `.venv313/`을 만들어 비교:
```powershell
uv venv .venv313 --python 3.13
.\.venv313\Scripts\activate
uv pip install -e ".[py313,dev]"
# import smoke test
python -c "import scanvas_maker; print('OK')"
```
문제 없으면 기존 `venv313/` 삭제. uv 환경이 더 빠르게 만들어짐 (수 초 vs 수 분).
## 6. CI/CD에서 uv 사용
Gitea Actions / GitHub Actions:
```yaml
- uses: astral-sh/setup-uv@v3
- run: uv sync --frozen --extra dev
- run: uv run pytest
- run: uv run ruff check
```
## 7. 기존 `requirements.txt` / `requirements-py313.txt` 호환
`pyproject.toml`이 정식 dependency 선언. 기존 requirements 파일들은:
- `requirements.txt`: 빌드 머신 핀 보존용 (PyInstaller .exe 재현)
- `requirements-py313.txt`: iter=3에서 만든 호환 핀 (현재는 `[py313]` extras에 흡수됨)
새 작업은 모두 `pyproject.toml` 수정. requirements.txt는 deprecated.
## 8. 트러블슈팅
### `uv pip install -e .` 실패: "build backend 'hatchling' not found"
```powershell
uv pip install hatchling
uv pip install -e .
```
### Python 3.13 wheel 못 찾음
일부 패키지(예: `rasterio`)는 3.13 wheel이 늦게 나올 수 있음. `[py313]` extras로 핀 완화.
### 기존 venv 충돌
```powershell
# 새 디렉토리로 venv 만들기
uv venv .venv-fresh --python 3.13
```
## 9. 사용자가 자주 하는 작업 (cheat sheet)
```powershell
# 처음 받은 직후
cd D:\2026\PROGRAM\1_S-CANVAS
uv venv .venv313 --python 3.13
.\.venv313\Scripts\activate
uv pip install -e ".[py313,dev]"
# 새 패키지 추가 시
uv pip install <package>
# pyproject.toml의 dependencies에도 수동 추가 후
uv lock
# 코드 작업 후 검사
ruff check
pytest
# 다른 머신에서 동일 환경 재현
git pull
uv sync --frozen
```

View File

@@ -78,7 +78,9 @@ class GateBuilder:
def _build_spillway_body(self): def _build_spillway_body(self):
"""Ogee 프로파일을 span 방향으로 extrude하여 본체 생성.""" """Ogee 프로파일을 span 방향으로 extrude하여 본체 생성."""
p = self.params p = self.params
profile = p.ogee_profile # 매끄러운 곡면을 위해 ogee 프로파일을 CubicSpline으로 4× densify
# (사용자 피드백 #2: 곡선 직선 분절 → spline 보간으로 매끄럽게)
profile = self._densify_profile(p.ogee_profile, n_factor=4)
if len(profile) < 3: if len(profile) < 3:
return return
@@ -101,6 +103,50 @@ class GateBuilder:
if mesh is not None: if mesh is not None:
self.meshes.append((mesh, COLORS["concrete"], 1.0)) self.meshes.append((mesh, COLORS["concrete"], 1.0))
@staticmethod
def _densify_profile(profile_2d, n_factor: int = 4, n_min: int = 4):
"""(x, z) 프로파일 점을 arc-length parametric CubicSpline로 보간.
Ogee 단면은 단조 함수가 아닐 수 있어(상류 옹벽 수직 구간에서 동일 x 다중 z)
parametric (s, x), (s, z) 곡선이 안전하다. 호 길이 누적을 매개변수로 사용해
균등하게 재샘플링한다.
Args:
profile_2d: [(x, z), ...] 원본 단면 점.
n_factor: 출력 점 개수 = max(n_factor * len(profile), n_min).
n_min: 출력 최소 점 개수.
Returns:
[(x, z), ...] 보간된 점 리스트. scipy 미설치 등 실패 시 원본 반환.
"""
if profile_2d is None:
return []
if len(profile_2d) < 4:
# 점이 너무 적으면 spline 의미 없음 — 원본 그대로
return list(profile_2d)
try:
from scipy.interpolate import CubicSpline
except Exception:
return list(profile_2d)
pts = np.asarray(profile_2d, dtype=float)
diffs = np.diff(pts, axis=0)
seg_len = np.sqrt((diffs ** 2).sum(axis=1))
s = np.concatenate([[0.0], np.cumsum(seg_len)])
if s[-1] <= 1e-9:
return list(profile_2d)
s_norm = s / s[-1]
try:
cs_x = CubicSpline(s_norm, pts[:, 0], bc_type="natural")
cs_z = CubicSpline(s_norm, pts[:, 1], bc_type="natural")
except Exception:
return list(profile_2d)
n_out = max(n_factor * len(profile_2d), n_min)
s_new = np.linspace(0.0, 1.0, n_out)
return list(zip(cs_x(s_new).tolist(), cs_z(s_new).tolist(), strict=False))
def _extrude_2d_profile(self, profile_2d: list, span: float) -> np.ndarray: def _extrude_2d_profile(self, profile_2d: list, span: float) -> np.ndarray:
"""(y, z) 프로파일 점들을 X 방향으로 2개 평면(start/end) 생성. """(y, z) 프로파일 점들을 X 방향으로 2개 평면(start/end) 생성.

136
harness/crash_logger.py Normal file
View File

@@ -0,0 +1,136 @@
"""S-CANVAS 크래시/예외 로깅 — sys.excepthook + faulthandler + 회전 파일 핸들러.
사용:
from harness.crash_logger import install_crash_handlers
install_crash_handlers() # main() 진입점에서 한 번 호출
동작:
- 미처리 예외: traceback을 logs/scanvas.log + logs/crash_<ts>.txt에 저장
- C-level 크래시(segfault 등): faulthandler가 stderr + logs/faulthandler.log로 trace
- 메인 thread 외 thread 예외도 캡처 (threading.excepthook)
- 기존 stdout/stderr 출력은 유지 (사용자 인지 가능)
"""
from __future__ import annotations
import datetime as _dt
import faulthandler
import logging
import logging.handlers
import sys
import threading
import traceback
from pathlib import Path
# 상수
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
_MAIN_LOG = _LOG_DIR / "scanvas.log"
_FAULT_LOG = _LOG_DIR / "faulthandler.log"
_MAX_BYTES = 5 * 1024 * 1024 # 5MB per file
_BACKUP_COUNT = 5 # rotate up to scanvas.log.5
_installed = False
_crash_logger: logging.Logger | None = None
def _ensure_log_dir() -> None:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
def _build_logger() -> logging.Logger:
logger = logging.getLogger("scanvas.crash")
logger.setLevel(logging.DEBUG)
# 회전 파일 핸들러 (중복 install 방지: 핸들러 이미 있으면 재사용)
if not any(isinstance(h, logging.handlers.RotatingFileHandler)
for h in logger.handlers):
handler = logging.handlers.RotatingFileHandler(
_MAIN_LOG, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT,
encoding="utf-8",
)
fmt = logging.Formatter(
"%(asctime)s | %(levelname)s | %(threadName)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
handler.setFormatter(fmt)
logger.addHandler(handler)
return logger
def _dump_crash_artifact(prefix: str, body: str) -> Path:
"""크래시별 별도 파일에도 전체 traceback 보관 (회전 로그와 별개)."""
ts = _dt.datetime.now().strftime("%Y%m%dT%H%M%S")
path = _LOG_DIR / f"{prefix}_{ts}.txt"
path.write_text(body, encoding="utf-8")
return path
def _excepthook(exc_type, exc_value, exc_tb):
"""sys.excepthook — 메인 thread 미처리 예외."""
if exc_type is KeyboardInterrupt:
# Ctrl+C는 정상 흐름으로 처리 (기본 동작 유지)
sys.__excepthook__(exc_type, exc_value, exc_tb)
return
body = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
if _crash_logger:
_crash_logger.critical("UNCAUGHT EXCEPTION (main thread):\n%s", body)
artifact = _dump_crash_artifact("crash", body)
# 기존 동작 유지 — stderr에도 출력
sys.__excepthook__(exc_type, exc_value, exc_tb)
print(f"\n[crash_logger] 크래시 덤프 → {artifact}", file=sys.stderr)
def _thread_excepthook(args: threading.ExceptHookArgs) -> None:
"""threading.excepthook — 워커 thread 미처리 예외 (Py3.8+)."""
if args.exc_type is SystemExit:
return
body = "".join(traceback.format_exception(
args.exc_type, args.exc_value, args.exc_traceback))
thread_name = args.thread.name if args.thread else "<unknown>"
if _crash_logger:
_crash_logger.error(
"UNCAUGHT EXCEPTION (thread %s):\n%s", thread_name, body)
_dump_crash_artifact(f"crash_thread_{thread_name}", body)
def install_crash_handlers(also_install_faulthandler: bool = True) -> Path:
"""크래시 핸들러를 한 번만 install. 재호출은 no-op.
Returns:
로그 디렉토리 경로 (사용자에게 보여줄 수 있음).
"""
global _installed, _crash_logger # noqa: PLW0603 (singleton install pattern)
if _installed:
return _LOG_DIR
_ensure_log_dir()
_crash_logger = _build_logger()
# Python 예외 훅
sys.excepthook = _excepthook
threading.excepthook = _thread_excepthook
# C-level 크래시 (segfault 등) — 별도 파일에 기록
if also_install_faulthandler:
# 기존 stderr 출력은 그대로 남기고, 추가로 파일에도.
# NOTE: faulthandler는 long-lived file handle이 필요 (크래시 시점에 쓰기 위해)
# — context manager로 닫으면 안 됨.
try:
fh = open(_FAULT_LOG, "a", encoding="utf-8") # noqa: SIM115 (faulthandler needs long-lived fh)
faulthandler.enable(file=fh, all_threads=True)
except OSError as e:
# 파일 핸들 못 열면 stderr만으로 폴백
faulthandler.enable(all_threads=True)
_crash_logger.warning("faulthandler 파일 모드 실패 (%s) — stderr만 사용", e)
_installed = True
_crash_logger.info("crash_logger 설치 완료 (logs=%s)", _LOG_DIR)
return _LOG_DIR
def get_logger() -> logging.Logger:
"""app.log() 같은 일반 로깅도 같은 회전 파일에 쓰고 싶을 때 사용."""
if _crash_logger is None:
# auto-install (설치 안 한 경우 안전한 폴백)
install_crash_handlers()
return _crash_logger # type: ignore[return-value]

144
pyproject.toml Normal file
View File

@@ -0,0 +1,144 @@
# S-CANVAS — Python project metadata + uv-compatible dependency declaration.
#
# 사용법 (uv 권장 — 피드백 #5):
# uv venv # 가상환경 생성 (.venv)
# uv pip install -e . # 본 프로젝트 + 일반 deps
# uv pip install -e ".[py313]" # Python 3.13 호환 변종
# uv pip install -e ".[dev]" # 개발 도구 (ruff, pytest, pre-commit)
#
# 기존 pip 사용자도 호환:
# pip install -e .
#
# 원본 requirements.txt는 build machine 빌드 재현용으로 보존.
[project]
name = "scanvas"
version = "0.7.0"
description = "S-CANVAS — Generative Design & Visualization Engine (DXF + DEM + AI)"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "Proprietary" }
authors = [
{ name = "Saman Corp.", email = "saman@example.com" },
]
keywords = ["cad", "dxf", "civil-engineering", "3d-visualization", "ai-rendering"]
# 기본 의존성 (build machine 핀 — Py3.9~3.12 검증).
dependencies = [
# --- GUI ---
"customtkinter==5.2.2",
"tkintermapview==1.29",
"Pillow==11.3.0",
# --- 3D / mesh ---
"pyvista==0.46.5",
# --- Geospatial / DXF ---
"ezdxf==1.4.2",
"pyproj==3.6.1",
"rasterio==1.4.3",
# --- Numerical ---
"numpy==2.0.2",
"scipy==1.13.1",
"matplotlib==3.9.4",
# --- Image / video ---
"opencv-python==4.13.0.92",
# --- Network ---
"requests==2.32.5",
# --- AI rendering ---
"google-genai==1.47.0",
"google-auth==2.49.2",
# --- Persistence / logging ---
"SQLAlchemy==2.0.49",
"structlog==25.5.0",
"PyYAML==6.0.3",
]
[project.optional-dependencies]
# Python 3.13 호환 변종 (wheel 미배포 패키지 핀 변경).
py313 = [
"pyproj>=3.7,<4",
"scipy>=1.14",
"numpy>=2.0.2",
# 나머지 핀은 base와 동일 (uv가 자동 충돌 해결).
]
# 개발 도구.
dev = [
"ruff>=0.15",
"pytest>=8.0",
"pytest-xdist>=3.5", # 병렬 테스트
"pytest-cov>=5.0", # 커버리지
"pre-commit>=3.7",
]
# 배포용 .exe 빌드.
build = [
"pyinstaller==6.18.0",
]
[project.scripts]
scanvas = "scanvas_maker:_cli_entry" # 향후 CLI 진입점 노출 시 사용 (현재는 GUI 직접 실행)
[project.urls]
Homepage = "https://gitea.hmac.kr/HYUNJUNGLEE/scanvas"
Repository = "https://gitea.hmac.kr/HYUNJUNGLEE/scanvas.git"
# ─────────────────────────────────────────────────────────────────────────
# uv 전용 설정
# ─────────────────────────────────────────────────────────────────────────
[tool.uv]
# uv lock 파일 사용 (재현 가능 환경).
# 명령: `uv lock` → uv.lock 생성/갱신, `uv sync` → 환경 동기화.
# Python 인터프리터 선택 우선순위 (uv가 자동 검색).
python-preference = "managed" # managed = uv가 직접 받아 관리 (3.13 자동 다운로드 가능)
# 색상/진행률 표시.
no-progress = false
# ─────────────────────────────────────────────────────────────────────────
# 빌드 시스템 (편집 가능 설치 / pip install -e . 가능)
# ─────────────────────────────────────────────────────────────────────────
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
# 단일 모듈 + 패키지 혼합 — root의 .py 파일들과 harness/ 패키지 모두 wheel에 포함.
packages = ["harness"]
include = [
"*.py",
"prompt_templates/**/*.yaml",
"structure_types/**/*.yaml",
]
exclude = [
"*.bak*",
"_unused/**",
"workspace/**",
"venv*/**",
"__pycache__/**",
"test/**",
"tests/**",
]
# ─────────────────────────────────────────────────────────────────────────
# Tooling — ruff/pytest config는 별도 파일(ruff.toml)에 있음. 여기는 보조 설정만.
# ─────────────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = [
"-ra", # short test summary for all
"--strict-markers",
"--tb=short",
]
markers = [
"slow: 무거운 통합 테스트 (DXF/렌더 포함)",
"integration: 외부 서비스 (Gemini API 등) 호출",
]

View File

@@ -1,10 +1,12 @@
import contextlib
import customtkinter as ctk import customtkinter as ctk
import datetime import datetime
import hashlib import hashlib
import os
import sys
import io import io
import json import json
import logging
import os
import sys
import threading import threading
import time as _time import time as _time
from pathlib import Path from pathlib import Path
@@ -26,6 +28,22 @@ import pyproj
import requests import requests
from PIL import Image, ImageDraw, ImageFilter from PIL import Image, ImageDraw, ImageFilter
import tkintermapview import tkintermapview
from matplotlib.colors import LinearSegmentedColormap
# 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림).
# 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체.
_TIN_EARTH_CMAP = LinearSegmentedColormap.from_list(
"scanvas_earth",
[
(0.00, "#3F2E1A"), # 저지대 — 짙은 갈색 (배수로 음영)
(0.20, "#6E5235"), # 토양
(0.45, "#9C7B4F"), # 황토
(0.70, "#C7AA7C"), # 모래/건조
(0.88, "#E5D5B0"), # 고지 능선
(1.00, "#F5EBD3"), # 정상 광택
],
N=256,
)
# Harness 모듈 (동일 디렉토리의 harness/ 폴더) # Harness 모듈 (동일 디렉토리의 harness/ 폴더)
try: try:
@@ -79,9 +97,7 @@ except ImportError as _e:
STRUCTURE_VLM_AVAILABLE = False STRUCTURE_VLM_AVAILABLE = False
print(f"[Warning] structure_vlm_feedback not available: {_e}") print(f"[Warning] structure_vlm_feedback not available: {_e}")
# 폰트 에러 방지를 위한 처리 # 폰트 에러 방지 — matplotlib font_manager 로그 비활성.
import logging
import contextlib
logging.getLogger('matplotlib.font_manager').disabled = True logging.getLogger('matplotlib.font_manager').disabled = True
os.environ['PYTHONIOENCODING'] = 'utf-8' os.environ['PYTHONIOENCODING'] = 'utf-8'
@@ -4660,7 +4676,7 @@ class SCanvasApp(ctk.CTk):
sample = pts_abs if len(pts_abs) <= 30000 else pts_abs[ sample = pts_abs if len(pts_abs) <= 30000 else pts_abs[
np.random.RandomState(0).choice(len(pts_abs), 30000, replace=False)] np.random.RandomState(0).choice(len(pts_abs), 30000, replace=False)]
sc = ax.scatter(sample[:, 0], sample[:, 1], c=sample[:, 2], sc = ax.scatter(sample[:, 0], sample[:, 1], c=sample[:, 2],
cmap="terrain", s=2, alpha=0.85) cmap=_TIN_EARTH_CMAP, s=2, alpha=0.85)
fig.colorbar(sc, ax=ax, label="Elevation (m)") fig.colorbar(sc, ax=ax, label="Elevation (m)")
# 도면 bbox 표시 # 도면 bbox 표시
ax.add_patch(_MplRect((x0p, y0p), x1p - x0p, y1p - y0p, ax.add_patch(_MplRect((x0p, y0p), x1p - x0p, y1p - y0p,
@@ -5515,7 +5531,7 @@ class SCanvasApp(ctk.CTk):
p.add_mesh(unified_mesh, texture=texture_obj, p.add_mesh(unified_mesh, texture=texture_obj,
show_edges=self.wireframe_var.get(), edge_color="white") show_edges=self.wireframe_var.get(), edge_color="white")
else: else:
p.add_mesh(unified_mesh, scalars="Elevation", cmap="terrain", p.add_mesh(unified_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
show_edges=self.wireframe_var.get(), edge_color="white", show_edges=self.wireframe_var.get(), edge_color="white",
scalar_bar_args={'title': 'Elevation (m)'}) scalar_bar_args={'title': 'Elevation (m)'})
else: else:
@@ -5524,7 +5540,7 @@ class SCanvasApp(ctk.CTk):
p.add_mesh(target_mesh, texture=texture_obj, p.add_mesh(target_mesh, texture=texture_obj,
show_edges=self.wireframe_var.get(), edge_color="white") show_edges=self.wireframe_var.get(), edge_color="white")
else: else:
p.add_mesh(target_mesh, scalars="Elevation", cmap="terrain", p.add_mesh(target_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
show_edges=self.wireframe_var.get(), edge_color="white", show_edges=self.wireframe_var.get(), edge_color="white",
scalar_bar_args={'title': 'Elevation (m)'}) scalar_bar_args={'title': 'Elevation (m)'})
if ext_mesh is not None: if ext_mesh is not None:
@@ -5533,7 +5549,7 @@ class SCanvasApp(ctk.CTk):
p.add_mesh(ext_mesh, texture=texture_obj, p.add_mesh(ext_mesh, texture=texture_obj,
show_edges=False, lighting=True) show_edges=False, lighting=True)
else: else:
p.add_mesh(ext_mesh, scalars="Elevation", cmap="terrain", p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
show_edges=False, lighting=True, show_edges=False, lighting=True,
show_scalar_bar=False) show_scalar_bar=False)
except Exception as e: except Exception as e:
@@ -5894,7 +5910,7 @@ class SCanvasApp(ctk.CTk):
tex = pv.read_texture("satellite_temp.png") tex = pv.read_texture("satellite_temp.png")
p.add_mesh(target, texture=tex, show_edges=self.wireframe_var.get(), edge_color="#444444") p.add_mesh(target, texture=tex, show_edges=self.wireframe_var.get(), edge_color="#444444")
else: else:
p.add_mesh(target, scalars="Elevation", cmap="terrain", p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
show_edges=self.wireframe_var.get(), edge_color="#444444") show_edges=self.wireframe_var.get(), edge_color="#444444")
# DEM 외곽 확장 메시 — 뷰포인트 선택/캡처/AI에 **같은 장면**을 쓰기 위해 같이 렌더 # DEM 외곽 확장 메시 — 뷰포인트 선택/캡처/AI에 **같은 장면**을 쓰기 위해 같이 렌더
@@ -5904,7 +5920,7 @@ class SCanvasApp(ctk.CTk):
if tex is not None and self.tin_extension_textured is not None: if tex is not None and self.tin_extension_textured is not None:
p.add_mesh(ext_mesh_view, texture=tex, show_edges=False, lighting=True) p.add_mesh(ext_mesh_view, texture=tex, show_edges=False, lighting=True)
else: else:
p.add_mesh(ext_mesh_view, scalars="Elevation", cmap="terrain", p.add_mesh(ext_mesh_view, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
show_edges=False, lighting=True, show_scalar_bar=False) show_edges=False, lighting=True, show_scalar_bar=False)
except Exception as e: except Exception as e:
self.log(f" [뷰어] 확장 메시 추가 경고: {e}") self.log(f" [뷰어] 확장 메시 추가 경고: {e}")
@@ -6315,19 +6331,19 @@ class SCanvasApp(ctk.CTk):
if textured and tex is not None: if textured and tex is not None:
p.add_mesh(unified, texture=tex) p.add_mesh(unified, texture=tex)
else: else:
p.add_mesh(unified, scalars="Elevation", cmap="terrain") p.add_mesh(unified, scalars="Elevation", cmap=_TIN_EARTH_CMAP)
else: else:
if textured and tex is not None: if textured and tex is not None:
p.add_mesh(target, texture=tex) p.add_mesh(target, texture=tex)
else: else:
p.add_mesh(target, scalars="Elevation", cmap="terrain") p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP)
if ext_mesh is not None: if ext_mesh is not None:
try: try:
if textured and tex is not None: if textured and tex is not None:
p.add_mesh(ext_mesh, texture=tex, p.add_mesh(ext_mesh, texture=tex,
show_edges=False, lighting=True) show_edges=False, lighting=True)
else: else:
p.add_mesh(ext_mesh, scalars="Elevation", cmap="terrain", p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
show_edges=False, lighting=True, show_edges=False, lighting=True,
show_scalar_bar=False) show_scalar_bar=False)
except Exception as e: except Exception as e:
@@ -7000,6 +7016,14 @@ class SCanvasApp(ctk.CTk):
self.log(f" 결과 표시 오류: {e}") self.log(f" 결과 표시 오류: {e}")
if __name__ == "__main__": if __name__ == "__main__":
# 크래시 핸들러 — Python 미처리 예외 + thread 예외 + faulthandler. logs/ 에 회전 저장.
try:
from harness.crash_logger import install_crash_handlers
_log_dir = install_crash_handlers()
print(f"[crash_logger] logs → {_log_dir}")
except Exception as _ch_err:
print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}")
# 인트로 스플래시 — Design/logo_intro.mp4 재생 후 메인 앱 기동. # 인트로 스플래시 — Design/logo_intro.mp4 재생 후 메인 앱 기동.
# 실패·파일 없음 시 조용히 skip(메인 앱은 항상 뜸). # 실패·파일 없음 시 조용히 skip(메인 앱은 항상 뜸).
try: try:

0
tests/__init__.py Normal file
View File

28
tests/conftest.py Normal file
View File

@@ -0,0 +1,28 @@
"""pytest 공통 fixture.
피드백 #7/#8: 버그 발견 시 테스트로 박제하여 재발 방지. 새 회귀 테스트는
이 파일에 fixture를 추가해 빠르게 빌드.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# 프로젝트 루트를 import path에 추가 — 테스트가 src 인접 모듈을 찾도록.
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
@pytest.fixture(scope="session")
def project_root() -> Path:
"""프로젝트 루트 경로."""
return _PROJECT_ROOT
@pytest.fixture
def sample_dxf_dir(project_root: Path) -> Path:
"""테스트용 sample DXF 디렉토리."""
return project_root / "SAMPLE_CAD"

214
tests/test_regressions.py Normal file
View File

@@ -0,0 +1,214 @@
"""회귀 테스트 — 발견된 버그가 다시 들어오지 못하게 박제.
피드백 #7 인용:
> "버그가 식별되면 수정 후 재발하지 않도록 하는 알고리즘도 필요"
새 버그 발견 → 여기 테스트 추가 → ruff + pytest CI에서 자동 검증.
"""
from __future__ import annotations
import pytest
# ============================================================================
# iter=1 / iter=2: 비동기 lambda + except/loop 변수 캡처 NameError
# (8 + 6 = 14건. 모든 케이스가 `lambda VAR=VAR:` 형식의 default-arg 캡처로 수정됨.)
# ============================================================================
def test_iter1_lambda_default_arg_pattern():
"""`except as e:` 직후 `app.after(..., lambda: ...{e}...)` 패턴은 NameError를 유발.
수정안 `lambda e=e:` 가 작동하는지 검증.
"""
queued: list = []
def fail_then_queue_unfixed():
try:
raise ValueError("boom")
except Exception as e: # noqa: F841 (의도적 demo — async lambda가 e 캡처)
queued.append(lambda: f"unfixed: {e}") # noqa: F821 (의도적 demo)
def fail_then_queue_fixed():
try:
raise ValueError("boom")
except Exception as e:
queued.append(lambda e=e: f"fixed: {e}") # default-arg capture
fail_then_queue_unfixed()
fail_then_queue_fixed()
# Unfixed: 비동기 시점에 `e`가 사라져 NameError.
with pytest.raises(NameError):
queued[0]()
# Fixed: default-arg가 lambda 인스턴스에 락-인.
assert queued[1]() == "fixed: boom"
def test_iter2_loop_var_capture():
"""B023: 루프 변수가 비동기 lambda에 free-var로 캡처되는 패턴.
`lambda _m=_m:` 형식이 올바르게 락-인하는지 검증.
"""
deferred: list = []
# 미수정 패턴 — 마지막 값으로 모두 통일됨 (PERF401/B023 의도적 demo)
for x in [1, 2, 3]:
deferred.append(lambda: x) # noqa: B023, PERF401 (의도적 demo)
assert all(f() == 3 for f in deferred), "free-var 캡처는 마지막 x=3로 통일"
# 수정 패턴 — 각 lambda가 자기 시점의 x를 락-인
fixed: list = []
for x in [1, 2, 3]:
fixed.append(lambda x=x: x) # noqa: PERF401 (demo 대응)
assert [f() for f in fixed] == [1, 2, 3]
# ============================================================================
# iter=4: B905 zip strict — sliding window에서 strict=True가 깨지는지 검증
# ============================================================================
def test_sliding_window_strict_false():
"""validate_gate_params.py:254에서 `pairwise(el_chain)` 사용. strict=True였다면
N과 N-1 길이 차로 ValueError. pairwise는 그 자체로 sliding-pair semantics.
"""
from itertools import pairwise
el_chain = [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)]
pairs = list(pairwise(el_chain))
assert len(pairs) == len(el_chain) - 1
assert pairs[0][0] == el_chain[0]
assert pairs[-1][1] == el_chain[-1]
# ============================================================================
# iter=6: RUF012 ClassVar — class-level mutable default가 인스턴스 간 공유되는지 검증
# ============================================================================
def test_classvar_singleton_pattern():
"""structure_templates.TemplateRegistry는 ClassVar로 명시된 _instance/_templates를
가진다. 모든 인스턴스가 같은 dict를 공유해야 함.
"""
try:
from structure_templates import TemplateRegistry
except ImportError:
pytest.skip("structure_templates 미설치 (외부 deps 필요)")
r1 = TemplateRegistry()
r2 = TemplateRegistry()
assert r1 is r2 # __new__ 싱글톤 — 동일 인스턴스
assert r1._templates is r2._templates # ClassVar — 동일 dict
def test_gate_parser_struct_layers_classvar():
"""gate_parser.GateParser.STRUCT_LAYERS 가 ClassVar set 인지 (인스턴스 X)."""
try:
from gate_parser import GateParser
except ImportError:
pytest.skip("gate_parser 미설치")
p1 = GateParser()
p2 = GateParser()
assert p1.STRUCT_LAYERS is p2.STRUCT_LAYERS # 같은 set 객체 공유
assert "CS-CONC-Spillway" in p1.STRUCT_LAYERS
# ============================================================================
# iter=6: RUF013 implicit Optional — dxf_geometry._process_entity 시그니처
# ============================================================================
def test_dxf_geometry_inherited_layer_optional_str():
"""dxf_geometry._process_entity의 `inherited_layer` 인자는 명시적 Optional[str]."""
try:
from dxf_geometry import extract_structural_geometry
except ImportError:
pytest.skip("dxf_geometry 미설치")
# 타입 자체는 import만 통과하면 OK (런타임 검증 어려움 — annotation 검사로 충분).
import inspect
src = inspect.getsource(extract_structural_geometry)
# 내부 정의된 _process_entity의 시그니처 텍스트 검색
assert "inherited_layer: str | None = None" in src or \
"inherited_layer: Optional[str]" in src
# ============================================================================
# iter=7: SIM102 collapsible-if — gate_3d_builder_bpy 진입점 보호
# (apply_blender_patch가 import 시 main()이 우발 실행 안 됨)
# ============================================================================
def test_gate_3d_builder_bpy_no_unexpected_main_on_import():
"""gate_3d_builder_bpy 의 진입점 가드는 `__name__ == "__main__"` AND --params 일 때만."""
try:
import sys
original_argv = sys.argv[:]
sys.argv = ["test", "--params", "fake.json"] # --params이 있어도
try:
# import만 시도 — main() 호출되면 예외/크래시
import importlib
import gate_3d_builder_bpy
importlib.reload(gate_3d_builder_bpy)
finally:
sys.argv = original_argv
except ModuleNotFoundError:
pytest.skip("gate_3d_builder_bpy bpy 미설치 (Blender 환경)")
except SystemExit:
pytest.fail("import만으로 main()이 실행되었다 — __name__ 가드 손상")
# ============================================================================
# Phase 1A (이번 세션): crash_logger
# ============================================================================
def test_crash_logger_install_idempotent(tmp_path, monkeypatch):
"""install_crash_handlers는 재호출해도 안전 (no-op)."""
from harness import crash_logger
# 기존 상태 백업
orig_excepthook = crash_logger.sys.excepthook
try:
log_dir1 = crash_logger.install_crash_handlers()
log_dir2 = crash_logger.install_crash_handlers()
assert log_dir1 == log_dir2
assert log_dir1.exists()
finally:
crash_logger.sys.excepthook = orig_excepthook
# ============================================================================
# Phase 1B (이번 세션): TIN colormap 파란색 제거
# ============================================================================
def test_tin_colormap_no_blue():
"""_TIN_EARTH_CMAP은 RGB의 B 채널이 R/G보다 작도록 (즉, 차가운 색이 아님) 보장."""
try:
from scanvas_maker import _TIN_EARTH_CMAP
except ImportError:
pytest.skip("scanvas_maker 미설치 (GUI deps)")
import numpy as np
# 5단계 샘플링 후 각 RGB가 earth-tone인지 확인
samples = _TIN_EARTH_CMAP(np.linspace(0, 1, 5))[:, :3] # (5, 3) RGB
for r, g, b in samples:
# earth tone: B (파랑)가 R, G 모두보다 작아야 함 — 따뜻한 톤
assert b <= r, f"파란색 우세 발견: R={r:.2f} G={g:.2f} B={b:.2f}"
assert b <= g, f"파란색 우세 발견: R={r:.2f} G={g:.2f} B={b:.2f}"
# ============================================================================
# 일반 헬스 — production 모듈 import 가능성
# ============================================================================
@pytest.mark.parametrize("module_name", [
"dxf_geometry", "filename_classifier", "optional_detector",
"polygon_reconstructor", "resource_paths",
"harness.crash_logger", "harness.seed_manager",
"harness.prompt_registry", "harness.logger",
])
def test_module_imports_without_external_deps(module_name):
"""기본 모듈은 외부 패키지 없이도 import 되거나, 명확한 ImportError를 줘야 한다."""
import importlib
try:
importlib.import_module(module_name)
except ImportError as e:
# 외부 deps 부재는 OK — 단 메시지가 도움 되는지
assert any(token in str(e).lower() for token in
("install", "필요", "required", "missing")), \
f"{module_name}: ImportError 메시지가 도움 안 됨 ({e})"