diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..46f9075 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d8de3e5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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' diff --git a/ARCHITECTURE_PLAN.md b/ARCHITECTURE_PLAN.md new file mode 100644 index 0000000..29f473d --- /dev/null +++ b/ARCHITECTURE_PLAN.md @@ -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. `/plugins/` — 번들 plugin (소스 트리 또는 PyInstaller `_MEIPASS`) +2. `/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__`) 을 부여한다. + +### 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//` 로 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//` +로 분할 + 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//` 풀어주는 헬퍼 `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//manifest.json × 7 +plugins/structures//parameters.json × 7 +plugins/structures//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///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 끝나면 도달. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7e267ed --- /dev/null +++ b/CONTRIBUTING.md @@ -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) diff --git a/CURVE_SMOOTHING_PLAN.md b/CURVE_SMOOTHING_PLAN.md new file mode 100644 index 0000000..4dbeb19 --- /dev/null +++ b/CURVE_SMOOTHING_PLAN.md @@ -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`) 그대로, 입력 점 수만 늘어남. +- 효과 즉각: 다음 빌드 결과부터 곡면이 부드러워짐. diff --git a/PERFORMANCE_BASELINE.md b/PERFORMANCE_BASELINE.md new file mode 100644 index 0000000..26600c5 --- /dev/null +++ b/PERFORMANCE_BASELINE.md @@ -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. 패치 후 동일 시나리오 재측정 → 회귀 확인. diff --git a/UI_REDESIGN_PLAN.md b/UI_REDESIGN_PLAN.md new file mode 100644 index 0000000..9b846df --- /dev/null +++ b/UI_REDESIGN_PLAN.md @@ -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 165–167. +- **메인 레이아웃**: 2-column grid. + - **Left (col 0)**: `sidebar_container` (270px 고정) + `sidebar_frame` (CTkScrollableFrame 250px). line 274–283. + - 섹션: 로고 → 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 558–562. + - row 0 weight=3 → `map_frame` (TkinterMapView, 위성 지도 미리보기). + - row 1 weight=1 → `textbox` (CTkTextbox, **height=120, 인라인 로그 패널**). line 582–583. + - row 2 → `status_bar` (28px, ● READY 인디케이터 + status_text). line 586–593. + +### 1.2 인라인 로그 (제거 대상) +- 표면화 위치: `main_frame.row=1`, `self.textbox` 한 위젯. +- 호출지점: `self.log(message)` — **180회**. line 691–696. +- 동작: `datetime` timestamp prefix → `textbox.insert("end", ...)` → auto-scroll. +- 별도로 `self._diag(...)` (구조물 분류 진단)와 `harness.logger.setup_logging(log_file=harness_log_path())`는 **이미 백엔드 파일**로 흘러가고 있음 (line 228, 698–710). 즉 인프라 절반은 이미 존재. +- 결론: `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 팝업. **창 4–5개를 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 691–696, 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 268–600 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 부터. diff --git a/UV_GUIDE.md b/UV_GUIDE.md new file mode 100644 index 0000000..9f781bc --- /dev/null +++ b/UV_GUIDE.md @@ -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 +# pyproject.toml의 dependencies에도 수동 추가 후 +uv lock + +# 코드 작업 후 검사 +ruff check +pytest + +# 다른 머신에서 동일 환경 재현 +git pull +uv sync --frozen +``` diff --git a/gate_3d_builder.py b/gate_3d_builder.py index dbee4c0..e707a01 100644 --- a/gate_3d_builder.py +++ b/gate_3d_builder.py @@ -78,7 +78,9 @@ class GateBuilder: def _build_spillway_body(self): """Ogee 프로파일을 span 방향으로 extrude하여 본체 생성.""" 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: return @@ -101,6 +103,50 @@ class GateBuilder: if mesh is not None: 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: """(y, z) 프로파일 점들을 X 방향으로 2개 평면(start/end) 생성. diff --git a/harness/crash_logger.py b/harness/crash_logger.py new file mode 100644 index 0000000..8aae865 --- /dev/null +++ b/harness/crash_logger.py @@ -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_.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 "" + 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] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6523f29 --- /dev/null +++ b/pyproject.toml @@ -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 등) 호출", +] diff --git a/scanvas_maker.py b/scanvas_maker.py index 7a4162d..7d841db 100644 --- a/scanvas_maker.py +++ b/scanvas_maker.py @@ -1,10 +1,12 @@ +import contextlib import customtkinter as ctk import datetime import hashlib -import os -import sys import io import json +import logging +import os +import sys import threading import time as _time from pathlib import Path @@ -26,6 +28,22 @@ import pyproj import requests from PIL import Image, ImageDraw, ImageFilter import tkintermapview +from matplotlib.colors import LinearSegmentedColormap + +# 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림). +# 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체. +_TIN_EARTH_CMAP = LinearSegmentedColormap.from_list( + "scanvas_earth", + [ + (0.00, "#3F2E1A"), # 저지대 — 짙은 갈색 (배수로 음영) + (0.20, "#6E5235"), # 토양 + (0.45, "#9C7B4F"), # 황토 + (0.70, "#C7AA7C"), # 모래/건조 + (0.88, "#E5D5B0"), # 고지 능선 + (1.00, "#F5EBD3"), # 정상 광택 + ], + N=256, +) # Harness 모듈 (동일 디렉토리의 harness/ 폴더) try: @@ -79,9 +97,7 @@ except ImportError as _e: STRUCTURE_VLM_AVAILABLE = False print(f"[Warning] structure_vlm_feedback not available: {_e}") -# 폰트 에러 방지를 위한 처리 -import logging -import contextlib +# 폰트 에러 방지 — matplotlib font_manager 로그 비활성. logging.getLogger('matplotlib.font_manager').disabled = True os.environ['PYTHONIOENCODING'] = 'utf-8' @@ -4660,7 +4676,7 @@ class SCanvasApp(ctk.CTk): sample = pts_abs if len(pts_abs) <= 30000 else pts_abs[ np.random.RandomState(0).choice(len(pts_abs), 30000, replace=False)] sc = ax.scatter(sample[:, 0], sample[:, 1], c=sample[:, 2], - cmap="terrain", s=2, alpha=0.85) + cmap=_TIN_EARTH_CMAP, s=2, alpha=0.85) fig.colorbar(sc, ax=ax, label="Elevation (m)") # 도면 bbox 표시 ax.add_patch(_MplRect((x0p, y0p), x1p - x0p, y1p - y0p, @@ -5515,7 +5531,7 @@ class SCanvasApp(ctk.CTk): p.add_mesh(unified_mesh, texture=texture_obj, show_edges=self.wireframe_var.get(), edge_color="white") else: - p.add_mesh(unified_mesh, scalars="Elevation", cmap="terrain", + p.add_mesh(unified_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP, show_edges=self.wireframe_var.get(), edge_color="white", scalar_bar_args={'title': 'Elevation (m)'}) else: @@ -5524,7 +5540,7 @@ class SCanvasApp(ctk.CTk): p.add_mesh(target_mesh, texture=texture_obj, show_edges=self.wireframe_var.get(), edge_color="white") else: - p.add_mesh(target_mesh, scalars="Elevation", cmap="terrain", + p.add_mesh(target_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP, show_edges=self.wireframe_var.get(), edge_color="white", scalar_bar_args={'title': 'Elevation (m)'}) if ext_mesh is not None: @@ -5533,7 +5549,7 @@ class SCanvasApp(ctk.CTk): p.add_mesh(ext_mesh, texture=texture_obj, show_edges=False, lighting=True) 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_scalar_bar=False) except Exception as e: @@ -5894,7 +5910,7 @@ class SCanvasApp(ctk.CTk): tex = pv.read_texture("satellite_temp.png") p.add_mesh(target, texture=tex, show_edges=self.wireframe_var.get(), edge_color="#444444") else: - p.add_mesh(target, scalars="Elevation", cmap="terrain", + p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP, show_edges=self.wireframe_var.get(), edge_color="#444444") # DEM 외곽 확장 메시 — 뷰포인트 선택/캡처/AI에 **같은 장면**을 쓰기 위해 같이 렌더 @@ -5904,7 +5920,7 @@ class SCanvasApp(ctk.CTk): if tex is not None and self.tin_extension_textured is not None: p.add_mesh(ext_mesh_view, texture=tex, show_edges=False, lighting=True) else: - p.add_mesh(ext_mesh_view, scalars="Elevation", cmap="terrain", + p.add_mesh(ext_mesh_view, scalars="Elevation", cmap=_TIN_EARTH_CMAP, show_edges=False, lighting=True, show_scalar_bar=False) except Exception as e: self.log(f" [뷰어] 확장 메시 추가 경고: {e}") @@ -6315,19 +6331,19 @@ class SCanvasApp(ctk.CTk): if textured and tex is not None: p.add_mesh(unified, texture=tex) else: - p.add_mesh(unified, scalars="Elevation", cmap="terrain") + p.add_mesh(unified, scalars="Elevation", cmap=_TIN_EARTH_CMAP) else: if textured and tex is not None: p.add_mesh(target, texture=tex) else: - p.add_mesh(target, scalars="Elevation", cmap="terrain") + p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP) if ext_mesh is not None: try: if textured and tex is not None: p.add_mesh(ext_mesh, texture=tex, show_edges=False, lighting=True) else: - p.add_mesh(ext_mesh, scalars="Elevation", cmap="terrain", + p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP, show_edges=False, lighting=True, show_scalar_bar=False) except Exception as e: @@ -7000,6 +7016,14 @@ class SCanvasApp(ctk.CTk): self.log(f" 결과 표시 오류: {e}") if __name__ == "__main__": + # 크래시 핸들러 — Python 미처리 예외 + thread 예외 + faulthandler. logs/ 에 회전 저장. + try: + from harness.crash_logger import install_crash_handlers + _log_dir = install_crash_handlers() + print(f"[crash_logger] logs → {_log_dir}") + except Exception as _ch_err: + print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}") + # 인트로 스플래시 — Design/logo_intro.mp4 재생 후 메인 앱 기동. # 실패·파일 없음 시 조용히 skip(메인 앱은 항상 뜸). try: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ff170b6 --- /dev/null +++ b/tests/conftest.py @@ -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" diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..dfb448e --- /dev/null +++ b/tests/test_regressions.py @@ -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})"