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

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

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

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

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

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

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

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

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

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

630
ARCHITECTURE_PLAN.md Normal file
View File

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

127
CONTRIBUTING.md Normal file
View File

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

200
CURVE_SMOOTHING_PLAN.md Normal file
View File

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

397
PERFORMANCE_BASELINE.md Normal file
View File

@@ -0,0 +1,397 @@
# S-CANVAS 성능 베이스라인 (Phase 0 — 진단)
본 문서는 read-only 정적 분석 결과로, 실제 측정 없이 코드 패턴/복잡도/I/O 경계를
근거로 추정한 핫스팟 후보 목록이다. 실측 단계(Phase 1)에서 본 문서의
"측정 instrumentation 패치"를 코드에 일시 삽입해 사용자가 실 도면으로 측정한 뒤
3.측정 후 비교 표를 채우면 된다.
기준 출처:
- `.claude/agents/performance-guardian.md` (함정 1~7 — 메인스레드 블로킹 / 폴리곤 폭발 /
매 프레임 재생성 / I/O 직렬화 / GI 과다 / 텍스처 메모리 폭발 / 구조물 누적)
- 사용자 피드백 #11: "위성지도 결합·구조물 빌드 시 CPU 대폭 증가 → ms 단위 추적·최적화"
---
## 1. 추정 핫스팟
| # | 경로/시나리오 | 파일:라인 | 의심 카테고리 | 근거 |
|---|---|---|---|---|
| H1 | XYZ 위성 타일 직렬 다운로드 | `tile_downloader.py:98-124` | **Network-bound**, 메인스레드 블로킹 (함정 1+4) | 이중 for 루프로 `requests.get` 직렬 호출. 줌17 + 1km×1km bbox = 약 16~50타일, 타일당 100~600ms, 총 5~30초. `btn_draping_callback`이 메인스레드에서 호출 → GUI 동결. |
| H2 | DEM (terrarium) 타일 직렬 다운로드 | `dem_extender.py:138-150` | **Network-bound** (함정 4) | 동일 직렬 루프. `fetch_terrarium_grid`는 z=13, buffer=1000m면 보통 4~16타일이지만 캐시 미스 시 GUI 동결. |
| H3 | TIN densify Phase C (10→1m 점진 격자) | `scanvas_maker.py:4405-4455` | **CPU-bound** (numpy + scipy), 메인스레드 (함정 1+2) | `for _step in (10..1)` 안에서 `ConvexHull` 재계산 + meshgrid + `MplPath.contains_points` + cKDTree 쿼리 + 매 단계 DEM bilinear 샘플. 큰 도면(2km×2km)에서 10단계 × 수만점 = 수 초. |
| H4 | TIN densify Phase B (긴 edge 중심 추가) | `scanvas_maker.py:4458-4477` | **CPU-bound** (Delaunay), 메인스레드 | 임시 Delaunay 1회 추가. 정점 ~10만 시 0.5~2초. |
| H5 | TIN bbox gap 채움 (Step 1.5-a) | `scanvas_maker.py:5089-5263` | **CPU-bound** + 잠재 Network (함정 1+4) | Phase C와 동일 알고리즘 재실행. 캐시된 `_dem_elev_grid`가 있으면 CPU만, 없으면 추가 fetch. v6 벽 컷 numpy 벡터화는 빠름. |
| H6 | 최종 Delaunay (TIN 생성 후) | `scanvas_maker.py:4502, 5216, 3343` | **CPU-bound**, 메인스레드 | scipy `Delaunay`는 O(n log n)이지만 numpy 출신이 아닌 native Qhull → GIL 안 풀림. 수십만 정점 시 1~3초. |
| H7 | DEM 링 메시 빌드 — outer smooth blend / Laplacian | `dem_extender.py:600-707` | **CPU-bound** (cKDTree + Python loop) | 라인 625, 696의 `for k, nb in enumerate(nbrs)` Python 루프. 격자점 1000개 + 이웃 평균 = 0.5~2초. numpy 벡터화 가능. |
| H8 | Step 1.5 경계 재보간 cKDTree | `scanvas_maker.py:5249-5329` | CPU, 가벼움 | 단일 cKDTree + np.where. 빠름(<200ms). 무시 가능. |
| H9 | `_excavate_tin_for_structures` Python 루프 | `scanvas_maker.py:2983-2993, 3025-3031` | **CPU-bound**, 메인스레드 (함정 1) | `for i in band_idx` / `for i, d in enumerate(grid_d)` 순수 Python. 구조물 5개 + 각 격자 1000점 = 0.5~2초. numpy 벡터화 즉시 가능. |
| H10 | `_composite_material_textures` PIL 픽셀 합성 | `scanvas_maker.py:3923-3996` | I/O + CPU, 메인스레드 | PIL `Draw.polygon` × 도로 수, 노이즈 텍스처 추가. 2048×2048에서 100~500ms. |
| H11 | 위성 텍스처 final resize LANCZOS 2048 | `tile_downloader.py:147` | CPU, 메인스레드 | 큰 합성 이미지를 한 번 리사이즈. 200~600ms. 무시 가능 그러나 일부 PC에서 GIL 동안 다른 일 못 함. |
| H12 | 캡처 단계: PyVista off_screen plotter 3회 생성 | `scanvas_maker.py:5849-5867` | **GPU+CPU**, 메인스레드 (함정 1+3) | `_capture_from_camera` / `_capture_depth_from_camera` / `_capture_lineart_from_camera` 각각 새 `pv.Plotter(off_screen=True)` 생성, 메시 add, 그리고 screenshot. 한 번에 1.5~5초. **세 번 직렬** = 4.5~15초 GUI 동결. |
| H13 | show_3d_preview 의 merge + extract_surface + compute_normals | `scanvas_maker.py:5485-5500` | CPU, 한 번 발생 (한정적) | `feature_angle=180.0` 전체 메시 노멀 재계산은 큰 메시(~50만 cells)에서 1~3초. 매번 호출되지만 1회/프리뷰 오픈이라 함정 3 해당 안 함. |
| H14 | `_add_template_structures_to_plotter` 로깅 + bounds 진단 | `scanvas_maker.py:5640-5760+` | CPU, 메인스레드 | 매 구조물마다 `np.concatenate([m.points])` 두 번(raw, placed). 50개 구조물 × 메시 20개 → 1000회 concat. 100~400ms. |
| H15 | `download_xyz_tiles` 최종 PIL `merged.crop().resize()` | `tile_downloader.py:146-147` | CPU, 메인스레드 | bbox 크롭 후 LANCZOS 2048 리사이즈. 가벼움. |
| H16 | `pv.read_texture("satellite_temp.png")` | `scanvas_maker.py:5393, 5894, 6281` | I/O, 메인스레드 | 매 capture/preview마다 디스크 재읽기. 200~500ms × 4회 = 1~2초 낭비. **재사용/캐싱 가능**. |
| H17 | `enable_eye_dome_lighting()` 매 plotter | `scanvas_maker.py:5563, 5914, 6339, 6359` | GPU, 60FPS 영향 (함정 5) | EDL은 비용이 보통이지만 SSAO와 누적되면 30FPS로 떨어짐. 큰 메시에선 주의. |
| H18 | `_build_plan_overlay_meshes` 모든 계획선 매번 재생성 | `scanvas_maker.py:3599-3700+` | CPU, show_3d_preview 호출 시마다 (함정 3) | 메시는 변하지 않는데 매 프리뷰 오픈마다 재빌드. 캐싱 가능. |
| H19 | TIN 생성 시 ezdxf entity 순회 | `scanvas_maker.py:4187-4226` | I/O+CPU | 6개 entity 타입 × 모든 modelspace 엔티티. 큰 DXF(수만 등고선)에서 2~10초. |
---
## 2. 측정 instrumentation 패치
각 핫스팟에 삽입할 컨텍스트 매니저. **본 라운드에서는 삽입하지 않음**(read-only).
사용자가 Phase 1 측정 시 임시로 삽입 → 측정 후 즉시 제거.
### 2.1 공통 컨텍스트 매니저 (`scanvas_maker.py` 상단에 추가)
```python
import time
from contextlib import contextmanager
@contextmanager
def _perf(label, log_fn=print):
"""일회성 측정. 시작/끝 ms 출력. CPU vs wall-time 둘 다."""
t_wall = time.perf_counter()
t_cpu = time.process_time()
try:
yield
finally:
dt_wall = (time.perf_counter() - t_wall) * 1000
dt_cpu = (time.process_time() - t_cpu) * 1000
log_fn(f" [PERF] {label}: wall={dt_wall:.1f}ms cpu={dt_cpu:.1f}ms "
f"({'CPU' if dt_cpu/max(dt_wall,1e-3) > 0.5 else 'I/O/Net'}-bound)")
```
판별: `cpu/wall > 0.5` → CPU-bound, 그 외 → I/O/Network-bound (GIL 풀린 시간).
### 2.2 H1: XYZ 타일 다운로드 (`tile_downloader.py:98`)
```python
# 기존 for 루프 직전
with _perf(f"XYZ tiles {cols}x{rows}={cols*rows}", log_fn):
for ty in range(y_min, y_max + 1):
... # 기존 루프
```
### 2.3 H2: terrarium fetch (`dem_extender.py:138`)
```python
with _perf(f"terrarium fetch {cols}x{rows} z{zoom}", log_fn):
for ty in range(y_min, y_max + 1):
...
```
### 2.4 H3: Phase C 점진 densify (`scanvas_maker.py:4414`)
```python
with _perf(f"Phase C densify (10->1m, n_pts={len(pts)})", self.log):
for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
with _perf(f" step {_step}m", self.log):
try:
hull_c = _ConvexHull(pts[:, :2])
except Exception:
break
... # 기존 루프 본문
```
### 2.5 H4: Phase B Delaunay (`scanvas_maker.py:4458`)
```python
with _perf(f"Phase B Delaunay (n={len(pts)})", self.log):
tri_tmp = Delaunay(pts[:, :2])
```
### 2.6 H5: Step 1.5-a 채움 (`scanvas_maker.py:5172`)
```python
with _perf("Step 1.5-a fill (point progressive 10->1m)", self.log):
current_abs = pts_abs.copy()
... # 기존 점진 densify 루프
```
### 2.7 H6: 최종 Delaunay (`scanvas_maker.py:4502`)
```python
with _perf(f"final Delaunay (n_pts={len(pts)})", self.log):
tri = Delaunay(pts[:, :2])
```
### 2.8 H7: DEM 링 build (`dem_extender.py` 함수 진입부)
```python
def build_extended_terrain_ring(...):
with _perf(f"build_extended_terrain_ring (buffer={buffer_m}m, "
f"step={grid_step_m or 'auto'})", log_fn):
... # 함수 본문 전체
```
내부 단계 분리:
```python
with _perf(" Phase 1: ring point gen", log_fn): ...
with _perf(" Phase 2: WGS84 transform + DEM sample", log_fn): ...
with _perf(" Phase 3: outlier + spike filter", log_fn): ...
with _perf(" Phase 4: feathering + Laplacian", log_fn): ...
with _perf(" Phase 5: Delaunay + cut", log_fn): ...
```
### 2.9 H9: `_excavate_tin_for_structures` (`scanvas_maker.py:2939` 루프 내)
```python
for info in self.structure_registry.values():
with _perf(f" excavate {info['name']}", self.log):
...
```
### 2.10 H10: `_composite_material_textures` (`scanvas_maker.py:3886`)
```python
def _composite_material_textures(self, satellite_img, ...):
with _perf(f"composite_materials img={satellite_img.size}", self.log):
...
```
### 2.11 H12: 캡처 3종 (`scanvas_maker.py:5849`)
```python
with _perf(f"capture_textured {out_w}x{out_h}", self.log):
self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
with _perf(f"capture_depth", self.log):
self.depth_map = self._capture_depth_from_camera(out_w, out_h)
with _perf(f"capture_lineart", self.log):
self.lineart_map = self._capture_lineart_from_camera(out_w, out_h)
```
### 2.12 H13: show_3d_preview merge (`scanvas_maker.py:5485`)
```python
with _perf(f"unified merge+normals (TIN {target_mesh.n_points} + "
f"DEM {ext_mesh.n_points})", self.log):
merged = target_mesh.merge(ext_mesh, merge_points=True, tolerance=0.01)
merged = merged.extract_surface() if not isinstance(merged, pv.PolyData) else merged
...
merged.compute_normals(feature_angle=180.0, ...)
```
### 2.13 H19: ezdxf entity 순회 (`scanvas_maker.py:4187`)
```python
with _perf(f"DXF entity ingest", self.log):
for entity in msp.query('LWPOLYLINE'):
...
# 모든 6개 타입 루프 동일 컨텍스트 안
```
### 2.14 60FPS 게이트 (PyVista 인터랙티브 뷰어용)
`_open_interactive_viewer` 안 (`scanvas_maker.py:5884` 이후, `p.show()` 직전):
```python
# performance-guardian.md의 FPSLogger 그대로
class _FPSLogger:
def __init__(self, plotter):
self.last_t = time.perf_counter()
self.frames = 0
plotter.iren.add_observer("RenderEvent", self.on_render)
def on_render(self, *_):
self.frames += 1
now = time.perf_counter()
if now - self.last_t > 1.0:
fps = self.frames / (now - self.last_t)
self.log(f" [FPS] {fps:.1f}")
self.frames = 0
self.last_t = now
self._fps_logger = _FPSLogger(p) # 가비지 컬렉션 방지
```
---
## 3. 측정 후 비교 표 템플릿
사용자가 실 도면(예: 사연댐 1km×1km, ~10만 측점)으로 Phase 1 측정 후 채움.
### 3.1 시나리오 A — Step 1 (TIN 생성)
| 핫스팟 | 측정 wall (ms) | 측정 cpu (ms) | 카테고리 | 60FPS 영향 | 비고 |
|---|---|---|---|---|---|
| H19 ezdxf 순회 | | | | | |
| H3 Phase C densify | | | | | |
| H4 Phase B Delaunay | | | | | |
| H6 최종 Delaunay | | | | | |
| 합계 (Step 1 전체) | | | | | 목표 < 5초 |
### 3.2 시나리오 B — Step 1.5 (DEM 확장)
| 핫스팟 | wall | cpu | 카테고리 | 비고 |
|---|---|---|---|---|
| H2 terrarium fetch (캐시 미스) | | | Network | |
| H2 terrarium fetch (캐시 히트) | | | I/O | |
| H5 Step 1.5-a fill | | | CPU | |
| H7 build_extended_terrain_ring | | | CPU | |
| H8 경계 재보간 | | | CPU | |
| 합계 | | | | 목표 < 8초 |
### 3.3 시나리오 C — Step 2 (위성지도 결합)
| 핫스팟 | wall | cpu | 카테고리 | 비고 |
|---|---|---|---|---|
| H1 XYZ 타일 fetch | | | Network | 직렬 16~50타일 |
| H10 material composite | | | CPU | |
| H11 LANCZOS resize | | | CPU | |
| H16 read_texture | | | I/O | |
| H13 unified merge | | | CPU | |
| 합계 | | | | 목표 < 10초 |
### 3.4 시나리오 D — Step 3 (캡처 4종)
| 핫스팟 | wall | cpu | 카테고리 | 비고 |
|---|---|---|---|---|
| H12 capture_textured | | | GPU+CPU | |
| H12 capture_depth | | | GPU+CPU | |
| H12 capture_lineart | | | GPU+CPU | |
| H10/_compose_guide_image | | | CPU | |
| 합계 | | | | 목표 < 6초 |
### 3.5 시나리오 E — 인터랙티브 뷰어
| 측정 | 값 | 목표 | 비고 |
|---|---|---|---|
| 평균 FPS (회전 중) | | ≥ 60 | EDL ON |
| TIN n_cells | | ≤ 100K | 단일 메시 |
| DEM ring n_cells | | ≤ 100K | 단일 메시 |
| 통합 메시 n_cells | | ≤ 500K | 함정 7 한도 |
| 구조물 누적 n_cells | | ≤ 200K 추가 | 합계 ≤ 500K |
---
## 4. 최적화 후보
### H1 (XYZ 타일 직렬 fetch) — **가장 큰 이득**
- **`ThreadPoolExecutor(max_workers=8)`로 병렬화** (함정 4 정석 해법). 타일 IP 분산은 이미 `_SUBDOMAINS` 사용 중.
- 직렬 30초 → 병렬 4~6초 예상.
- **메인스레드 분리**: `btn_draping_callback``threading.Thread(daemon=True).start()` 패턴으로 감싸 GUI 블록 제거 (함정 1).
- Disk 캐시 추가 (BBOX 해시 키, terrarium_grid처럼).
### H2 (terrarium 직렬 fetch)
- 동일하게 `ThreadPoolExecutor`. 캐시 히트면 무관.
- 캐시 디스크 접근만 비동기로 풀어도 200ms 절감.
### H3 (Phase C 10→1m 점진 densify)
- 현재 10단계 모두 실행 → **early-exit**: hull이 bbox의 99% 이상 덮으면 break.
- meshgrid 결과 cKDTree 거리 검사 → numpy `np.in1d` + bbox 마스크로 더 빠르게.
- 매 step마다 ConvexHull 재계산 (Python+Qhull) 회피: 첫 hull로 한 번만 계산해도 충분(점이 추가될수록 hull은 단조 확장).
- **lazy 로드**: Phase C는 사용자가 "TIN 이용 범위" 선택했으면 skip (이미 로직 있음 — 5025줄).
### H4 (Phase B centroid 추가)
- numpy 벡터화 완료, 추가 최적화 거의 불필요. 큰 도면에서 임계값(50m → 100m)으로 절반 줄일 수 있음 (시각 차이 미미).
### H5 (Step 1.5-a)
- H3와 동일 패턴 → 동일 처방. 추가로 cached `_dem_elev_grid` 재사용은 이미 됨 (네트워크 절감).
### H6 (최종 Delaunay)
- scipy Delaunay는 GIL 풀려서 BackgroundThread + `app.after(0, callback)` 가능 (함정 1).
- 큰 도면용 옵션: `qhull_options="Qbb Qc Qz"` 더 빠름.
### H7 (DEM ring outer smooth blend / Laplacian)
- `for k, nb in enumerate(nbrs)` Python 루프 → numpy 벡터화. 평균은 padded array 또는 scipy `ndimage.generic_filter` 가능.
- 0.5~2초 → 50~200ms 예상.
### H9 (`_excavate_tin_for_structures` 루프)
- `for i in band_idx` / `for i, d in enumerate(grid_d)` → 순수 numpy:
```python
d = signed_d[band_idx]
t = np.clip(d / transition_w, 0, 1)
blend = t*t*(3 - 2*t)
inside = d <= 0
work_pts[band_idx, 2] = np.where(inside, pad_z,
pad_z * (1-blend) + orig_pts[band_idx, 2] * blend)
```
- 100~500ms → <50ms. **즉시 가능**.
### H10 (material composite)
- PIL 그대로 두되 **메인스레드 분리** (background thread + after(0)).
- 노이즈 레이어는 큰 PNG 캐싱 (한 번만 생성).
### H12 (캡처 3종 직렬)
- 같은 카메라/뷰포인트라 plotter **재사용** 가능: 한 번 생성 → screenshot 3번 (color/depth/lineart) → close.
- 단 lineart는 `show_edges=True` 다른 mesh, depth는 `add_mesh(color="white")` 다른 마테리얼. 이거 토글이 PyVista에서 가능한지 확인 필요(아마 `actor.GetProperty().SetEdgeVisibility(...)`로 됨).
- 4.5~15초 → 1.5~5초 예상.
- **메인스레드 분리** + 진행률 표시 (함정 1).
### H13 (unified merge + compute_normals)
- 캐싱: `unified_mesh`를 self에 저장, `tin_mesh`/`tin_extension_mesh` 변경 시에만 무효화. 매 `show_3d_preview` 호출마다 1~3초 재계산 → 한 번 후 재사용.
### H16 (`pv.read_texture` 4회 디스크 재읽기)
- `self._cached_texture = None`. draping 시 한 번 읽고 caller에서 재사용. capture/preview에서는 `self._cached_texture`를 직접 사용.
### H17 (EDL 누적)
- 60FPS 검사 후 떨어지면 EDL OFF 옵션 제공. 사용자가 "고품질 / 성능" 토글.
### H18 (overlay 매번 재생성)
- `self._cached_overlay_meshes = None`. layer_geometries 변경 시에만 재빌드.
### H19 (ezdxf 순회)
- 6개 타입 루프 → 한 번 전체 순회 후 dispatch 가능. 그러나 ezdxf `msp.query`가 빠르게 인덱싱 → 큰 차이 없을 수 있음. 측정 후 결정.
### LOD (전체 씬 폴리곤 폭발 대응 — 함정 2/7)
- 현재 코드에 `n_cells > 100_000` 체크 / `decimate(target_reduction=0.7)` 없음. 사용자 "큰 도면 100배"에 무방비.
- **`SceneBudgetTracker` 추가** (`cities_placement_widget.py`에 이미 있다면 두 워크플로 공통 게이트로 끌어올림).
- TIN/DEM ring 메시 추가 직전:
```python
if mesh.n_cells > 100_000:
self.log(f" 메시 단순화: {mesh.n_cells} → ", end="")
mesh = mesh.decimate(target_reduction=0.7)
self.log(f"{mesh.n_cells}")
```
---
## 5. 60 FPS 게이트 검증 방법
`_open_interactive_viewer` 안에 §2.14 FPSLogger 삽입.
사용자 액션:
1. Step 3 클릭 → 인터랙티브 뷰어 열림
2. 10초간 마우스로 회전/줌
3. 콘솔에 `[FPS] 58.3` 같은 라인이 1초마다 찍힘
4. 평균값을 §3.5에 기록
게이트 기준:
- **평균 ≥ 60 FPS** → 통과
- **30 ≤ 평균 < 60** → EDL OFF / 메시 decimate / DEM ring step 키움
- **평균 < 30** → 메시 폴리곤 폭발 의심 → §4 LOD 항목 즉시 적용
---
## 6. 우선순위
### P1 (즉시 — 사용자가 측정 보고 후 다음 라운드)
- **H1**: XYZ 타일 ThreadPoolExecutor 병렬화 + threading.Thread 분리 (사용자 피드백 #11 정확히 매치, 가장 큰 체감 이득)
- **H12**: 캡처 3종 plotter 재사용 + threading 분리 (Step 3 GUI 동결 제거)
- **H9**: 굴착 루프 numpy 벡터화 (즉시 가능, 위험 0)
- **H16**: 텍스처 4회 디스크 재읽기 → 캐싱 (한 줄 수정)
### P2 (다음 — 측정으로 회귀 위험 검증 후)
- **H2**: terrarium 병렬화 + threading
- **H3/H5**: Phase C/Step 1.5-a early-exit + ConvexHull 1회 캐싱
- **H7**: DEM ring Laplacian / outer blend numpy 벡터화
- **H13**: unified merge 결과 캐싱
- **H18**: overlay 메시 캐싱
### P3 (장기 — 큰 도면/구조물 누적 시나리오용)
- LOD/decimate 게이트 도입 (함정 2/7 대비)
- SceneBudgetTracker 통합 (Cities + DXF 워크플로 공통)
- EDL/SSAO ON/OFF 토글 (함정 5 대비, 저사양 PC)
- 텍스처 4K → 2K 다운샘플 옵션 (함정 6 대비)
---
## 7. 작업 흐름 (Phase 1 측정용)
1. 사용자: §2의 instrumentation 패치를 임시로 삽입 (한 번에 다 넣지 말고 P1 핫스팟부터).
2. 사용자: 실 도면(사연댐 권장, 1~2km bbox)으로 Step 1~3 시나리오 실행.
3. 결과 콘솔 로그를 `outputs/perf_baseline_run_YYYYMMDD.log` 저장.
4. §3 표를 채움 → 어디가 정말 느린지 정량 확인.
5. P1 항목부터 패치 라운드 진행.
6. 패치 후 동일 시나리오 재측정 → 회귀 확인.

374
UI_REDESIGN_PLAN.md Normal file
View File

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

141
UV_GUIDE.md Normal file
View File

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

View File

@@ -78,7 +78,9 @@ class GateBuilder:
def _build_spillway_body(self):
"""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) 생성.

136
harness/crash_logger.py Normal file
View File

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

144
pyproject.toml Normal file
View File

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

View File

@@ -1,10 +1,12 @@
import contextlib
import customtkinter as ctk
import 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:

0
tests/__init__.py Normal file
View File

28
tests/conftest.py Normal file
View File

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

214
tests/test_regressions.py Normal file
View File

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