Compare commits
8 Commits
184185c635
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fcbf14ed8 | |||
| fc963007b7 | |||
| 5a44c90ea6 | |||
| 470020cf57 | |||
| 8c6d7f0279 | |||
| e9cc6bfcf4 | |||
| b9342f6726 | |||
| 53d8b53c2f |
94
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,94 @@
|
||||
# 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
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
if [ "${{ matrix.python-version }}" = "3.13" ]; then
|
||||
EXTRAS=".[py313,dev]"
|
||||
else
|
||||
EXTRAS=".[dev]"
|
||||
fi
|
||||
# uv 가 자동으로 Py 버전 fetch + .venv 생성. 별도 `uv python install`
|
||||
# step 불필요 (uv venv 가 내부적으로 처리).
|
||||
uv venv .venv --python ${{ matrix.python-version }}
|
||||
source .venv/bin/activate
|
||||
uv pip install -e "$EXTRAS"
|
||||
|
||||
- name: Ruff lint
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
ruff check --output-format=github
|
||||
|
||||
- name: py_compile (전체 .py)
|
||||
shell: bash
|
||||
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 (회귀)
|
||||
shell: bash
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest -ra --tb=short -m "slow or integration"
|
||||
171
.gitignore
vendored
@@ -1,153 +1,56 @@
|
||||
# ========================================================================
|
||||
# S-CANVAS .gitignore
|
||||
# ========================================================================
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 1. 자격 증명 / 시크릿 (절대 커밋 금지)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# ===== Secrets =====
|
||||
gcp-key.json
|
||||
*-credentials.json
|
||||
*-service-account.json
|
||||
*.key
|
||||
*.pem
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 2. Python
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
.Python
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.egg
|
||||
|
||||
# 가상환경
|
||||
# ===== Virtual envs =====
|
||||
venv/
|
||||
env/
|
||||
venv*/
|
||||
.venv/
|
||||
ENV/
|
||||
.python-version
|
||||
env/
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 3. PyInstaller 빌드 산출물
|
||||
# (scanvas_maker.spec, build.bat 은 추적 — 빌드 재현용)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
build/
|
||||
dist/
|
||||
*.manifest
|
||||
# scanvas_maker.spec 외 자동 생성 spec 만 차단하려면 아래를 활성화:
|
||||
# *.spec
|
||||
# !scanvas_maker.spec
|
||||
# ===== Backups =====
|
||||
*.bak
|
||||
*.bak_*
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 4. 런타임 데이터 (DB · 로그 · 캐시)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# SQLite Job 이력
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
scanvas_jobs.db
|
||||
egview_jobs.db
|
||||
|
||||
# 로그
|
||||
*.log
|
||||
scanvas_harness.log
|
||||
scanvas_diagnostic.log
|
||||
egview_harness.log
|
||||
egview_diagnostic.log
|
||||
Build_log.txt
|
||||
|
||||
# DEM / 타일 / VLM 캐시
|
||||
cache/
|
||||
!cache/.gitkeep
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 5. 런타임 생성 이미지 (Step 2~4 산출물)
|
||||
# 매 실행 시 재생성 — 추적 불필요
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
capture_for_ai.png
|
||||
capture_textured.png
|
||||
depth_map.png
|
||||
lineart_map.png
|
||||
guide_composite.png
|
||||
rendered_birdseye.png
|
||||
satellite_temp.png
|
||||
error.png
|
||||
error*.png
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 6. 구버전 / 미사용 자료 (EG-VIEW 시절 잔존물)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
_unused/
|
||||
SCREENSHOT_LOG/
|
||||
scratch/
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 7. 대용량 / 선택적 자산
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 로컬 GeoTIFF DEM (NGII 5m 등) — 수백 MB
|
||||
cache/dem/local.tif
|
||||
*.tif
|
||||
*.tiff
|
||||
!Design/*.tif
|
||||
!Design/*.tiff
|
||||
|
||||
# 대용량 DXF 샘플은 SAMPLE_CAD/ 하위만 추적, 그 외 *.dxf 는 차단하고 싶다면:
|
||||
# *.dxf
|
||||
# !SAMPLE_CAD/**/*.dxf
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 8. IDE / 에디터 / AI 코딩 도구
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# ===== IDE / OS =====
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.cursorrules
|
||||
.claude/
|
||||
.aider*
|
||||
.cursor/
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints/
|
||||
*.ipynb_checkpoints
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 9. OS 메타파일
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
Thumbs.db
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.Trash-*
|
||||
# ===== Tooling metadata =====
|
||||
.claude/
|
||||
workspace/
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 10. 백업 / 임시
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
*.bak
|
||||
*.backup
|
||||
*.orig
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
# ===== Runtime artifacts =====
|
||||
*.log
|
||||
*.db
|
||||
gate_params.json
|
||||
|
||||
# ===== Cache =====
|
||||
cache/
|
||||
|
||||
# ===== Generated render outputs (재생성 가능) =====
|
||||
capture_for_ai.png
|
||||
capture_textured.png
|
||||
rendered_birdseye.png
|
||||
satellite_temp.png
|
||||
lineart_map.png
|
||||
guide_composite.png
|
||||
depth_map.png
|
||||
error*.png
|
||||
|
||||
# ===== Misc local dev =====
|
||||
dot.tmux.conf
|
||||
Build_log.txt
|
||||
|
||||
55
.pre-commit-config.yaml
Normal 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
@@ -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 끝나면 도달.
|
||||
280
CHANGELOG.md
@@ -10,8 +10,286 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (후속 — UI 3차: 인라인 로그 제거 + InlinePanel)
|
||||
|
||||
### [feat] 사용자 피드백 #4 핵심 — 인라인 로그 제거 + 모든 popup 인라인화
|
||||
|
||||
> **사용자 명시 요청**: "기존 구조에 지도 아래에 있는 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용".
|
||||
|
||||
#### 1. 인라인 로그 패널 제거 (`scanvas_maker.py`)
|
||||
|
||||
- **제거**: `self.textbox = ctk.CTkTextbox(self.main_frame, height=80, ...)` 위젯과 그 grid 배치 (이전 라운드에서 이미 80 으로 축소했지만 본 라운드에서 **완전 제거**).
|
||||
- **layout 재배치**:
|
||||
- `main_frame.grid_rowconfigure(0)` weight 3 → 1, 지도/캔버스가 row 0 전체 차지.
|
||||
- `main_frame.grid_rowconfigure(1)` (로그 행) 제거 — weight 설정 자체 삭제.
|
||||
- `status_bar.grid(row=2, ...)` → `row=1`. 로그 행 제거 후 한 칸 위로.
|
||||
- **`self.log()` 동작 변경**: textbox.insert 대신 백엔드 logger 사용.
|
||||
- 파일 sink: `logs/scanvas.log` (RotatingFileHandler 5MB×5, `harness/crash_logger.py` 의 `get_logger()` 활용).
|
||||
- 추가 sink: `%LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log` (`harness/logger.py` 의 `setup_logging` 통해 구조화 로그).
|
||||
- **즉시 가시성**: status_bar 의 `status_text` 가 짧은 미리보기 (≤80자, 그 이상은 잘림 + …). 사용자가 진행 상황을 한 눈에 보되 인라인 로그 패널은 사라짐.
|
||||
- **효과**: 메인 캔버스 영역 ~25% 확대 (이전 row 1 weight 1 영역 흡수). GUI 메인 thread 의 textbox.insert 부담 제거.
|
||||
|
||||
#### 2. `harness/inline_panel.py` 신규 — `ctk.CTkToplevel` 호환 인라인 오버레이
|
||||
|
||||
별도 OS 창 없이 main_frame 안에 floating frame 으로 렌더하는 drop-in 대체. **드러난 인터페이스가 CTkToplevel 와 동일**해서 호출지 코드는 1줄만 변경 (`ctk.CTkToplevel` → `InlinePanel`).
|
||||
|
||||
**구현 핵심**:
|
||||
- `ctk.CTkFrame` 상속 → `tk.Misc.wait_window(self)` 가 widget destruction 을 기다리는 매커니즘이라 Frame 에서도 동작 (Toplevel 만 동작 X). `wait_window()` 호출지 5곳 (line 971, 2518, 2662, 2968, 6662) 그대로 유지 가능.
|
||||
- `place(relx=0.5, rely=0.5, anchor="center")` 으로 main_frame 중앙 배치.
|
||||
- `geometry("WxH+X+Y")` 파싱 — X/Y 무시 (always center). main_frame 의 95% cap.
|
||||
- 다중 패널 z-order: `_z_counter` + `lift()` 으로 최신 패널이 위.
|
||||
- 타이틀 바: MC Red (#EB001B) 배경, 흰 텍스트, ✕ 닫기 버튼 (피드백 #4 색감 일관).
|
||||
- `protocol("WM_DELETE_WINDOW", fn)` → 내부 핸들러 등록. `grab_set()` → lift+focus 시뮬레이트.
|
||||
- **Toplevel 전용 메서드 no-op**: `iconbitmap`, `iconphoto`, `wm_*`, `attributes`, `overrideredirect`. 호출은 silently ignored.
|
||||
|
||||
#### 3. 12개 `ctk.CTkToplevel` → `InlinePanel` 일괄 치환
|
||||
|
||||
`scanvas_maker.py` 의 popup 생성 라인 12 곳:
|
||||
|
||||
| # | line | 호출 | 용도 |
|
||||
|---|---|---|---|
|
||||
| T1 | 851 | `win = ctk.CTkToplevel(self)` | DXF 레이어 분류 (900×650) |
|
||||
| T2 | 1504 | `win = ctk.CTkToplevel(self)` | 구조물 상세 3D 빌드 (1100×650) |
|
||||
| T3 | 1681 | `win = ctk.CTkToplevel(self)` | 빌드 진행 |
|
||||
| T4 | 1974 | `opt_win = ctk.CTkToplevel(win)` | 렌더 옵션 (T3 자식) |
|
||||
| T5 | 2129 | `dwin = ctk.CTkToplevel(win)` | VLM 결과 (T3 자식) |
|
||||
| T6 | 2451 | `win = ctk.CTkToplevel(self)` | 상세도면 업로드 |
|
||||
| T7 | 2571 | `win = ctk.CTkToplevel(self)` | 치수 확인 (650×500) |
|
||||
| T8 | 2808 | `win = ctk.CTkToplevel(self)` | 계획선 고도 설정 (1280×560) |
|
||||
| T9 | 4710 | `win = ctk.CTkToplevel(self)` | TIN 이용 범위 (1100×920) |
|
||||
| T10 | 6625 | `time_win = ctk.CTkToplevel(self)` | 렌더링 옵션 (380×360) |
|
||||
| T11 | 6985 | `win = ctk.CTkToplevel(self)` | Blender 결과 |
|
||||
| T12 | 7058 | `win = ctk.CTkToplevel(self)` | AI 렌더 결과 |
|
||||
|
||||
치환 패턴 2종 (replace_all): `ctk.CTkToplevel(self)` → `InlinePanel(self)` (10곳), `ctk.CTkToplevel(win)` → `InlinePanel(win)` (2곳, 자식 패널).
|
||||
|
||||
**12 popup 모두 별도 OS 창 없이 main 창 안에서 동작.** 사용자가 ALT-TAB 으로 창 사이 오갈 필요 없음.
|
||||
|
||||
#### 4. 검증
|
||||
|
||||
- `python -m py_compile scanvas_maker.py harness/perf.py harness/crash_logger.py harness/inline_panel.py` 통과.
|
||||
- AST parse OK.
|
||||
- Import smoke test: `import scanvas_maker` 성공, `InlinePanel` 이 진짜 `harness.inline_panel.InlinePanel` 클래스로 로드 (CTkToplevel 폴백 아님).
|
||||
- ruff `check scanvas_maker.py harness/`: All checks passed.
|
||||
- 잔존 검사:
|
||||
- `self.textbox` refs: **0** (완전 제거).
|
||||
- `CTkToplevel` refs: 3 (모두 import 폴백/주석).
|
||||
- `InlinePanel` refs: 15 (12 호출지 + import + fallback).
|
||||
|
||||
#### 5. 잔여 (#4 next round, multi-session)
|
||||
|
||||
- **InlinePanel 동작 검증 (실 GUI)**: 자동 import 검증은 끝났지만 사용자가 실제로 도면 로드 → DXF 레이어 분류 (T1) → 구조물 빌드 (T2) → 등 워크플로 한 번 돌려봐야 모달 동작/wait_window/grab_set 시뮬레이션의 실효성 확인.
|
||||
- **VTK 임베딩**: 6개 `pv.Plotter().show()` → `vtkmodules.tk.vtkTkRenderWidget` 또는 `pyvistaqt.QtInteractor`. PyQt 의존성 추가 필요.
|
||||
- **`messagebox` 63회** → 인라인 토스트/배너 (위험 4건 askyesno 만 모달 유지).
|
||||
- **Inspector 패널 컬럼 (3-column 레이아웃)**: 현재 InlinePanel 은 floating overlay. 영구적 우측 인스펙터 컬럼 (UI_REDESIGN_PLAN.md §2.1) 은 별도 작업.
|
||||
- **메인 thread 블로킹 작업 worker thread 분리**: 그래야 progress_bar animation 실제 동작.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (후속 — CI fix + UI 2차)
|
||||
|
||||
### [fix] Gitea CI uv setup 실패 — `**/uv.lock` 미존재 → setup-uv 액션 abort (#6 후속)
|
||||
|
||||
- **증상**: 7개 push 모두 CI run 10-20초 만에 `failure`. 로그:
|
||||
```
|
||||
::error::No file in /workspace/HYUNJUNGLEE/s-canvas matched to [**/uv.lock]
|
||||
❌ Failure - Main Setup uv (fast Python pkg manager)
|
||||
```
|
||||
- **원인**: `astral-sh/setup-uv@v3` 의 `enable-cache: true` 옵션이 cache key 산출용 `**/uv.lock` 검색 → 미발견 시 hard fail. UV_GUIDE.md §3 에서 권장만 했고 실제 lock 파일은 없었음.
|
||||
- **추가 발견 (uv.lock 생성 시도 시 노출)**:
|
||||
1. `[tool.uv] no-progress = false` — uv 0.11+ 가 모르는 옵션 (deprecated). 제거.
|
||||
2. **dependency 핀 충돌**: `dependencies` 의 `scipy==1.13.1` / `pyproj==3.6.1` vs `[py313]` extras 의 `scipy>=1.14` / `pyproj>=3.7,<4` — uv resolver 가 동시 만족 불가.
|
||||
3. `pyproj>=3.7` 가 Py3.11+ 만 wheel 배포 — `requires-python = ">=3.9"` 와 충돌.
|
||||
|
||||
#### 수정안 (5건)
|
||||
- **`.gitea/workflows/ci.yml`**:
|
||||
- `enable-cache: true` + `cache-dependency-glob: "uv.lock"` (lock 파일 변경 시에만 캐시 갱신).
|
||||
- 별도 `Setup Python` step 제거 — `uv venv --python <ver>` 가 자동 fetch.
|
||||
- install step 단순화: matrix 분기에서 EXTRAS 변수로 `[dev]` vs `[py313,dev]` 선택 후 단일 `uv pip install`.
|
||||
- 모든 `run:` 에 `shell: bash` 명시 (Gitea act-runner 호환).
|
||||
- **`pyproject.toml`**:
|
||||
- `scipy==1.13.1` → `scipy>=1.13,<2`.
|
||||
- `pyproj==3.6.1` → `pyproj>=3.6,<4`.
|
||||
- `numpy==2.0.2` → `numpy>=2.0.2,<3`.
|
||||
- `requires-python = ">=3.9"` → `">=3.11"` (CI matrix Py3.11/3.13 와 일치, Py3.9/3.10 legacy 종료).
|
||||
- `[tool.uv] no-progress = false` 제거.
|
||||
- **`uv.lock` 신규** (438 KB, 89 packages 해결): 다른 머신/CI에서 동일 환경 재현. `uv sync --frozen` 또는 `uv pip install -e ".[dev]" --frozen` 으로 lock 기준 install.
|
||||
|
||||
검증 (로컬): `uv lock` 성공 89 packages 해결, `ruff check` All checks passed.
|
||||
|
||||
### [feat] UI 진행률 인디케이터 + 로그 패널 축소 (#4 부분)
|
||||
|
||||
- **사용자 피드백 #4**: "느리게 느껴짐" → 긴 작업 중 시각적 피드백 부재.
|
||||
- **신규 위젯**: `self.progress_bar = ctk.CTkProgressBar(self.status_bar, mode="indeterminate", width=180, height=10, progress_color="#FF5F00")`. 기본 hidden (pack 안 함). MC overlap orange 색.
|
||||
- **신규 메서드** (`scanvas_maker.py` `SCanvasApp` 안):
|
||||
- `start_progress(label: str | None = None)`: progress_bar pack(side="right") + indeterminate animation 시작 + 옵션 status_text 갱신. `self.after(0, ...)` 로 메인 thread 안전.
|
||||
- `stop_progress(final_label: str | None = None)`: animation 정지 + pack_forget + status_text 옵션 갱신.
|
||||
- **로그 패널 축소**: `self.textbox` height 120 → 80. 인라인 로그 비중 줄여 캔버스 영역 확보. 사용자 피드백 "로그는 백엔드로" 의 점진적 적용 — 완전 제거가 아니라 디스크 (`%LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log` + `logs/scanvas.log`)가 주 기록처임을 주석으로 명시. 다음 라운드에서 toggle 버튼 또는 완전 제거.
|
||||
- **잔여 (#4 next round)**:
|
||||
- `start_progress`/`stop_progress` 를 실제 핫스팟 호출지에 wire (capture pipeline, 위성 타일, TIN densify 등).
|
||||
- 메인 thread 블로킹 작업을 worker thread 로 분리 — 그래야 progress animation 실제 동작.
|
||||
- 12개 `CTkToplevel` 인스펙터 패널 통합 (별도 multi-session).
|
||||
|
||||
### [chore] `harness/perf.py` ruff 정리
|
||||
|
||||
- `Optional[Callable[...]]` → `Callable[...] | None` (UP045, Py3.11+ native union).
|
||||
- `try: ...; except Exception: pass` → `with contextlib.suppress(Exception):` (SIM105).
|
||||
- 사용 안 되는 `# noqa: BLE001` 제거 (RUF100).
|
||||
- 결과: ruff `--no-cache` All checks passed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (후속 — UI 1차)
|
||||
|
||||
### [feat] Mastercard 팔레트 1차 적용 + 인트로 비디오 제거 (#4 부분)
|
||||
|
||||
> **사용자 피드백 #4 부분 진행**: 색감/텍스쳐 위주의 1차 라운드. 단일 창 구조 (CTkToplevel 12개 통합, VTK 임베딩 등) 는 multi-session 작업이라 별도 라운드. 본 commit 은 색채 정체성 + 인트로 제거에 한정.
|
||||
|
||||
#### Mastercard 디자인 토큰 매핑 (10색)
|
||||
- `#2ECC71` (Bootstrap green) → `#22A06B` Brand Green (READY 인디케이터, 12곳).
|
||||
- `#E74C3C` (Bootstrap red) → `#EB001B` Mastercard Red (에러 status, 14곳).
|
||||
- `#F1C40F` (Bootstrap yellow) → `#F79E1B` Mastercard Yellow (경고 status, 7곳).
|
||||
- `#27AE60` / `#1E8449` (CTA 그린/hover) → `#22A06B` / `#1B8454` (5+3곳).
|
||||
- `#E67E22` / `#BA6116` (오렌지 CTA/hover) → `#EB001B` / `#A30013` MC Red (3+1곳) — 주요 액션 버튼이 MC 레드로 통일.
|
||||
- `#343A40` / `#212529` (다크 슬레이트 버튼/hover) → `#1A1A1A` / `#000000` MC Near-black (1+1곳).
|
||||
- `#2b2b2b` (CTk 기본 다크 캔버스) → `#1A1A1A` (1곳, 다크 모드 일관성).
|
||||
|
||||
총 50+ hex literal 갱신. Mastercard 공식 brand 가이드라인 (공개) 기반 — `npx getdesign@latest add mastercard` 시도는 외부 npm 코드 실행 차단으로 건너뛰고 공개 팔레트 적용.
|
||||
|
||||
#### 디자인 의도
|
||||
- **PRIMARY (MC Red `#EB001B`)**: 주 CTA + 에러 상태 일관 적용. 위험 신호와 액션 강조 단일 색.
|
||||
- **ACCENT (MC Yellow `#F79E1B`)**: 경고/노란 status 일관 — Step3 진행 인디케이터 등.
|
||||
- **SUCCESS (Brand Green `#22A06B`)**: READY/완료 인디케이터. MC 자체 그린 토큰 없음 → brand-friendly 톤 선정.
|
||||
- **DARK (`#1A1A1A`)**: 다크 모드 페이지/카드 bg, 다크 버튼 — pure black 직전. 텍스트 가독성 ↑.
|
||||
- 팔레트 문서화 블록: `scanvas_maker.py` line ~33-47 (`Mastercard 디자인 시스템 팔레트` 주석).
|
||||
|
||||
#### 인트로 영상 제거
|
||||
- `splash.py` 삭제 (178 LOC) — `show_intro_splash` 함수 단일 진입점, 더 이상 호출 없음.
|
||||
- `Design/logo_intro.mp4` 삭제 (3.7 MB).
|
||||
- `scanvas_maker.py` 의 호출부 제거 (line ~7044-7054 13줄): try/except + show_intro_splash + import.
|
||||
- 효과: 메인 앱 즉시 기동, 첫 화면까지 12초 fade-in 사라짐 → "느리게 느껴짐" 피드백 일부 완화.
|
||||
|
||||
#### 잔여 (#4 다음 라운드)
|
||||
- **단일 창 구조**: CTkToplevel 12개 (T1~T12) 인스펙터 패널로 통합.
|
||||
- **인라인 로그 패널 제거**: `main_frame.row=1` `CTkTextbox` → harness_log_path() 백엔드 파일.
|
||||
- **VTK 임베딩**: `pv.Plotter().show()` 6개 호출지 → `vtkmodules.tk.vtkTkRenderWidget` 또는 `pyvistaqt.QtInteractor`.
|
||||
- **3-column 레이아웃**: Sidebar(240) / Main Canvas(flex) / Inspector(340).
|
||||
- **messagebox 63회** → 인라인 토스트/배너 (위험한 askyesno 4건만 모달 유지).
|
||||
|
||||
이는 multi-session 작업이라 본 라운드 범위 외. UI_REDESIGN_PLAN.md §3 참조.
|
||||
|
||||
#### 검증
|
||||
- `python -m py_compile scanvas_maker.py harness/perf.py harness/crash_logger.py` 통과.
|
||||
- AST parse OK.
|
||||
- `splash`/`show_intro_splash` 호출 잔존 검사 — 0건 (주석 블록의 1회 'splash' 언급 제외).
|
||||
- `git ls-files | grep -E '...gcp.*key|\\.log$|\\.db$|\\.bak|venv|__pycache__|cache/'` — 0건. .gitignore 정상 동작.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08
|
||||
|
||||
### [merge] Gitea s-canvas 원격(raw upload, 184185c)과 로컬 lint+Phase 0 history 통합
|
||||
|
||||
- **상황**: 원격 `https://gitea.hmac.kr/HYUNJUNGLEE/s-canvas.git` 에 사용자가 site에서 raw upload 한 1회 commit (`184185c`)이 존재. 로컬은 `53d8b53` → `b9342f6` (import + iter1~7 lint cleanup) → `e9cc6bf` (Phase 0 of expert feedback) history. **공통 조상 없음 (unrelated histories)**.
|
||||
- **분석**: 원격에만 있는 파일 0개 (원격은 로컬의 부분집합). 양쪽 다 있고 내용 다른 파일 29개 (raw upload vs lint-applied 버전). 로컬에만 있는 파일 80+ (Phase 0 산출물 + workspace/`_unused/` 등).
|
||||
- **전략**: `git merge --allow-unrelated-histories -X ours`. 충돌 시 로컬 우선으로 lint cleanup 보존.
|
||||
- **결과**: 머지 commit `8c6d7f0`. 자동 머지된 31파일 중 README.md 만 실질 변경 (로컬 0줄 → 원격 404줄 README 흡수 — 빈 파일 vs 내용있음은 `-X ours` 적용 외 단순 합병). 나머지 source/config 28개는 로컬 lint 버전 유지.
|
||||
- **푸시**: 머지 후 fast-forward push 가능. 원격 history 손실 없이 통합.
|
||||
|
||||
### [feat] #11 perf instrumentation — `harness/perf.py` 신규 + `scanvas_maker.py` 5곳 wire
|
||||
|
||||
- **사용자 피드백 #11**: "로딩이 오래 걸리는 부분(위성지도 결합·구조물 빌드 시 등)은 CPU 이용률이 대폭 증가하는 프로세스를 ms 단위로 추적해서 원인을 규명하고 최적화하는 조치 필요".
|
||||
|
||||
#### 신규 모듈 — `harness/perf.py` (54 LOC)
|
||||
- `perf_block(label)` — 컨텍스트 매니저. `with perf_block("XYZ tiles"): ...` 형태로 블록 실행 시간(wall + CPU)을 ms 단위로 측정.
|
||||
- `set_perf_log(callable)` — 외부 sink 등록 (예: `set_perf_log(app.log)` 시 GUI 로그 패널에도 표시).
|
||||
- 출력 형식: `[PERF] {label}: wall={NNN}ms cpu={NNN}ms ({CPU|I/O/Net}-bound)`. `cpu/wall > 0.5` 면 CPU-bound로 분류.
|
||||
|
||||
#### Setup — `scanvas_maker.py` 2곳
|
||||
1. **import 블록 (~line 58)**: `from harness.perf import perf_block, set_perf_log` + ImportError 시 `@contextlib.contextmanager` 노옵 폴백 → `harness/` 모듈 누락 환경에서도 안전.
|
||||
2. **`SCanvasApp.__init__` (~line 613)**: `set_perf_log(self.log)` 등록 — perf 측정 라인이 GUI 텍스트박스에도 표시됨.
|
||||
|
||||
#### Hotspot wraps — `scanvas_maker.py` 3곳 (PERFORMANCE_BASELINE.md 매핑)
|
||||
1. **TIN densify Phase C (line ~4430) → H3**: `with perf_block("TIN densify Phase C (10m→1m)")` 로 10단계 점진 격자 루프 감쌈.
|
||||
2. **위성 타일 다운로드 (line ~5384) → H1**: `with perf_block("위성 타일 다운로드+병합")` 로 `_download_xyz_tiles()` 감쌈 — 사용자 피드백 #11이 명시한 "위성지도 결합" 핫스팟.
|
||||
3. **제어맵 캡처 파이프라인 (line ~5864) → H12**: `with perf_block("control map capture x3 + composite")` 로 textured + depth + lineart 3-stage 캡처 + composite 감쌈.
|
||||
|
||||
#### 출력 예 (실제 측정 시)
|
||||
```
|
||||
[PERF] 위성 타일 다운로드+병합: wall=12340.5ms cpu=860.3ms (I/O/Net-bound)
|
||||
[PERF] TIN densify Phase C (10m→1m): wall=2150.7ms cpu=2080.4ms (CPU-bound)
|
||||
[PERF] control map capture x3 + composite: wall=4520.1ms cpu=3760.8ms (CPU-bound)
|
||||
```
|
||||
|
||||
#### 검증
|
||||
- `python -m py_compile scanvas_maker.py harness/perf.py` 통과. AST parse OK (39 top-level statements).
|
||||
- ruff Green 정식 검증은 다음 세션 (글로벌 ruff 미설치, `uv pip install -e ".[dev]"` 후 `ruff check`).
|
||||
|
||||
#### 다음 라운드 (#11 추가)
|
||||
- 사용자 실제 도면으로 [PERF] 출력 1회 측정 → PERFORMANCE_BASELINE.md "측정 후 비교 표" 채움.
|
||||
- 측정 결과 기반 추가 hotspot wrap (H7·H9·H13·H18 등) + 최적화 (numpy 벡터화 / 스레드 풀 / GIL 해제).
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-28
|
||||
|
||||
### [fix] 화면비 버튼이 텍스트만 떠서 안 보이는 문제 — vtkButtonWidget 로 교체
|
||||
|
||||
- **수정 파일**: `scanvas_maker.py`
|
||||
- **사용자 보고**: 하단에 글씨만 뜨고 버튼이 하나도 없음 → 클릭해도 반응 없음.
|
||||
- **원인**: 초기 구현은 `p.add_text(viewport=True)` 텍스트 액터 14개 + 좌클릭 옵저버
|
||||
로 가짜 버튼을 흉내냄. 하지만 텍스트는 배경/테두리 없이 떠서 시각적으로 버튼처럼
|
||||
안 보였고, 일부 PyVista 버전에서 `viewport=True` 가 무시돼 위치까지 어긋났음.
|
||||
- **수정**: `vtkButtonWidget` + `vtkTexturedButtonRepresentation2D` 기반 진짜 버튼.
|
||||
- PIL 로 78×32px 둥근 사각 버튼 이미지(배경+테두리+한글 텍스트) 를 그리고,
|
||||
`vtkImageData` 로 변환해 `rep.SetButtonTexture(0, img)` 로 적용.
|
||||
- 한글 가독성을 위해 `malgun.ttf` (Windows) 자동 로드, 폴백 체인 포함.
|
||||
- `widget.AddObserver("StateChangedEvent", _cb)` 로 클릭 처리 — 옵저버 우선순위
|
||||
조작이나 `SetAbortFlag` 불필요 (vtkButtonWidget 이 자체 hit-test 수행).
|
||||
- `_reposition_buttons` 헬퍼가 창 너비 기준 가운데 정렬 → `RenderWindow`
|
||||
`ModifiedEvent` 옵저버로 리사이즈에 자동 반응.
|
||||
- 활성 비율 = 금색(#FFD700) 채움 + 진한 텍스트, 비활성 = 다크 그레이 + 옅은 텍스트.
|
||||
|
||||
### [feature] AI 렌더링 워크플로 구체화 — 화면비 락 + 출력 화질 + Enter 확정
|
||||
|
||||
- **수정 파일**: `scanvas_maker.py`, `gemini_renderer.py`
|
||||
- **사용자 요청**: 사진 편집기처럼 (1) 제어맵 추출 시 화면비를 명시 클릭으로 잠그고,
|
||||
(2) 'q' 대신 Enter 로 확정하고, (3) AI 렌더링 다이얼로그에서 출력 화질
|
||||
(HD/FHD/UHD) 을 미리 골라 그 해상도로 조감도가 나오게 만들고 싶음.
|
||||
- **수정**:
|
||||
- `__init__`: `self.extraction_aspect_ratio = None`, `self._aspect_buttons = []`,
|
||||
`self.target_resolution = None` 추가 — Step 3/4 사이 상태 공유.
|
||||
- `_compute_capture_size`: 화면비 락이 있으면 그 비율로 lock, 없으면 기존 창 크기
|
||||
기반(자유 모드)으로 폴백. 호출처(`btn_control_map_callback:5733`,
|
||||
`btn_ai_render_callback:6264`) 두 군데가 자동으로 새 비율 따라감 — 단일 진실 원천.
|
||||
- `_open_interactive_viewer`: 14개 화면비 버튼 행을 viewport-normalized 좌표
|
||||
(`y=0.018`, `BTN_W=0.060`, `BTN_H=0.045`) 로 하단에 배치. 자유/1:1/9:16/16:9/4:5/
|
||||
5:4/3:4/4:3/2:3/3:2/5:7/7:5/1:2/2:1. 활성 버튼은 `▶라벨◀` 금색, 비활성은
|
||||
`[라벨]` 회색. `LeftButtonPressEvent` 옵저버를 priority=10.0 으로 등록 →
|
||||
InteractorStyle 카메라 회전보다 먼저 발화. 클릭 영역 안이면 `SetAbortFlag(1)`
|
||||
로 회전 시작 차단 후 `p.window_size = (cur_w, new_h)` 즉시 변경 + `p.render()`.
|
||||
`_refresh_buttons` 헬퍼가 14 actor 의 텍스트/색을 갱신.
|
||||
- Enter 키: `p.add_key_event("Return", _on_enter)` + `KP_Enter` 둘 다 바인딩.
|
||||
`_on_enter` 는 `_update_hud_and_save()` 후 `p.iren.terminate_app()`. 'q' 는
|
||||
VTK 기본 동작으로 그대로 폴백.
|
||||
- HUD 텍스트: "q로 확정" → "Enter로 확정 (q도 가능)", 라이브 갱신 텍스트에
|
||||
`비율: 16:9` 같은 현재 잠긴 비율 표시 추가.
|
||||
- 사이드바 로그/status: 같은 톤으로 "Enter 키(또는 q)" 안내.
|
||||
- `btn_ai_render_callback`: 다이얼로그를 `360x250` → `380x360` 으로 늘리고
|
||||
"출력 화질" 라디오 3개(HD 720p / FHD 1080p / UHD 4K) 추가. `on_ok` 가 3-튜플
|
||||
`(time, extra, res)` 반환. 화질 + Step 3 화면비를 곱해 `target_resolution =
|
||||
(target_w, target_h)` 계산. `extraction_aspect_ratio` 가 None 이면 캡처 이미지의
|
||||
실제 비율을 사용해 폴백 → 자유 모드도 안전.
|
||||
- `_run_stability_render`: 3-stage 폴백 후 성공한 `rendered` 를 저장 직전에
|
||||
`Image.LANCZOS` 로 `target_resolution` 리사이즈. 변경 시 콘솔 로그.
|
||||
- `gemini_renderer.run_gemini_render`: 동일 로직을 `app.target_resolution` 참조로
|
||||
삽입 (함수 시그니처 변경 없음, 기존 `app` 인스턴스 패턴 유지).
|
||||
- **검증**: `python scanvas_maker.py` → Step 3 진입 시 14개 버튼 행이 보이고,
|
||||
"16:9" 클릭 → 즉시 와이드 비율로 창 크기 변경 + 강조 표시. Enter 로 확정 후
|
||||
`capture_textured.png` 가 16:9 비율(1536×864 등 8배수). Step 4 에서
|
||||
"FHD (1080p)" 선택 → 콘솔 "목표 해상도: 1920x1080" 로그 + 결과
|
||||
`rendered_birdseye.png` 가 정확히 1920×1080. 자유 모드도 회귀 없이 동작.
|
||||
|
||||
### [fix] 제어맵·조감도 화면비 보존 — 뷰어 창 크기 그대로 캡처
|
||||
|
||||
- **수정 파일**: `scanvas_maker.py`
|
||||
@@ -153,7 +431,7 @@
|
||||
|
||||
- **파일**: `scanvas_maker.py`, **신규** `splash.py`
|
||||
- **사용자 요청**:
|
||||
1. Vworld API 키 기본값으로 프리필. (※ 키 값은 공개 repo 보안상 redact 됨 → 환경변수 `VWORLD_API_KEY` 로 이관)
|
||||
1. Vworld API 키 `383CB30A-2AD8-3199-8A7B-215DE3E4280C` 기본값으로 프리필.
|
||||
2. 로고를 `logo_V2.png` 로 교체, 뒷배경(다크 네이비) 제거해 GUI 배경과 동일하게.
|
||||
3. 프로그램 시작 시 `logo_intro.mp4` 로 역동적 로딩 스플래시 구현 (JS 등 가능).
|
||||
- **수정**:
|
||||
|
||||
127
CONTRIBUTING.md
Normal 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
@@ -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
@@ -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
@@ -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 부터.
|
||||
141
UV_GUIDE.md
Normal 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
|
||||
```
|
||||
0
_unused/REF_BY_SEOK/__init__.py
Normal file
137
_unused/REF_BY_SEOK/logger.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""로거 - SQLite DB + structlog 기반 작업 이력 추적."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Column, DateTime, Float, Integer, String, Text, create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
|
||||
# ──────────────────────────── ORM 모델 ────────────────────────────
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class JobRecord(Base):
|
||||
"""조감도 생성 작업 1건의 이력 레코드."""
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
dxf_path = Column(String(512), nullable=False)
|
||||
dxf_hash = Column(String(32))
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
seed = Column(Integer)
|
||||
prompt_version = Column(String(32))
|
||||
prompt_hash = Column(String(32))
|
||||
status = Column(String(16), default="pending") # pending / running / done / failed
|
||||
output_path = Column(String(512))
|
||||
quality_score = Column(Float)
|
||||
error_message = Column(Text)
|
||||
latency_ms = Column(Float)
|
||||
|
||||
|
||||
# ──────────────────────────── DB 세션 ────────────────────────────
|
||||
|
||||
_engine = None
|
||||
_SessionFactory = None
|
||||
|
||||
|
||||
def init_db(db_path: str | Path = "cad_aerial_gen.db"):
|
||||
global _engine, _SessionFactory
|
||||
_engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
Base.metadata.create_all(_engine)
|
||||
_SessionFactory = sessionmaker(bind=_engine)
|
||||
|
||||
|
||||
def get_db_session() -> Session:
|
||||
if _SessionFactory is None:
|
||||
init_db()
|
||||
return _SessionFactory()
|
||||
|
||||
|
||||
# ──────────────────────────── structlog 설정 ────────────────────────────
|
||||
|
||||
def setup_logging(log_file: Optional[Path] = None, level: str = "INFO"):
|
||||
"""콘솔 + 파일 동시 로깅을 설정한다."""
|
||||
handlers = [logging.StreamHandler(sys.stdout)]
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
handlers.append(logging.FileHandler(str(log_file), encoding="utf-8"))
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
handlers=handlers,
|
||||
)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
|
||||
structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, level.upper(), logging.INFO)
|
||||
),
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str = "cad_aerial_gen"):
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
# ──────────────────────────── 작업 이력 헬퍼 ────────────────────────────
|
||||
|
||||
class JobLogger:
|
||||
"""작업 이력 CRUD 래퍼."""
|
||||
|
||||
def create_job(self, db: Session, dxf_path: str, dxf_hash: str = "") -> JobRecord:
|
||||
record = JobRecord(dxf_path=dxf_path, dxf_hash=dxf_hash, status="pending")
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
def start_job(self, db: Session, job_id: int, seed: int, prompt_version: str, prompt_hash: str):
|
||||
record = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if record:
|
||||
record.status = "running"
|
||||
record.seed = seed
|
||||
record.prompt_version = prompt_version
|
||||
record.prompt_hash = prompt_hash
|
||||
db.commit()
|
||||
|
||||
def complete_job(
|
||||
self,
|
||||
db: Session,
|
||||
job_id: int,
|
||||
output_path: str,
|
||||
quality_score: float,
|
||||
latency_ms: float,
|
||||
):
|
||||
record = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if record:
|
||||
record.status = "done"
|
||||
record.output_path = output_path
|
||||
record.quality_score = quality_score
|
||||
record.latency_ms = latency_ms
|
||||
db.commit()
|
||||
|
||||
def fail_job(self, db: Session, job_id: int, error: str):
|
||||
record = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if record:
|
||||
record.status = "failed"
|
||||
record.error_message = error
|
||||
db.commit()
|
||||
|
||||
def list_jobs(self, db: Session, limit: int = 50):
|
||||
return db.query(JobRecord).order_by(JobRecord.id.desc()).limit(limit).all()
|
||||
58
_unused/REF_BY_SEOK/prompt_registry.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""프롬프트 레지스트리 - 버전 관리 및 재현 가능성 보장."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class PromptRegistry:
|
||||
"""프롬프트 템플릿 버전을 관리하고 변경 이력을 추적한다."""
|
||||
|
||||
def __init__(self, templates_dir: Path):
|
||||
self.templates_dir = templates_dir
|
||||
|
||||
def list_versions(self) -> List[str]:
|
||||
"""사용 가능한 템플릿 버전 목록을 반환한다 (최신순)."""
|
||||
yamls = sorted(self.templates_dir.glob("v*.yaml"), reverse=True)
|
||||
return [p.stem for p in yamls]
|
||||
|
||||
def latest_version(self) -> Optional[str]:
|
||||
versions = self.list_versions()
|
||||
return versions[0] if versions else None
|
||||
|
||||
def load_template(self, version: str) -> Dict:
|
||||
path = self.templates_dir / f"{version}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"템플릿 버전 {version}이 없습니다.")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def compare(self, version_a: str, version_b: str) -> Dict:
|
||||
"""두 버전의 차이점을 반환한다."""
|
||||
a = self.load_template(version_a)
|
||||
b = self.load_template(version_b)
|
||||
diff = {}
|
||||
all_keys = set(a) | set(b)
|
||||
for key in all_keys:
|
||||
va, vb = a.get(key), b.get(key)
|
||||
if va != vb:
|
||||
diff[key] = {"old": va, "new": vb}
|
||||
return diff
|
||||
|
||||
def save_new_version(self, new_version: str, template: Dict) -> Path:
|
||||
"""새 버전 템플릿을 저장한다."""
|
||||
path = self.templates_dir / f"{new_version}.yaml"
|
||||
if path.exists():
|
||||
raise FileExistsError(f"버전 {new_version}이 이미 존재합니다.")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(template, f, allow_unicode=True, default_flow_style=False)
|
||||
return path
|
||||
|
||||
def get_version_for_hash(self, prompt_hash: str, db_session) -> Optional[str]:
|
||||
"""프롬프트 해시로 사용된 버전을 역조회한다."""
|
||||
from harness.logger import JobRecord
|
||||
record = db_session.query(JobRecord).filter_by(prompt_hash=prompt_hash).first()
|
||||
return record.prompt_version if record else None
|
||||
98
_unused/REF_BY_SEOK/quality_validator.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""품질 검증기 - 생성된 이미지가 기준을 충족하는지 자동 검사한다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
raise ImportError("opencv-python, Pillow이 필요합니다: pip install opencv-python Pillow")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
passed: bool
|
||||
score: float # 0.0 ~ 1.0 종합 품질 점수
|
||||
resolution_ok: bool
|
||||
sharpness_ok: bool
|
||||
color_diversity_ok: bool
|
||||
messages: List[str]
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
status = "PASS" if self.passed else "FAIL"
|
||||
return f"[{status}] score={self.score:.2f} | " + " | ".join(self.messages)
|
||||
|
||||
|
||||
class QualityValidator:
|
||||
"""이미지 품질을 자동으로 검증한다."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_resolution: int = 2048,
|
||||
sharpness_threshold: float = 100.0,
|
||||
color_diversity_threshold: float = 0.15,
|
||||
):
|
||||
self.min_resolution = min_resolution
|
||||
self.sharpness_threshold = sharpness_threshold
|
||||
self.color_diversity_threshold = color_diversity_threshold
|
||||
|
||||
def validate(self, image_path: Path) -> ValidationResult:
|
||||
if not image_path.exists():
|
||||
return ValidationResult(
|
||||
passed=False, score=0.0,
|
||||
resolution_ok=False, sharpness_ok=False, color_diversity_ok=False,
|
||||
messages=["파일이 존재하지 않음"],
|
||||
)
|
||||
|
||||
img_pil = Image.open(image_path).convert("RGB")
|
||||
img_cv = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
|
||||
|
||||
resolution_ok, res_msg = self._check_resolution(img_pil)
|
||||
sharpness_ok, sharp_score, sharp_msg = self._check_sharpness(img_cv)
|
||||
color_ok, color_msg = self._check_color_diversity(img_cv)
|
||||
|
||||
scores = [
|
||||
1.0 if resolution_ok else 0.0,
|
||||
min(sharp_score / (self.sharpness_threshold * 3), 1.0),
|
||||
1.0 if color_ok else 0.3,
|
||||
]
|
||||
overall_score = float(np.mean(scores))
|
||||
passed = resolution_ok and sharpness_ok and color_ok
|
||||
|
||||
return ValidationResult(
|
||||
passed=passed,
|
||||
score=overall_score,
|
||||
resolution_ok=resolution_ok,
|
||||
sharpness_ok=sharpness_ok,
|
||||
color_diversity_ok=color_ok,
|
||||
messages=[res_msg, sharp_msg, color_msg],
|
||||
)
|
||||
|
||||
def _check_resolution(self, img: Image.Image):
|
||||
w, h = img.size
|
||||
ok = w >= self.min_resolution and h >= self.min_resolution
|
||||
msg = f"해상도={w}x{h} {'OK' if ok else f'(최소 {self.min_resolution} 필요)'}"
|
||||
return ok, msg
|
||||
|
||||
def _check_sharpness(self, img_cv):
|
||||
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
|
||||
lap_var = cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||
ok = lap_var >= self.sharpness_threshold
|
||||
msg = f"선명도={lap_var:.1f} {'OK' if ok else f'(임계값 {self.sharpness_threshold})'}"
|
||||
return ok, float(lap_var), msg
|
||||
|
||||
def _check_color_diversity(self, img_cv):
|
||||
"""색상 다양성 검사 - 단색 평면 출력을 탐지한다."""
|
||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2HSV)
|
||||
s_channel = hsv[:, :, 1].astype(np.float32) / 255.0
|
||||
mean_saturation = float(s_channel.mean())
|
||||
ok = mean_saturation >= self.color_diversity_threshold
|
||||
msg = f"색상다양성={mean_saturation:.3f} {'OK' if ok else f'(임계값 {self.color_diversity_threshold})'}"
|
||||
return ok, msg
|
||||
66
_unused/REF_BY_SEOK/seed_manager.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Seed 관리자 - 작업별 Seed 고정 및 추적."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from harness.logger import JobRecord, get_db_session
|
||||
|
||||
|
||||
class SeedManager:
|
||||
"""DXF 파일 해시 기반 결정론적 seed를 생성하고 이력을 관리한다."""
|
||||
|
||||
MAX_SEED = 2**32 - 1
|
||||
|
||||
def get_seed(
|
||||
self,
|
||||
file_hash: str,
|
||||
fixed_seed: Optional[int] = None,
|
||||
deterministic: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
Args:
|
||||
file_hash: DXF 파일의 SHA256 해시 앞 16자
|
||||
fixed_seed: 사용자가 직접 지정한 seed (None이면 자동)
|
||||
deterministic: True면 파일 해시 기반, False면 랜덤
|
||||
"""
|
||||
if fixed_seed is not None:
|
||||
return int(fixed_seed) % (self.MAX_SEED + 1)
|
||||
|
||||
if deterministic:
|
||||
return self._hash_to_seed(file_hash)
|
||||
|
||||
return random.randint(0, self.MAX_SEED)
|
||||
|
||||
def get_or_create_seed(
|
||||
self,
|
||||
db: Session,
|
||||
job_id: int,
|
||||
file_hash: str,
|
||||
fixed_seed: Optional[int] = None,
|
||||
deterministic: bool = True,
|
||||
) -> int:
|
||||
"""DB에서 기존 seed를 조회하거나 새로 생성한다."""
|
||||
existing = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if existing and existing.seed is not None:
|
||||
return existing.seed
|
||||
|
||||
seed = self.get_seed(file_hash, fixed_seed, deterministic)
|
||||
if existing:
|
||||
existing.seed = seed
|
||||
db.commit()
|
||||
return seed
|
||||
|
||||
@staticmethod
|
||||
def _hash_to_seed(file_hash: str) -> int:
|
||||
"""파일 해시를 정수 seed로 변환한다."""
|
||||
digest = hashlib.sha256(file_hash.encode()).digest()
|
||||
return int.from_bytes(digest[:4], "big")
|
||||
|
||||
@staticmethod
|
||||
def describe(seed: int) -> str:
|
||||
return f"seed={seed} (0x{seed:08X})"
|
||||
BIN
_unused/SCREENSHOT_lOG/1.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
_unused/SCREENSHOT_lOG/10.png
Normal file
|
After Width: | Height: | Size: 776 KiB |
BIN
_unused/SCREENSHOT_lOG/2.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
_unused/SCREENSHOT_lOG/3.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
_unused/SCREENSHOT_lOG/4.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
_unused/SCREENSHOT_lOG/5.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
_unused/SCREENSHOT_lOG/6.png
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
_unused/SCREENSHOT_lOG/7.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
_unused/SCREENSHOT_lOG/8.png
Normal file
|
After Width: | Height: | Size: 591 KiB |
BIN
_unused/SCREENSHOT_lOG/9.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
_unused/SCREENSHOT_lOG/rainny.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
5
_unused/ai_studio_prompt.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Transform this aerial/satellite terrain image into a high-quality photorealistic bird's-eye view rendering. IMPORTANT: Keep the EXACT same terrain layout, structures, roads, water bodies from the input image. Only enhance the visual quality - add realistic textures, lighting, vegetation detail, water reflections. Do NOT change the composition or add/remove any major features.
|
||||
|
||||
Style: oblique aerial perspective, 3/4 view showing terrain depth, bright daylight, clear blue sky, sharp shadows, vivid green vegetation, enhance the existing satellite terrain texture and details, maintain exact terrain shape, contours, and layout from the input image, preserve water bodies, roads, and structural positions precisely, do NOT add or remove any major landscape features, photorealistic architectural visualization, professional drone photography quality, 8K ultra sharp detail, high dynamic range, realistic vegetation depth and canopy textures, natural water reflections and surface detail
|
||||
|
||||
Negative: blurry, low quality, distorted, watermark, text, logo, cartoon, anime, illustration, painting, sketch, oversaturated, underexposed, noisy, artifacts, completely different scene, unrelated content, changed terrain layout, moved structures, wrong topology
|
||||
221
_unused/install.cmd
Normal file
@@ -0,0 +1,221 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Claude Code Windows CMD Bootstrap Script
|
||||
REM Installs Claude Code for environments where PowerShell is not available
|
||||
|
||||
REM Parse command line argument
|
||||
set "TARGET=%~1"
|
||||
if "!TARGET!"=="" set "TARGET=latest"
|
||||
|
||||
REM Validate target parameter
|
||||
if /i "!TARGET!"=="stable" goto :target_valid
|
||||
if /i "!TARGET!"=="latest" goto :target_valid
|
||||
echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul
|
||||
if !ERRORLEVEL! equ 0 goto :target_valid
|
||||
|
||||
echo Usage: %0 [stable^|latest^|VERSION] >&2
|
||||
echo Example: %0 1.0.58 >&2
|
||||
exit /b 1
|
||||
|
||||
:target_valid
|
||||
|
||||
REM Check for 64-bit Windows
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid
|
||||
if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid
|
||||
if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid
|
||||
|
||||
echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2
|
||||
exit /b 1
|
||||
|
||||
:arch_valid
|
||||
|
||||
REM Set constants
|
||||
set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"
|
||||
set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads"
|
||||
REM Use native ARM64 binary on ARM64 Windows, x64 otherwise
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||
set "PLATFORM=win32-arm64"
|
||||
) else (
|
||||
set "PLATFORM=win32-x64"
|
||||
)
|
||||
|
||||
REM Create download directory
|
||||
if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!"
|
||||
|
||||
REM Check for curl availability
|
||||
curl --version >nul 2>&1
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo curl is required but not available. Please install curl or use PowerShell installer. >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Always download latest version (which has the most up-to-date installer)
|
||||
call :download_file "!GCS_BUCKET!/latest" "!DOWNLOAD_DIR!\latest"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Failed to get latest version >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Read version from file
|
||||
set /p VERSION=<"!DOWNLOAD_DIR!\latest"
|
||||
del "!DOWNLOAD_DIR!\latest"
|
||||
|
||||
REM Download manifest
|
||||
call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Failed to get manifest >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Extract checksum from manifest
|
||||
call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Platform !PLATFORM! not found in manifest >&2
|
||||
del "!DOWNLOAD_DIR!\manifest.json" 2>nul
|
||||
exit /b 1
|
||||
)
|
||||
del "!DOWNLOAD_DIR!\manifest.json"
|
||||
|
||||
REM Download binary
|
||||
set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe"
|
||||
call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Failed to download binary >&2
|
||||
if exist "!BINARY_PATH!" del "!BINARY_PATH!"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Verify checksum
|
||||
call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Checksum verification failed >&2
|
||||
del "!BINARY_PATH!"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Run claude install to set up launcher and shell integration
|
||||
echo Setting up Claude Code...
|
||||
"!BINARY_PATH!" install "!TARGET!"
|
||||
set "INSTALL_RESULT=!ERRORLEVEL!"
|
||||
|
||||
REM Clean up downloaded file
|
||||
REM Wait a moment for any file handles to be released
|
||||
timeout /t 1 /nobreak >nul 2>&1
|
||||
del /f "!BINARY_PATH!" >nul 2>&1
|
||||
if exist "!BINARY_PATH!" (
|
||||
echo Warning: Could not remove temporary file: !BINARY_PATH!
|
||||
)
|
||||
|
||||
if !INSTALL_RESULT! neq 0 (
|
||||
echo Installation failed >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installation complete^^!
|
||||
echo.
|
||||
exit /b 0
|
||||
|
||||
REM ============================================================================
|
||||
REM SUBROUTINES
|
||||
REM ============================================================================
|
||||
|
||||
:download_file
|
||||
REM Downloads a file using curl
|
||||
REM Args: %1=URL, %2=OutputPath
|
||||
set "URL=%~1"
|
||||
set "OUTPUT=%~2"
|
||||
|
||||
curl -fsSL "!URL!" -o "!OUTPUT!"
|
||||
exit /b !ERRORLEVEL!
|
||||
|
||||
:parse_manifest
|
||||
REM Parse JSON manifest to extract checksum for platform
|
||||
REM Args: %1=ManifestPath, %2=Platform
|
||||
set "MANIFEST_PATH=%~1"
|
||||
set "PLATFORM_NAME=%~2"
|
||||
set "EXPECTED_CHECKSUM="
|
||||
|
||||
REM Use findstr to find platform section, then look for checksum
|
||||
set "FOUND_PLATFORM="
|
||||
set "IN_PLATFORM_SECTION="
|
||||
|
||||
REM Read the manifest line by line
|
||||
for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do (
|
||||
set "LINE=%%i"
|
||||
|
||||
REM Check if this line contains our platform
|
||||
echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul
|
||||
if !ERRORLEVEL! equ 0 (
|
||||
set "IN_PLATFORM_SECTION=1"
|
||||
)
|
||||
|
||||
REM If we're in the platform section, look for checksum
|
||||
if defined IN_PLATFORM_SECTION (
|
||||
echo !LINE! | findstr /c:"\"checksum\":" >nul
|
||||
if !ERRORLEVEL! equ 0 (
|
||||
REM Extract checksum value
|
||||
for /f "tokens=2 delims=:" %%j in ("!LINE!") do (
|
||||
set "CHECKSUM_PART=%%j"
|
||||
REM Remove quotes, whitespace, and comma
|
||||
set "CHECKSUM_PART=!CHECKSUM_PART: =!"
|
||||
set "CHECKSUM_PART=!CHECKSUM_PART:"=!"
|
||||
set "CHECKSUM_PART=!CHECKSUM_PART:,=!"
|
||||
|
||||
REM Check if it looks like a SHA256 (64 hex chars)
|
||||
if not "!CHECKSUM_PART!"=="" (
|
||||
call :check_length "!CHECKSUM_PART!" 64
|
||||
if !ERRORLEVEL! equ 0 (
|
||||
set "EXPECTED_CHECKSUM=!CHECKSUM_PART!"
|
||||
exit /b 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
REM Check if we've left the platform section (closing brace)
|
||||
echo !LINE! | findstr /c:"}" >nul
|
||||
if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION="
|
||||
)
|
||||
)
|
||||
|
||||
if "!EXPECTED_CHECKSUM!"=="" exit /b 1
|
||||
exit /b 0
|
||||
|
||||
:check_length
|
||||
REM Check if string length equals expected length
|
||||
REM Args: %1=String, %2=ExpectedLength
|
||||
set "STR=%~1"
|
||||
set "EXPECTED_LEN=%~2"
|
||||
set "LEN=0"
|
||||
:count_loop
|
||||
if "!STR:~%LEN%,1!"=="" goto :count_done
|
||||
set /a LEN+=1
|
||||
goto :count_loop
|
||||
:count_done
|
||||
if %LEN%==%EXPECTED_LEN% exit /b 0
|
||||
exit /b 1
|
||||
|
||||
:verify_checksum
|
||||
REM Verify file checksum using certutil
|
||||
REM Args: %1=FilePath, %2=ExpectedChecksum
|
||||
set "FILE_PATH=%~1"
|
||||
set "EXPECTED=%~2"
|
||||
|
||||
for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do (
|
||||
set "ACTUAL=%%i"
|
||||
set "ACTUAL=!ACTUAL: =!"
|
||||
if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done
|
||||
if "!ACTUAL!" neq "" (
|
||||
if /i "!ACTUAL!"=="!EXPECTED!" (
|
||||
exit /b 0
|
||||
) else (
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:verify_done
|
||||
exit /b 1
|
||||
26
_unused/nano_banana2.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from google import genai
|
||||
from google.genai import types as gtypes
|
||||
from PIL import Image
|
||||
import os
|
||||
|
||||
client = genai.Client(
|
||||
vertexai=True,
|
||||
project=os.environ["GCP_PROJECT_ID"],
|
||||
location="global", # gemini-3.x 이미지 모델은 글로벌 전용
|
||||
)
|
||||
|
||||
prompt = "여기에 프롬프트"
|
||||
image = Image.open("input.png")
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-3-pro-image-preview", # Nano Banana 2
|
||||
contents=[prompt, image],
|
||||
config=gtypes.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"],
|
||||
),
|
||||
)
|
||||
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
with open("output.png", "wb") as f:
|
||||
f.write(part.inline_data.data)
|
||||
52
_unused/scratch/analyze_dxf.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
|
||||
def analyze_dxf(filepath):
|
||||
print(f"Analyzing: {filepath}")
|
||||
doc = ezdxf.readfile(filepath)
|
||||
msp = doc.modelspace()
|
||||
|
||||
points = []
|
||||
|
||||
# 1. LWPOLYLINE, POLYLINE
|
||||
for entity in msp.query('LWPOLYLINE POLYLINE'):
|
||||
elevation = 0
|
||||
if hasattr(entity, 'dxf'):
|
||||
elevation = entity.dxf.elevation if hasattr(entity.dxf, 'elevation') else 0
|
||||
|
||||
for p in entity.get_points():
|
||||
if len(p) >= 3:
|
||||
points.append((p[0], p[1], p[2]))
|
||||
else:
|
||||
points.append((p[0], p[1], elevation))
|
||||
|
||||
# 2. LINE
|
||||
for entity in msp.query('LINE'):
|
||||
points.append(entity.dxf.start)
|
||||
points.append(entity.dxf.end)
|
||||
|
||||
if not points:
|
||||
print("No points found!")
|
||||
return
|
||||
|
||||
pts = np.array(points)
|
||||
min_vals = np.min(pts, axis=0)
|
||||
max_vals = np.max(pts, axis=0)
|
||||
ranges = max_vals - min_vals
|
||||
|
||||
print("\n[Statistics]")
|
||||
print(f"Total points: {len(pts)}")
|
||||
print(f"X: {min_vals[0]:.2f} to {max_vals[0]:.2f} (Range: {ranges[0]:.2f})")
|
||||
print(f"Y: {min_vals[1]:.2f} to {max_vals[1]:.2f} (Range: {ranges[1]:.2f})")
|
||||
print(f"Z: {min_vals[2]:.2f} to {max_vals[2]:.2f} (Range: {ranges[2]:.2f})")
|
||||
|
||||
# Ratio
|
||||
if ranges[0] > 0 and ranges[1] > 0:
|
||||
xy_avg_range = (ranges[0] + ranges[1]) / 2
|
||||
z_ratio = (ranges[2] / xy_avg_range) * 100
|
||||
print(f"Z-Ratio to XY: {z_ratio:.4f}%")
|
||||
if z_ratio < 0.1:
|
||||
print("WARNING: Z-range is extremely small compared to XY. Vertical exaggeration is required.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
analyze_dxf('사연댐 전체계획 평면도_contour.dxf')
|
||||
1080
_unused/structure_ui.py
Normal file
400
_unused/test_gate_render.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""구조물 조감도 스탠드얼론 테스트 스크립트.
|
||||
|
||||
Gate_Sample DXF → 파라미터 추출 → 3D 모델 → 다각도 캡처 → AI 렌더링.
|
||||
|
||||
사용법:
|
||||
python test_gate_render.py
|
||||
python test_gate_render.py --interactive # 인터랙티브 3D 뷰어
|
||||
python test_gate_render.py --ai # AI 렌더링 포함 (GEMINI_API_KEY 필요)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
from spillway_parser import parse_spillway_dxf, SpillwayParams
|
||||
from spillway_3d_builder import SpillwayBuilder, COLORS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 렌더링 유틸
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_all_meshes(plotter: pv.Plotter, meshes: list):
|
||||
"""메쉬 리스트를 플로터에 추가."""
|
||||
for mesh, color, opacity in meshes:
|
||||
try:
|
||||
plotter.add_mesh(
|
||||
mesh,
|
||||
color=color,
|
||||
opacity=opacity,
|
||||
smooth_shading=True,
|
||||
specular=0.3,
|
||||
specular_power=10,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" mesh 추가 실패: {e}")
|
||||
|
||||
|
||||
def compute_camera_for_birdseye(params: SpillwayParams,
|
||||
elevation_deg: float = 35.0,
|
||||
azimuth_deg: float = 225.0,
|
||||
zoom: float = 1.2) -> tuple:
|
||||
"""여수로 구조물을 프레임에 담는 카메라 위치 계산.
|
||||
|
||||
elevation_deg: 앙각 (수평=0°, 수직 아래로=90°)
|
||||
azimuth_deg: 방위각 (북=0°, 동=90°, 남=180°, 서=270°)
|
||||
zoom: 값이 클수록 카메라가 멀어짐 (전체를 더 많이 담음)
|
||||
"""
|
||||
# 씬 전체 범위 (수면 + 구조물 + 하류 에이프런 포함)
|
||||
# 수면: Y ∈ [-40, 0.5], 구조물: Y ∈ [0, pier_length], 하류: Y ∈ [pier_length, pier_length+30]
|
||||
scene_y_min = -40.0
|
||||
scene_y_max = params.pier_length + 30.0
|
||||
scene_x_span = params.total_span
|
||||
scene_z_min = min(params.el_upstream_bed, params.el_downstream, params.el_gate_sill) - 1.0
|
||||
scene_z_max = params.el_bridge_top + 6.0 # 권양기 지붕까지
|
||||
|
||||
# 씬의 중심 (focal point) — 구조물 위주
|
||||
cx = scene_x_span / 2
|
||||
cy = (0 + params.pier_length) / 2 # 구조물 중심
|
||||
cz = (params.el_gate_sill + params.el_bridge_top) / 2
|
||||
|
||||
# 씬의 대각선 크기
|
||||
scene_dx = scene_x_span * 1.2 # X 방향 여유
|
||||
scene_dy = scene_y_max - scene_y_min
|
||||
scene_dz = scene_z_max - scene_z_min
|
||||
scene_diag = math.sqrt(scene_dx ** 2 + scene_dy ** 2 + scene_dz ** 2)
|
||||
|
||||
# 카메라 거리: PyVista 기본 viewAngle ≈ 30° 가정, tan(15°) ≈ 0.268
|
||||
# 프레임 꽉 채우려면 dist = (scene_diag/2) / tan(15°) ≈ scene_diag * 1.87
|
||||
dist = scene_diag * 1.0 * zoom
|
||||
|
||||
# 방위각/앙각 → 구면 좌표
|
||||
el_rad = math.radians(elevation_deg)
|
||||
az_rad = math.radians(azimuth_deg)
|
||||
|
||||
dx = dist * math.cos(el_rad) * math.sin(az_rad)
|
||||
dy = -dist * math.cos(el_rad) * math.cos(az_rad) # Y축 반전 (북→양)
|
||||
dz = dist * math.sin(el_rad)
|
||||
|
||||
camera_pos = (cx + dx, cy + dy, cz + dz)
|
||||
focal_point = (cx, cy, cz)
|
||||
view_up = (0, 0, 1)
|
||||
|
||||
return camera_pos, focal_point, view_up
|
||||
|
||||
|
||||
def capture_view(params: SpillwayParams, meshes: list,
|
||||
elevation_deg: float, azimuth_deg: float,
|
||||
size: int = 1536, bg_color: str = "#C8D4E0",
|
||||
zoom: float = 1.3) -> Image.Image:
|
||||
"""지정한 각도에서 3D 씬을 캡처."""
|
||||
plotter = pv.Plotter(off_screen=True, window_size=(size, size))
|
||||
plotter.set_background(bg_color)
|
||||
|
||||
add_all_meshes(plotter, meshes)
|
||||
|
||||
# 카메라 설정
|
||||
cam_pos, focal, up = compute_camera_for_birdseye(
|
||||
params, elevation_deg, azimuth_deg, zoom
|
||||
)
|
||||
plotter.camera_position = [cam_pos, focal, up]
|
||||
|
||||
# 조명 설정: 기본 헤드라이트 + 방향 조명
|
||||
plotter.enable_3_lights()
|
||||
|
||||
img_arr = plotter.screenshot(return_img=True, window_size=(size, size))
|
||||
plotter.close()
|
||||
|
||||
img = Image.fromarray(img_arr)
|
||||
return img
|
||||
|
||||
|
||||
def capture_depth(params: SpillwayParams, meshes: list,
|
||||
elevation_deg: float, azimuth_deg: float,
|
||||
size: int = 1536, zoom: float = 1.3) -> Image.Image:
|
||||
"""depth map 캡처 (제어맵용)."""
|
||||
plotter = pv.Plotter(off_screen=True, window_size=(size, size))
|
||||
plotter.set_background("black")
|
||||
|
||||
for mesh, _, _ in meshes:
|
||||
plotter.add_mesh(mesh, color="white", smooth_shading=False)
|
||||
|
||||
cam_pos, focal, up = compute_camera_for_birdseye(params, elevation_deg, azimuth_deg, zoom)
|
||||
plotter.camera_position = [cam_pos, focal, up]
|
||||
|
||||
# show()로 렌더 파이프라인 초기화 후 depth 추출
|
||||
plotter.show(auto_close=False)
|
||||
try:
|
||||
z_img = plotter.get_image_depth()
|
||||
except Exception:
|
||||
z_img = None
|
||||
plotter.close()
|
||||
|
||||
if z_img is None:
|
||||
return Image.new("L", (size, size), 0)
|
||||
|
||||
# NaN 처리 + 정규화
|
||||
z_img = np.array(z_img, dtype=np.float32)
|
||||
z_finite = z_img[np.isfinite(z_img)]
|
||||
if len(z_finite) == 0:
|
||||
return Image.new("L", (size, size), 0)
|
||||
|
||||
z_min, z_max = z_finite.min(), z_finite.max()
|
||||
if z_max - z_min < 1e-6:
|
||||
return Image.new("L", (size, size), 128)
|
||||
|
||||
z_norm = (z_img - z_min) / (z_max - z_min)
|
||||
z_norm = np.where(np.isfinite(z_norm), z_norm, 1.0)
|
||||
# 가까울수록 밝게 (invert)
|
||||
z_norm = 1.0 - z_norm
|
||||
z_8bit = (z_norm * 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(z_8bit, "L")
|
||||
|
||||
|
||||
def capture_lineart(params: SpillwayParams, meshes: list,
|
||||
elevation_deg: float, azimuth_deg: float,
|
||||
size: int = 1536, zoom: float = 1.3) -> Image.Image:
|
||||
"""라인아트 캡처 (흰 배경 + 검은 엣지)."""
|
||||
plotter = pv.Plotter(off_screen=True, window_size=(size, size))
|
||||
plotter.set_background("white")
|
||||
|
||||
for mesh, _, _ in meshes:
|
||||
plotter.add_mesh(mesh, color="white", show_edges=True, edge_color="black", line_width=1)
|
||||
|
||||
cam_pos, focal, up = compute_camera_for_birdseye(params, elevation_deg, azimuth_deg, zoom)
|
||||
plotter.camera_position = [cam_pos, focal, up]
|
||||
|
||||
img_arr = plotter.screenshot(return_img=True, window_size=(size, size))
|
||||
plotter.close()
|
||||
|
||||
return Image.fromarray(img_arr)
|
||||
|
||||
|
||||
def compose_guide_image(capture: Image.Image, depth: Image.Image, lineart: Image.Image) -> Image.Image:
|
||||
"""캡처 + depth + lineart를 가이드 이미지로 합성."""
|
||||
# 모두 동일 크기로 맞춤
|
||||
base = capture.convert("RGB")
|
||||
d = depth.convert("RGB").resize(base.size)
|
||||
la = lineart.convert("RGB").resize(base.size)
|
||||
|
||||
# 80% base + 20% depth, 그 위에 lineart 살짝
|
||||
arr_base = np.array(base, dtype=np.float32)
|
||||
arr_depth = np.array(d, dtype=np.float32)
|
||||
arr_line = np.array(la, dtype=np.float32)
|
||||
|
||||
blend = arr_base * 0.80 + arr_depth * 0.20
|
||||
# 라인아트: 검은 픽셀만 선택해서 덧씌움
|
||||
line_mask = (arr_line.mean(axis=2, keepdims=True) < 100).astype(np.float32)
|
||||
final = blend * (1 - line_mask * 0.4) + arr_line * (line_mask * 0.4)
|
||||
final = np.clip(final, 0, 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(final)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 인터랙티브 뷰어
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def show_interactive(params: SpillwayParams, meshes: list):
|
||||
"""PyVista 인터랙티브 뷰어. q로 종료."""
|
||||
plotter = pv.Plotter(title="EG-VIEW Gate: Interactive Preview")
|
||||
plotter.set_background("#2B3A4A")
|
||||
add_all_meshes(plotter, meshes)
|
||||
|
||||
# 카메라 초기 위치: bird's eye
|
||||
cam_pos, focal, up = compute_camera_for_birdseye(params, 35, 225, 1.2)
|
||||
plotter.camera_position = [cam_pos, focal, up]
|
||||
|
||||
plotter.enable_3_lights()
|
||||
plotter.show_grid(color="#555")
|
||||
plotter.add_axes()
|
||||
|
||||
plotter.add_text(
|
||||
params.summary().replace("\n", " "),
|
||||
font_size=10, color="white", position="upper_left",
|
||||
)
|
||||
|
||||
plotter.show()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI 렌더링 (Gemini)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_with_gemini(guide_img: Image.Image, prompt: str, api_key: str) -> Image.Image | None:
|
||||
"""Gemini API로 AI 렌더링. 실패 시 None."""
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
import io as _io
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# 이미지를 PNG 바이트로 변환
|
||||
buf = _io.BytesIO()
|
||||
guide_img.save(buf, format="PNG")
|
||||
img_bytes = buf.getvalue()
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash-image",
|
||||
contents=[
|
||||
prompt,
|
||||
types.Part.from_bytes(data=img_bytes, mime_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
# 응답에서 이미지 추출
|
||||
for part in response.candidates[0].content.parts:
|
||||
if hasattr(part, "inline_data") and part.inline_data:
|
||||
img_data = part.inline_data.data
|
||||
return Image.open(_io.BytesIO(img_data))
|
||||
|
||||
print(" Gemini 응답에 이미지 없음")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f" Gemini 렌더링 오류: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def build_gate_prompt(params: SpillwayParams, time_of_day: str = "daytime") -> str:
|
||||
"""수문 구조물용 AI 프롬프트."""
|
||||
return (
|
||||
f"Photorealistic bird's eye view of a dam spillway gate facility, "
|
||||
f"{params.n_gates} radial (Tainter) gates each {params.gate_width:.0f}m wide by {params.gate_height:.0f}m tall, "
|
||||
f"ogee-profile concrete weir, service bridge on top, "
|
||||
f"hoist houses above each gate, {time_of_day}, "
|
||||
f"crystal clear water upstream, concrete apron downstream, "
|
||||
f"maintain exact structural geometry, layout, and proportions from the input image, "
|
||||
f"preserve gate positions and pier locations precisely, "
|
||||
f"professional architectural rendering, "
|
||||
f"8K ultra sharp detail, high dynamic range, "
|
||||
f"realistic concrete texture, steel gate panels, "
|
||||
f"bright daylight with sharp shadows, clear blue sky"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 메인
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--plan", default=None, help="Plan DXF (1/2)")
|
||||
ap.add_argument("--section", default=None, help="Section DXF (2/2)")
|
||||
ap.add_argument("--interactive", action="store_true", help="인터랙티브 3D 뷰어")
|
||||
ap.add_argument("--ai", action="store_true", help="AI 렌더링 (Gemini)")
|
||||
ap.add_argument("--output", default="gate_render_output", help="출력 디렉토리")
|
||||
ap.add_argument("--time", default="daytime", choices=["daytime", "sunset", "overcast"])
|
||||
ap.add_argument("--size", type=int, default=1536, help="렌더 해상도")
|
||||
args = ap.parse_args()
|
||||
|
||||
# 기본 샘플 경로
|
||||
if args.plan is None:
|
||||
base = Path("Gate_Sample")
|
||||
args.plan = str(base / "12995740-M40-001 여수로 수문 설치도(1/2).dxf")
|
||||
args.section = str(base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf")
|
||||
|
||||
# 출력 디렉토리
|
||||
out = Path(args.output)
|
||||
out.mkdir(exist_ok=True)
|
||||
|
||||
# 1) 파라미터 추출
|
||||
print("=" * 60)
|
||||
print("Step 1: DXF 파싱")
|
||||
print("=" * 60)
|
||||
params = parse_spillway_dxf(args.plan, args.section)
|
||||
print(params.summary())
|
||||
|
||||
# 2) 3D 모델 빌드
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 2: 3D 모델 빌드")
|
||||
print("=" * 60)
|
||||
builder = SpillwayBuilder(params)
|
||||
meshes = builder.build_all()
|
||||
print(f"{len(meshes)}개 메쉬 컴포넌트 생성")
|
||||
|
||||
# 3) 인터랙티브 모드?
|
||||
if args.interactive:
|
||||
print("\n인터랙티브 뷰어 실행 중...")
|
||||
show_interactive(params, meshes)
|
||||
return
|
||||
|
||||
# 4) 다각도 캡처
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 3: 다각도 캡처")
|
||||
print("=" * 60)
|
||||
|
||||
views = [
|
||||
("top_down", 75, 180, 1.0), # 수직 상부
|
||||
("bird_eye_1", 35, 225, 1.2), # 조감도 (북동)
|
||||
("bird_eye_2", 35, 135, 1.2), # 조감도 (북서)
|
||||
("bird_eye_3", 25, 180, 1.3), # 조감도 (정면)
|
||||
("elevation", 5, 180, 1.1), # 정면 입면
|
||||
]
|
||||
|
||||
for name, elev, azim, zoom in views:
|
||||
print(f" [{name}] elev={elev}°, azim={azim}°")
|
||||
img = capture_view(params, meshes, elev, azim, size=args.size, zoom=zoom)
|
||||
img_path = out / f"capture_{name}.png"
|
||||
img.save(img_path)
|
||||
print(f" → {img_path}")
|
||||
|
||||
# 5) 제어맵 추출 (bird_eye_1 기준)
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 4: 제어맵 추출 (bird_eye_1)")
|
||||
print("=" * 60)
|
||||
main_elev, main_azim, main_zoom = 35, 225, 1.2
|
||||
|
||||
capture = capture_view(params, meshes, main_elev, main_azim, args.size, zoom=main_zoom)
|
||||
capture.save(out / "capture_main.png")
|
||||
print(f" capture_main.png")
|
||||
|
||||
depth = capture_depth(params, meshes, main_elev, main_azim, args.size, zoom=main_zoom)
|
||||
depth.save(out / "depth_map.png")
|
||||
print(f" depth_map.png")
|
||||
|
||||
lineart = capture_lineart(params, meshes, main_elev, main_azim, args.size, zoom=main_zoom)
|
||||
lineart.save(out / "lineart_map.png")
|
||||
print(f" lineart_map.png")
|
||||
|
||||
guide = compose_guide_image(capture, depth, lineart)
|
||||
guide.save(out / "guide_composite.png")
|
||||
print(f" guide_composite.png")
|
||||
|
||||
# 6) AI 렌더링 (선택적)
|
||||
if args.ai:
|
||||
api_key = os.environ.get("GEMINI_API_KEY", "")
|
||||
if not api_key:
|
||||
print("\n경고: GEMINI_API_KEY 환경변수 필요 (AI 렌더링 건너뜀)")
|
||||
else:
|
||||
print("\n" + "=" * 60)
|
||||
print("Step 5: AI 렌더링 (Gemini)")
|
||||
print("=" * 60)
|
||||
prompt = build_gate_prompt(params, args.time)
|
||||
print(f" 프롬프트: {prompt[:100]}...")
|
||||
|
||||
ai_img = render_with_gemini(guide, prompt, api_key)
|
||||
if ai_img:
|
||||
ai_img.save(out / "ai_rendered.png")
|
||||
print(f" → ai_rendered.png")
|
||||
else:
|
||||
print(f" AI 렌더링 실패")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"완료. 출력 디렉토리: {out.absolute()}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
_unused/지형도 베이스맵/EG-VIEW_rendered_v2(eng).png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
_unused/지형도 베이스맵/V10.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
_unused/지형도 베이스맵/V11.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
_unused/지형도 베이스맵/V12.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
_unused/지형도 베이스맵/V13.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
_unused/지형도 베이스맵/V3.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
_unused/지형도 베이스맵/V4.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
_unused/지형도 베이스맵/V5.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
_unused/지형도 베이스맵/V6.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
_unused/지형도 베이스맵/V7.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
_unused/지형도 베이스맵/V8.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
_unused/지형도 베이스맵/V9.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
138
agents.sh
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/bin/bash
|
||||
# 4-pane tmux layout for AI agents
|
||||
# ┌─────────────────────────┬─────────────────────────┐
|
||||
# │ TL: 오케 + 메인구현 │ TR: 대시보드 │
|
||||
# │ Claude Code │ │
|
||||
# ├─────────────────────────┼─────────────────────────┤
|
||||
# │ BL: 플래너 │ BR: 디자이너+검수자 │
|
||||
# │ Codex (Plus) │ Claude Code │
|
||||
# └─────────────────────────┴─────────────────────────┘
|
||||
#
|
||||
# 모든 AI 페인은 ~/work/agents/ 워크스페이스에서 시작.
|
||||
# claude는 cwd의 CLAUDE.md를 자동 로드 → TL/BR은 매 turn 컨텍스트 자동.
|
||||
# BL(codex)는 자동 로드 X → dispatch.sh의 self-contained 헤더로 보완.
|
||||
|
||||
SESSION=agents
|
||||
WORKSPACE=$HOME/work/agents
|
||||
|
||||
mkdir -p "$WORKSPACE/outputs"
|
||||
|
||||
# 부트스트랩 마커 정리 (디버깅용)
|
||||
rm -f "$WORKSPACE/outputs/.bootstrap_done" 2>/dev/null
|
||||
|
||||
# Project Root는 매 작업마다 TL이 사용자에게 묻고 TASK.md에 갱신함.
|
||||
# (한 세션에서 여러 프로젝트를 번갈아 다룰 수 있으므로 시작 시 인자로 못 박지 않음)
|
||||
|
||||
# 항상 리셋: 기존 세션이 있으면 죽이고 새로 만든다
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null
|
||||
|
||||
# 1) 새 세션 - 첫 페인 ID 캡처 (좌상단)
|
||||
TL=$(tmux new-session -d -s "$SESSION" -n main -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
|
||||
# 2) TL을 좌우 분할 → 우측이 TR
|
||||
TR=$(tmux split-window -h -t "$TL" -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
|
||||
# 3) TL을 위아래 분할 → 아래가 BL
|
||||
BL=$(tmux split-window -v -t "$TL" -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
|
||||
# 4) TR을 위아래 분할 → 아래가 BR
|
||||
BR=$(tmux split-window -v -t "$TR" -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
|
||||
# 5) 진단: 실제 페인 위치를 /tmp/agents-layout.log 에 기록
|
||||
{
|
||||
echo "=== Pane Layout (실제 위치) ==="
|
||||
tmux list-panes -t "$SESSION:main" \
|
||||
-F 'pane=#{pane_id} pos=(left:#{pane_left}, top:#{pane_top}) size=#{pane_width}x#{pane_height} cwd=#{pane_current_path}'
|
||||
echo "지정한 ID: TL=$TL TR=$TR BL=$BL BR=$BR"
|
||||
echo "==============================="
|
||||
} > /tmp/agents-layout.log
|
||||
|
||||
# 6) 각 페인에 명령 전송
|
||||
# TL: 오케 + 메인구현 (Claude)
|
||||
# TR: 라이브 대시보드 (Python rich)
|
||||
# BL: 플래너 (Codex)
|
||||
# BR: 디자이너+검수자 (Claude)
|
||||
tmux send-keys -t "$TL" "claude" C-m
|
||||
tmux send-keys -t "$TR" "python3 $WORKSPACE/dashboard.py" C-m
|
||||
tmux send-keys -t "$BL" "codex" C-m
|
||||
tmux send-keys -t "$BR" "claude" C-m
|
||||
|
||||
# 7) 시작 포커스는 좌상단 (오케)
|
||||
tmux select-pane -t "$TL"
|
||||
|
||||
# 8) 백그라운드 부트스트랩
|
||||
# claude/codex가 ready 되도록 12초 대기 후 페인별 역할 프롬프트 주입.
|
||||
# multiline 메시지는 dispatch.sh와 동일한 방식 (-l literal paste + 3 Enter).
|
||||
(
|
||||
sleep 12
|
||||
|
||||
TODAY=$(date +'%Y-%m-%d %H:%M')
|
||||
|
||||
LAST_LOG=$(tail -n 5 "$WORKSPACE/outputs/iterations.log" 2>/dev/null)
|
||||
[ -z "$LAST_LOG" ] && LAST_LOG="(없음 — 새 워크스페이스)"
|
||||
|
||||
LAST_TASK_HEAD=$(awk 'NR<=12' "$WORKSPACE/TASK.md" 2>/dev/null)
|
||||
[ -z "$LAST_TASK_HEAD" ] && LAST_TASK_HEAD="(없음)"
|
||||
|
||||
TL_PROMPT="새 agents 세션 시작 ($TODAY).
|
||||
|
||||
너는 TL = 오케스트레이터 + 메인 구현자 + GAN 루프 드라이버.
|
||||
|
||||
[즉시 실행]
|
||||
1. AGENTS.md, DESIGN.md, TASK.md, outputs/iterations.log 읽고 상태 파악.
|
||||
2. iterations.log 마지막 줄 mtime이 24시간 이내면 \"이전 작업 이어가는 세션\"일 가능성 높음.
|
||||
사용자 첫 메시지 받으면:
|
||||
- 새 작업 같음 → Project Root 묻기 → TASK.md 갱신 → plan→design→review 흐름.
|
||||
- 이어가는 거 같음 → \"어제 <TASK 제목> iter=N (마지막 점수 NN)에서 끊겼는데 이어갈까?\" 명시적 확인.
|
||||
추측 금지.
|
||||
3. dispatch.sh 호출 시 self-contained 헤더가 자동 prepend됨 — 본문엔 작업 내용만.
|
||||
|
||||
[직전 iterations.log (tail -5)]
|
||||
$LAST_LOG
|
||||
|
||||
[직전 TASK.md 헤더 12줄]
|
||||
$LAST_TASK_HEAD
|
||||
|
||||
사용자 메시지 받을 때까지 대기."
|
||||
|
||||
BL_PROMPT="새 agents 세션 시작 ($TODAY).
|
||||
너는 BL = 플래너 + 보조 (Codex Plus). dispatch.sh planner 메시지 오면 발동.
|
||||
매 메시지에 [ROLE: planner | iter=N | project=...] 헤더 + 필독 파일 목록 자동 포함됨.
|
||||
헤더 따라 ~/work/agents/AGENTS.md, TASK.md 읽고 outputs/plan.md 작성.
|
||||
요청 없을 때는 대기."
|
||||
|
||||
BR_PROMPT="새 agents 세션 시작 ($TODAY).
|
||||
|
||||
너는 BR = 디자이너 + 검수자 (점수 산출).
|
||||
|
||||
[즉시 실행]
|
||||
1. AGENTS.md, DESIGN.md, TASK.md, outputs/iterations.log 읽기.
|
||||
2. iter ≥ 1이면 outputs/review.md, outputs/design.md도 읽어서 직전 비평/디자인 맥락 파악.
|
||||
3. dispatch.sh로 메시지 오면 헤더의 [ROLE: designer] 또는 [ROLE: reviewer]에 따라 모드 전환.
|
||||
|
||||
[직전 iterations.log (tail -5)]
|
||||
$LAST_LOG
|
||||
|
||||
요청 올 때까지 대기."
|
||||
|
||||
send_bootstrap() {
|
||||
local pane="$1"
|
||||
local msg="$2"
|
||||
tmux send-keys -t "$pane" -l "$msg"
|
||||
sleep 1.2
|
||||
tmux send-keys -t "$pane" Enter
|
||||
sleep 0.6
|
||||
tmux send-keys -t "$pane" Enter
|
||||
sleep 0.6
|
||||
tmux send-keys -t "$pane" Enter
|
||||
}
|
||||
|
||||
send_bootstrap "$TL" "$TL_PROMPT"
|
||||
send_bootstrap "$BL" "$BL_PROMPT"
|
||||
send_bootstrap "$BR" "$BR_PROMPT"
|
||||
|
||||
# 부트스트랩 완료 마커 (디버깅용)
|
||||
date +%s > "$WORKSPACE/outputs/.bootstrap_done"
|
||||
) &
|
||||
|
||||
exec tmux attach -t "$SESSION"
|
||||
415
apply_blender_patch.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""apply_blender_patch.py — scanvas_maker.py 에 Blender 렌더 통합 패치 적용.
|
||||
|
||||
패치 내용 (3곳):
|
||||
P1) `_open_structure_template_dialog` 안에 `_do_blender_render` 콜백 추가
|
||||
(기존 `_do_vlm_feedback` 함수 정의 바로 앞에 삽입)
|
||||
P2) "🤖 AI 검증" 버튼 다음 줄에 "🎨 Blender 렌더" 버튼 추가
|
||||
P3) 클래스 메서드로 `_show_structure_render(self, image_path)` 추가
|
||||
(`_show_rendered_result` 메서드 바로 앞에 삽입)
|
||||
|
||||
특징:
|
||||
- Idempotent: 이미 적용된 상태에서 다시 실행해도 변경 없음 (안전)
|
||||
- Anchor 기반: 원본의 정확한 텍스트를 찾아 그 위치에만 삽입
|
||||
anchor 못 찾으면 즉시 중단 (파일 망가뜨리지 않음)
|
||||
- 백업: 실행 전 scanvas_maker.py.bak_blender 자동 생성
|
||||
- 줄바꿈: 입력 파일이 CRLF면 출력도 CRLF, LF면 LF (보존)
|
||||
- AST parse 검증으로 결과 syntax 체크
|
||||
|
||||
사용법:
|
||||
cd D:\\2026\\PROGRAM\\1_S-CANVAS
|
||||
python apply_blender_patch.py
|
||||
|
||||
옵션:
|
||||
--dry-run 실제 쓰지 않고 어떤 변경이 일어날지 출력만
|
||||
--no-backup 백업 파일 생성 생략
|
||||
--check 이미 적용됐는지만 확인 (변경 없음)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("scanvas_maker.py")
|
||||
BACKUP = Path("scanvas_maker.py.bak_blender")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 패치 정의 (anchor + insert)
|
||||
# ===========================================================================
|
||||
|
||||
P1_GUARD = "def _do_blender_render():"
|
||||
P1_ANCHOR = " def _do_vlm_feedback():\n"
|
||||
P1_INSERT = ''' def _do_blender_render():
|
||||
"""Blender Cycles로 구조물 단독 고품질 렌더 (별도 트랙).
|
||||
|
||||
AI 워크플로(Step 4)와 별개로 실행. 결과는 'structure_render.png'.
|
||||
transparent_bg=True 로 RGBA 출력 → 추후 지형 합성 입력으로도 사용 가능.
|
||||
"""
|
||||
try:
|
||||
from blender_renderer import run_blender_render
|
||||
from gate_3d_builder_bpy import dump_params_to_json as _dump_gate
|
||||
except ImportError as e:
|
||||
messagebox.showerror(
|
||||
"모듈 없음",
|
||||
f"Blender 렌더 모듈을 찾을 수 없습니다:\\n{e}\\n\\n"
|
||||
"blender_renderer.py / gate_3d_builder_bpy.py 가 "
|
||||
"S-CANVAS 폴더에 있는지 확인하세요.",
|
||||
parent=win,
|
||||
)
|
||||
return
|
||||
|
||||
if state["params"] is None:
|
||||
messagebox.showwarning(
|
||||
"안내", "파라미터가 비어있습니다. '미리보기 (빌드)' 먼저 실행하세요.",
|
||||
parent=win,
|
||||
)
|
||||
return
|
||||
|
||||
# 현재는 수문(spillway_gate)만 지원.
|
||||
if tid != "spillway_gate":
|
||||
messagebox.showinfo(
|
||||
"지원 예정",
|
||||
f"Blender 렌더는 현재 '여수로 수문(spillway_gate)' 템플릿만 "
|
||||
f"지원합니다.\\n현재 템플릿: {tid}",
|
||||
parent=win,
|
||||
)
|
||||
return
|
||||
|
||||
# 옵션 다이얼로그 — 시간대 + 투명배경 + 샘플
|
||||
opt_win = ctk.CTkToplevel(win)
|
||||
opt_win.title("Blender Cycles 렌더 옵션")
|
||||
opt_win.geometry("420x340")
|
||||
opt_win.transient(win); opt_win.grab_set()
|
||||
|
||||
ctk.CTkLabel(
|
||||
opt_win, text="Blender Cycles 렌더 옵션",
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
).pack(pady=(15, 10))
|
||||
|
||||
ctk.CTkLabel(opt_win, text="조명 / 시간대").pack(anchor="w", padx=20)
|
||||
time_var = ctk.StringVar(value="daytime")
|
||||
tf = ctk.CTkFrame(opt_win, fg_color="transparent")
|
||||
tf.pack(fill="x", padx=20, pady=(0, 10))
|
||||
for v, lbl in [("daytime", "주간"), ("sunset", "노을"), ("overcast", "흐림")]:
|
||||
ctk.CTkRadioButton(tf, text=lbl, variable=time_var, value=v).pack(side="left", padx=8)
|
||||
|
||||
transparent_var = ctk.BooleanVar(value=False)
|
||||
ctk.CTkCheckBox(
|
||||
opt_win,
|
||||
text="투명 배경 (RGBA) — 지형 합성용",
|
||||
variable=transparent_var,
|
||||
).pack(anchor="w", padx=20, pady=(0, 8))
|
||||
|
||||
ctk.CTkLabel(opt_win, text="Cycles 샘플 (높을수록 깨끗·느림)").pack(anchor="w", padx=20)
|
||||
samples_var = ctk.StringVar(value="128")
|
||||
sf = ctk.CTkFrame(opt_win, fg_color="transparent")
|
||||
sf.pack(fill="x", padx=20, pady=(0, 8))
|
||||
for s in ("32", "64", "128", "256"):
|
||||
ctk.CTkRadioButton(sf, text=s, variable=samples_var, value=s).pack(side="left", padx=6)
|
||||
|
||||
save_blend_var = ctk.BooleanVar(value=False)
|
||||
save_glb_var = ctk.BooleanVar(value=False)
|
||||
ctk.CTkCheckBox(opt_win, text=".blend 저장",
|
||||
variable=save_blend_var).pack(anchor="w", padx=20)
|
||||
ctk.CTkCheckBox(opt_win, text=".glb 저장 (외부 뷰어/VR)",
|
||||
variable=save_glb_var).pack(anchor="w", padx=20, pady=(0, 6))
|
||||
|
||||
def _start():
|
||||
try:
|
||||
samples = int(samples_var.get())
|
||||
except ValueError:
|
||||
samples = 128
|
||||
t_preset = time_var.get()
|
||||
trans = bool(transparent_var.get())
|
||||
save_b = bool(save_blend_var.get())
|
||||
save_g = bool(save_glb_var.get())
|
||||
opt_win.destroy()
|
||||
|
||||
try:
|
||||
json_path = "gate_params.json"
|
||||
_dump_gate(state["params"], json_path)
|
||||
self.log(f" [{name}] GateParams -> {json_path}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("JSON 저장 실패",
|
||||
f"GateParams JSON 직렬화 실패:\\n{e}", parent=win)
|
||||
return
|
||||
|
||||
self.log(f" [{name}] Blender 렌더 시작 "
|
||||
f"(time={t_preset}, samples={samples}, "
|
||||
f"bg={'투명' if trans else 'sky'})")
|
||||
threading.Thread(
|
||||
target=run_blender_render,
|
||||
args=(self, None, json_path),
|
||||
kwargs=dict(
|
||||
time_preset=t_preset,
|
||||
engine="CYCLES",
|
||||
samples=samples,
|
||||
output_path="structure_render.png",
|
||||
transparent_bg=trans,
|
||||
save_blend=save_b,
|
||||
save_glb=save_g,
|
||||
structure_kind="gate",
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
bf = ctk.CTkFrame(opt_win, fg_color="transparent")
|
||||
bf.pack(fill="x", pady=15, padx=20)
|
||||
ctk.CTkButton(bf, text="취소", width=80,
|
||||
fg_color="transparent", border_width=1,
|
||||
command=opt_win.destroy).pack(side="left")
|
||||
ctk.CTkButton(bf, text="🎨 렌더 시작", width=140,
|
||||
fg_color="#16A085", hover_color="#117A65",
|
||||
text_color="white",
|
||||
command=_start).pack(side="right")
|
||||
|
||||
''' + P1_ANCHOR
|
||||
|
||||
|
||||
P2_GUARD = '🎨 Blender 렌더'
|
||||
P2_ANCHOR = (
|
||||
' ctk.CTkButton(bottom, text="🤖 AI 검증", width=110,\n'
|
||||
' fg_color="#D35400", hover_color="#A04000",\n'
|
||||
' text_color="white",\n'
|
||||
' font=ctk.CTkFont(size=11, weight="bold"),\n'
|
||||
' command=_do_vlm_feedback).pack(side="right", padx=3)\n'
|
||||
)
|
||||
P2_INSERT = P2_ANCHOR + (
|
||||
' ctk.CTkButton(bottom, text="🎨 Blender 렌더", width=140,\n'
|
||||
' fg_color="#16A085", hover_color="#117A65",\n'
|
||||
' text_color="white",\n'
|
||||
' font=ctk.CTkFont(size=11, weight="bold"),\n'
|
||||
' command=_do_blender_render).pack(side="right", padx=3)\n'
|
||||
)
|
||||
|
||||
|
||||
P3_GUARD = "def _show_structure_render(self,"
|
||||
P3_ANCHOR = " def _show_rendered_result(self, image_path):\n"
|
||||
P3_INSERT = ''' def _show_structure_render(self, image_path):
|
||||
"""Blender 구조물 렌더 결과를 별도 창에 표시 (AI 결과와 분리).
|
||||
|
||||
blender_renderer.py 가 호출. 투명 PNG도 정상 표시(체커보드 배경).
|
||||
"""
|
||||
try:
|
||||
from PIL import ImageTk, Image as _PILImage
|
||||
try:
|
||||
pil_img = _PILImage.open(image_path)
|
||||
except Exception as e:
|
||||
messagebox.showerror("이미지 열기 실패",
|
||||
f"렌더 결과 PNG를 열 수 없습니다:\\n{image_path}\\n\\n{e}")
|
||||
return
|
||||
|
||||
# 투명 PNG는 체커보드 배경 위에 합성 표시
|
||||
display_img = pil_img
|
||||
if pil_img.mode == "RGBA":
|
||||
from PIL import ImageDraw
|
||||
tile = _PILImage.new("RGB", (16, 16), (200, 200, 200))
|
||||
d = ImageDraw.Draw(tile)
|
||||
d.rectangle((0, 0, 7, 7), fill=(170, 170, 170))
|
||||
d.rectangle((8, 8, 15, 15), fill=(170, 170, 170))
|
||||
bg = _PILImage.new("RGB", pil_img.size, (200, 200, 200))
|
||||
for y in range(0, pil_img.size[1], 16):
|
||||
for x in range(0, pil_img.size[0], 16):
|
||||
bg.paste(tile, (x, y))
|
||||
display_img = _PILImage.alpha_composite(
|
||||
bg.convert("RGBA"), pil_img
|
||||
).convert("RGB")
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win.title(f"🎨 Blender 렌더 결과 - {Path(image_path).name}")
|
||||
|
||||
sw = self.winfo_screenwidth(); sh = self.winfo_screenheight()
|
||||
max_w, max_h = int(sw * 0.7), int(sh * 0.75)
|
||||
iw, ih = display_img.size
|
||||
scale = min(max_w / iw, max_h / ih, 1.0)
|
||||
disp_w, disp_h = int(iw * scale), int(ih * scale)
|
||||
disp = display_img.resize((disp_w, disp_h), _PILImage.LANCZOS)
|
||||
|
||||
tk_img = ImageTk.PhotoImage(disp)
|
||||
lbl = ctk.CTkLabel(win, text="", image=tk_img)
|
||||
lbl.image = tk_img
|
||||
lbl.pack(padx=10, pady=10)
|
||||
|
||||
info = ctk.CTkFrame(win, fg_color="transparent")
|
||||
info.pack(fill="x", padx=10, pady=(0, 5))
|
||||
mode_str = "투명 배경 (RGBA, 합성용)" if pil_img.mode == "RGBA" else "Sky 배경 (RGB)"
|
||||
ctk.CTkLabel(
|
||||
info,
|
||||
text=f"파일: {image_path} · 원본 {iw}×{ih} · {mode_str}",
|
||||
font=ctk.CTkFont(size=11),
|
||||
).pack(side="left", padx=5)
|
||||
|
||||
btnf = ctk.CTkFrame(win, fg_color="transparent")
|
||||
btnf.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
def _open_external():
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(image_path)
|
||||
elif sys.platform == "darwin":
|
||||
import subprocess
|
||||
subprocess.Popen(["open", image_path])
|
||||
else:
|
||||
import subprocess
|
||||
subprocess.Popen(["xdg-open", image_path])
|
||||
except Exception as e:
|
||||
messagebox.showerror("열기 실패",
|
||||
f"기본 뷰어 실행 실패:\\n{e}", parent=win)
|
||||
|
||||
def _open_folder():
|
||||
try:
|
||||
folder = str(Path(image_path).resolve().parent)
|
||||
if sys.platform == "win32":
|
||||
os.startfile(folder)
|
||||
elif sys.platform == "darwin":
|
||||
import subprocess
|
||||
subprocess.Popen(["open", folder])
|
||||
else:
|
||||
import subprocess
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
except Exception as e:
|
||||
messagebox.showerror("폴더 열기 실패", f"{e}", parent=win)
|
||||
|
||||
ctk.CTkButton(btnf, text="📂 폴더 열기", width=110,
|
||||
command=_open_folder).pack(side="right", padx=3)
|
||||
ctk.CTkButton(btnf, text="🖼 외부 뷰어로 열기", width=160,
|
||||
command=_open_external).pack(side="right", padx=3)
|
||||
ctk.CTkButton(btnf, text="닫기", width=80,
|
||||
fg_color="transparent", border_width=1,
|
||||
command=win.destroy).pack(side="left", padx=3)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
messagebox.showerror("결과 창 오류", f"렌더 결과 창 생성 실패:\\n{e}")
|
||||
|
||||
''' + P3_ANCHOR
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 메인
|
||||
# ===========================================================================
|
||||
|
||||
def detect_eol(raw_bytes: bytes) -> str:
|
||||
"""원본 파일의 줄바꿈 형식 감지. 패치 후 보존."""
|
||||
crlf = raw_bytes.count(b"\r\n")
|
||||
lf = raw_bytes.count(b"\n") - crlf
|
||||
if crlf > lf:
|
||||
return "\r\n"
|
||||
return "\n"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="실제 쓰지 않고 변경 사항만 출력")
|
||||
ap.add_argument("--no-backup", action="store_true",
|
||||
help="백업 파일 생성 생략")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="패치 적용 상태만 확인 (변경 없음)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not TARGET.is_file():
|
||||
sys.exit(f"[ERR] {TARGET} 가 현재 폴더에 없습니다. "
|
||||
f"D:\\2026\\PROGRAM\\1_S-CANVAS 에서 실행하세요.")
|
||||
|
||||
raw = TARGET.read_bytes()
|
||||
eol = detect_eol(raw)
|
||||
print(f"파일: {TARGET} ({len(raw):,} bytes, EOL={'CRLF' if eol == chr(13) + chr(10) else 'LF'})")
|
||||
|
||||
# 항상 LF로 정규화해서 작업 (anchor 매칭 일관성)
|
||||
src = raw.decode("utf-8").replace("\r\n", "\n")
|
||||
original_src = src
|
||||
|
||||
# 적용 상태 점검
|
||||
states = {
|
||||
"P1 (callback _do_blender_render)": P1_GUARD in src,
|
||||
"P2 (button '🎨 Blender 렌더')": P2_GUARD in src,
|
||||
"P3 (method _show_structure_render)": P3_GUARD in src,
|
||||
}
|
||||
all_applied = all(states.values())
|
||||
none_applied = not any(states.values())
|
||||
|
||||
print("\n현재 상태:")
|
||||
for name, applied in states.items():
|
||||
mark = "✓ 적용됨" if applied else " 미적용"
|
||||
print(f" {mark} {name}")
|
||||
|
||||
if args.check:
|
||||
if all_applied:
|
||||
print("\n[CHECK] 모든 패치 적용됨")
|
||||
sys.exit(0)
|
||||
elif none_applied:
|
||||
print("\n[CHECK] 패치 미적용")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n[CHECK] 부분 적용 — 권장: --dry-run 으로 확인 후 정상 실행")
|
||||
sys.exit(2)
|
||||
|
||||
if all_applied:
|
||||
print("\n→ 모든 패치 이미 적용됨. 추가 작업 없음.")
|
||||
return 0
|
||||
|
||||
# 적용
|
||||
changes = []
|
||||
if P1_GUARD not in src:
|
||||
if P1_ANCHOR not in src:
|
||||
sys.exit("[ERR] P1 anchor 'def _do_vlm_feedback():' 못 찾음. "
|
||||
"scanvas_maker.py 구조가 변경되었을 수 있습니다.")
|
||||
src = src.replace(P1_ANCHOR, P1_INSERT, 1)
|
||||
changes.append("P1: callback _do_blender_render 추가")
|
||||
|
||||
if P2_GUARD not in src:
|
||||
if P2_ANCHOR not in src:
|
||||
sys.exit("[ERR] P2 anchor (🤖 AI 검증 버튼 블록) 못 찾음.")
|
||||
src = src.replace(P2_ANCHOR, P2_INSERT, 1)
|
||||
changes.append("P2: 🎨 Blender 렌더 버튼 추가")
|
||||
|
||||
if P3_GUARD not in src:
|
||||
if P3_ANCHOR not in src:
|
||||
sys.exit("[ERR] P3 anchor 'def _show_rendered_result' 못 찾음.")
|
||||
src = src.replace(P3_ANCHOR, P3_INSERT, 1)
|
||||
changes.append("P3: method _show_structure_render 추가")
|
||||
|
||||
print("\n적용할 변경:")
|
||||
for c in changes:
|
||||
print(f" + {c}")
|
||||
print(f"\n 파일 크기: {len(original_src):,} → {len(src):,} chars "
|
||||
f"({len(src) - len(original_src):+,})")
|
||||
|
||||
# AST parse 검증
|
||||
try:
|
||||
ast.parse(src)
|
||||
print(f" AST parse: OK ({len(src.splitlines()):,} lines)")
|
||||
except SyntaxError as e:
|
||||
sys.exit(f"\n[ERR] 패치 후 syntax error 발생: {e}\n"
|
||||
f"파일 변경 안 함. 원본 유지.")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY RUN] 실제 파일은 변경하지 않았습니다. 적용하려면 --dry-run 빼고 재실행.")
|
||||
return 0
|
||||
|
||||
# 백업
|
||||
if not args.no_backup:
|
||||
shutil.copy2(TARGET, BACKUP)
|
||||
print(f"\n 백업: {BACKUP}")
|
||||
|
||||
# 원본 EOL로 복원해서 쓰기
|
||||
final_bytes = src.encode("utf-8")
|
||||
if eol == "\r\n":
|
||||
final_bytes = final_bytes.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
|
||||
TARGET.write_bytes(final_bytes)
|
||||
print(f"\n✓ 패치 적용 완료: {TARGET} ({len(final_bytes):,} bytes)")
|
||||
print("\n다음 단계:")
|
||||
print(" 1) S-CANVAS 실행: python scanvas_maker.py")
|
||||
print(" 2) Step 2에서 도면 로드 → 구조물 식별")
|
||||
print(" 3) 사이드바 '구조물 상세 빌드' 다이얼로그 → 수문 선택")
|
||||
print(" 4) 파라미터 조정 후 '🗔 미리보기' 또는 '🎨 Blender 렌더'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
726
blender_renderer.py
Normal file
@@ -0,0 +1,726 @@
|
||||
"""Blender 헤드리스 기반 구조물 단독 렌더링 워커.
|
||||
|
||||
scanvas_maker.SCanvasApp 안의 구조물 빌드 다이얼로그(_open_structure_template_dialog)
|
||||
에서 호출되어, 사용자 파라미터로 빌드된 구조물을 Blender Cycles로 고품질 렌더한다.
|
||||
|
||||
워크플로 안에서의 위치:
|
||||
[A] DXF + 파라미터 → Blender 빌더 → Cycles 렌더 → 구조물 PNG ← 이 모듈
|
||||
↓
|
||||
[B] 별도 트랙: DEM/위성 → 지형 capture map
|
||||
↓
|
||||
[C] 합성: 지형 capture에 구조물 PNG 얹기 ← 추후 작업
|
||||
↓
|
||||
[D] AI 트랙: 합성 control map → Gemini → 최종 조감도
|
||||
|
||||
따라서:
|
||||
- 출력 파일명은 'structure_render.png' (AI 결과 'rendered_birdseye.png'를
|
||||
덮어쓰지 않음)
|
||||
- 결과 표시는 app._show_rendered_result()가 아니라 app._show_structure_render()
|
||||
로 분리 (없으면 OS 기본 뷰어 폴백, GUI 없는 환경에서는 단순 print)
|
||||
- transparent_bg=True 면 RGBA PNG → 추후 [C] 합성에 직접 사용 가능
|
||||
- transparent_bg=False 면 Sky 배경 → 단독 발표용 그림으로 사용
|
||||
|
||||
공개 API:
|
||||
run_blender_render(app, blender_exe, params_json, ...)
|
||||
|
||||
----------------------------------------------------------------------
|
||||
v3 (워크플로 분리):
|
||||
- 출력 기본값: rendered_birdseye.png → structure_render.png
|
||||
- app._show_rendered_result → app._show_structure_render (없으면 OS 폴백)
|
||||
- transparent_bg 인자 추가 → 빌더의 setup_lighting_and_camera/render_to_png
|
||||
호출 시 동일하게 전달
|
||||
- PIL 후처리 분기: 투명 PNG는 RGBA로 리사이즈 (RGB 변환하면 알파 손실)
|
||||
v2: tkinter / PIL 을 lazy import (GUI 없는 환경에서도 모듈 로드 가능)
|
||||
v1: 초기 버전
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
|
||||
# tkinter / PIL 은 GUI 컨텍스트에서만 필요 → lazy import.
|
||||
# Harness 의존 (선택적 — 없으면 그냥 렌더만 진행)
|
||||
try:
|
||||
from harness.seed_manager import SeedManager
|
||||
from harness.logger import get_db_session
|
||||
_HARNESS_OK = True
|
||||
except Exception:
|
||||
SeedManager = None # type: ignore
|
||||
get_db_session = None # type: ignore
|
||||
_HARNESS_OK = False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Lazy import 헬퍼
|
||||
# ===========================================================================
|
||||
|
||||
def _show_error_dialog(title: str, msg: str) -> None:
|
||||
"""tkinter messagebox로 에러 다이얼로그. 없으면 stderr 폴백."""
|
||||
try:
|
||||
from tkinter import messagebox
|
||||
messagebox.showerror(title, msg)
|
||||
except Exception:
|
||||
sys.stderr.write(f"[ERROR] {title}: {msg}\n")
|
||||
|
||||
|
||||
def _open_image(path: str):
|
||||
"""PIL.Image.open 의 lazy wrapper."""
|
||||
from PIL import Image
|
||||
return Image.open(path)
|
||||
|
||||
|
||||
def _image_lanczos():
|
||||
"""PIL.Image.LANCZOS 상수 lazy 접근."""
|
||||
from PIL import Image
|
||||
return Image.LANCZOS
|
||||
|
||||
|
||||
def _open_in_os_default_viewer(path: str) -> bool:
|
||||
"""플랫폼별 기본 이미지 뷰어로 PNG 열기. 결과 표시 폴백용.
|
||||
|
||||
Returns: True if launched successfully (best-effort)
|
||||
"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", path])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Blender 실행파일 자동 탐색
|
||||
# ===========================================================================
|
||||
|
||||
_BLENDER_SEARCH_PATHS = {
|
||||
"win32": [
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.5\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.4\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.3\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.2\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.1\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 4.0\blender.exe",
|
||||
r"C:\Program Files\Blender Foundation\Blender 3.6\blender.exe",
|
||||
],
|
||||
"darwin": [
|
||||
"/Applications/Blender.app/Contents/MacOS/Blender",
|
||||
],
|
||||
"linux": [
|
||||
"/usr/bin/blender",
|
||||
"/usr/local/bin/blender",
|
||||
"/snap/bin/blender",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def find_blender_executable(explicit: str | None = None) -> str | None:
|
||||
"""Blender 실행파일 위치 결정.
|
||||
|
||||
우선순위:
|
||||
1) 인자로 명시
|
||||
2) 환경변수 BLENDER_EXE
|
||||
3) PATH 안의 'blender' / 'blender.exe'
|
||||
4) 플랫폼별 표준 설치 경로 (win/mac/linux)
|
||||
5) Windows의 경우 'C:\\Program Files\\Blender Foundation\\*' glob
|
||||
|
||||
Returns: 절대 경로 (str) 또는 None
|
||||
"""
|
||||
if explicit and Path(explicit).is_file():
|
||||
return str(Path(explicit).resolve())
|
||||
|
||||
env_exe = os.environ.get("BLENDER_EXE")
|
||||
if env_exe and Path(env_exe).is_file():
|
||||
return str(Path(env_exe).resolve())
|
||||
|
||||
which = shutil.which("blender") or shutil.which("blender.exe")
|
||||
if which:
|
||||
return str(Path(which).resolve())
|
||||
|
||||
candidates = _BLENDER_SEARCH_PATHS.get(sys.platform, [])
|
||||
for c in candidates:
|
||||
if Path(c).is_file():
|
||||
return str(Path(c).resolve())
|
||||
|
||||
if sys.platform == "win32":
|
||||
prog_files = Path(r"C:\Program Files\Blender Foundation")
|
||||
if prog_files.is_dir():
|
||||
for sub in sorted(prog_files.iterdir(), reverse=True):
|
||||
exe = sub / "blender.exe"
|
||||
if exe.is_file():
|
||||
return str(exe.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 구조물 종류 자동 감지 + 빌더 라우팅
|
||||
# ===========================================================================
|
||||
|
||||
_STRUCTURE_REGISTRY = {
|
||||
"gate": {
|
||||
"module": "gate_3d_builder_bpy",
|
||||
"klass": "GateBuilderBpy",
|
||||
"marker": "n_gates",
|
||||
},
|
||||
"intake_tower": {
|
||||
"module": "intake_tower_3d_builder_bpy",
|
||||
"klass": "IntakeTowerBuilderBpy",
|
||||
"marker": "body_top_el",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_structure_kind(params_json_path: str) -> str | None:
|
||||
"""JSON 안의 marker 필드를 보고 구조물 종류 판별."""
|
||||
try:
|
||||
with open(params_json_path, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
|
||||
for kind, spec in _STRUCTURE_REGISTRY.items():
|
||||
marker = spec["marker"]
|
||||
if marker in d and isinstance(d[marker], (int, float)):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 동적 wrapper script 생성 (Cycles seed + transparent_bg 주입)
|
||||
# ===========================================================================
|
||||
|
||||
def _generate_blender_runner(
|
||||
*,
|
||||
s_canvas_dir: str,
|
||||
structure_kind: str,
|
||||
params_json: str,
|
||||
output_path: str,
|
||||
seed: int,
|
||||
samples: int,
|
||||
engine: str,
|
||||
time_preset: str,
|
||||
width: int,
|
||||
height: int,
|
||||
transparent_bg: bool = False,
|
||||
blend_path: str | None = None,
|
||||
glb_path: str | None = None,
|
||||
) -> str:
|
||||
"""헤드리스 Blender에 줄 임시 Python 스크립트.
|
||||
|
||||
Cycles seed + transparent_bg 모두 빌더 외부에서 주입.
|
||||
빌더 파일은 immutable 유지.
|
||||
"""
|
||||
spec = _STRUCTURE_REGISTRY[structure_kind]
|
||||
module = spec["module"]
|
||||
klass = spec["klass"]
|
||||
|
||||
src = textwrap.dedent(f"""
|
||||
# Auto-generated by blender_renderer.py — do not edit.
|
||||
import sys, json, traceback
|
||||
from pathlib import Path
|
||||
|
||||
SCANVAS_DIR = {s_canvas_dir!r}
|
||||
if SCANVAS_DIR not in sys.path:
|
||||
sys.path.insert(0, SCANVAS_DIR)
|
||||
|
||||
try:
|
||||
import bpy
|
||||
import {module} as B
|
||||
|
||||
# 1) Params 로드
|
||||
params_dict = json.loads(Path({params_json!r}).read_text(encoding="utf-8"))
|
||||
params = B.params_from_dict(params_dict)
|
||||
|
||||
# 2) 3D scene 빌드
|
||||
builder = B.{klass}(params, clear_scene=True)
|
||||
builder.build_all()
|
||||
print(f"[blender_renderer] objects={{builder._object_count}}")
|
||||
|
||||
# 3) 카메라 + 조명 (transparent_bg 옵션 포함)
|
||||
B.setup_lighting_and_camera(
|
||||
params,
|
||||
time_preset={time_preset!r},
|
||||
transparent_bg={transparent_bg!r},
|
||||
)
|
||||
|
||||
# 4) Cycles seed (결정론적) — 빌더 외부에서 주입
|
||||
scene = bpy.context.scene
|
||||
if {engine!r} == "CYCLES":
|
||||
scene.cycles.seed = {seed}
|
||||
print(f"[blender_renderer] cycles.seed={{scene.cycles.seed}}")
|
||||
|
||||
# 5) (옵션) .blend / .glb 부수 출력
|
||||
blend_path = {blend_path!r}
|
||||
glb_path = {glb_path!r}
|
||||
if blend_path:
|
||||
B.save_blend(blend_path)
|
||||
print(f"[blender_renderer] saved .blend -> {{blend_path}}")
|
||||
if glb_path:
|
||||
B.export_glb(glb_path)
|
||||
print(f"[blender_renderer] saved .glb -> {{glb_path}}")
|
||||
|
||||
# 6) PNG 렌더 (transparent_bg 전달)
|
||||
B.render_to_png(
|
||||
{output_path!r},
|
||||
resolution=({width}, {height}),
|
||||
samples={samples},
|
||||
engine={engine!r},
|
||||
transparent_bg={transparent_bg!r},
|
||||
)
|
||||
print(f"[blender_renderer] OK -> {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
print("[blender_renderer] FAIL:", e)
|
||||
traceback.print_exc()
|
||||
sys.exit(2)
|
||||
""")
|
||||
return src
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# subprocess 실행 + stdout 스트리밍
|
||||
# ===========================================================================
|
||||
|
||||
def _run_blender_subprocess(
|
||||
blender_exe: str,
|
||||
runner_script: str,
|
||||
*,
|
||||
log_callback,
|
||||
timeout_sec: int = 1800,
|
||||
) -> tuple[int, str]:
|
||||
"""Blender를 subprocess로 호출. stdout 줄 단위로 log_callback에 전달."""
|
||||
cmd = [blender_exe, "--background", "--python", runner_script]
|
||||
log_callback(f" $ {Path(blender_exe).name} --background --python <runner.py>")
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return -1, f"Blender 실행 실패: {e}"
|
||||
|
||||
captured: list[str] = []
|
||||
t_start = _time.time()
|
||||
try:
|
||||
for raw_line in proc.stdout: # type: ignore
|
||||
line = raw_line.rstrip()
|
||||
captured.append(line)
|
||||
if any(tag in line for tag in
|
||||
("[blender_renderer]", "[bpy-gate]", "[bpy-builder]",
|
||||
"Saved:", "Render finished")):
|
||||
log_callback(f" {line}")
|
||||
if _time.time() - t_start > timeout_sec:
|
||||
proc.kill()
|
||||
captured.append(f"[timeout] {timeout_sec}초 초과")
|
||||
log_callback(f" ⚠ {timeout_sec}초 초과 — Blender 강제 종료")
|
||||
break
|
||||
proc.wait(timeout=10)
|
||||
except Exception as e:
|
||||
proc.kill()
|
||||
captured.append(f"[exception] {e}")
|
||||
|
||||
return proc.returncode, "\n".join(captured)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 메인 진입점
|
||||
# ===========================================================================
|
||||
|
||||
def run_blender_render(
|
||||
app,
|
||||
blender_exe: str | None,
|
||||
params_json: str,
|
||||
*,
|
||||
time_preset: str = "daytime",
|
||||
engine: str = "CYCLES",
|
||||
samples: int = 128,
|
||||
output_path: str = "structure_render.png",
|
||||
transparent_bg: bool = False,
|
||||
save_blend: bool = False,
|
||||
save_glb: bool = False,
|
||||
structure_kind: str | None = None,
|
||||
timeout_sec: int = 1800,
|
||||
) -> None:
|
||||
"""구조물 단독을 Blender Cycles로 렌더 + Harness 통합.
|
||||
|
||||
AI 워크플로(Step 4)와 **별개의 트랙**입니다. 결과 PNG는 추후 지형 합성([C])
|
||||
의 입력으로 사용되거나 단독 발표용으로 사용됩니다.
|
||||
|
||||
Args:
|
||||
app: scanvas_maker.SCanvasApp (상태/로그/UI scheduling 접근)
|
||||
blender_exe: Blender 실행파일 경로. None이면 자동 탐색.
|
||||
params_json: GateParams 또는 IntakeTowerParams JSON 경로.
|
||||
time_preset: 'daytime' | 'sunset' | 'overcast'
|
||||
engine: 'CYCLES' (사실적) | 'BLENDER_EEVEE_NEXT' (빠른 미리보기)
|
||||
samples: Cycles 샘플 수 (CYCLES 외 엔진은 무시)
|
||||
output_path: 결과 PNG. 기본 'structure_render.png' (AI 결과와 분리)
|
||||
transparent_bg: True면 RGBA + 투명 배경 (지형 합성 입력용)
|
||||
save_blend: True면 .blend 추가 저장
|
||||
save_glb: True면 .glb 추가 저장 (VR/AR/외부 뷰어용)
|
||||
structure_kind: 'gate' | 'intake_tower' | None (auto-detect)
|
||||
timeout_sec: subprocess 강제 종료 임계 (기본 30분)
|
||||
|
||||
UI / harness 통합 동작:
|
||||
- app.log(...) 로 진행 상황 출력
|
||||
- app.set_status(...) 로 상태바 갱신
|
||||
- SeedManager로 결정론적 seed 산출 → Cycles seed 주입
|
||||
- QualityValidator로 결과 검증
|
||||
- JobLogger로 SQLite 이력 기록
|
||||
- 완료 후 app._show_structure_render(output_path) 호출
|
||||
(없으면 OS 기본 뷰어로 폴백)
|
||||
"""
|
||||
t_start = _time.time()
|
||||
job_id = None
|
||||
db = None
|
||||
runner_path: Path | None = None
|
||||
|
||||
# ── 0) 사전 점검 ────────────────────────────────────────────────────
|
||||
blender_exe = find_blender_executable(blender_exe)
|
||||
if not blender_exe:
|
||||
msg = (
|
||||
"Blender 실행파일을 찾을 수 없습니다.\n\n"
|
||||
"다음 중 하나로 해결:\n"
|
||||
" 1) 환경변수 BLENDER_EXE 설정 (절대경로)\n"
|
||||
" 2) 표준 위치에 설치 (Windows: "
|
||||
"C:\\Program Files\\Blender Foundation\\Blender X.X\\blender.exe)\n"
|
||||
" 3) PATH에 'blender' 추가\n\n"
|
||||
"다운로드: https://www.blender.org/download/"
|
||||
)
|
||||
app.after(0, lambda: app.log(f" ✗ {msg.splitlines()[0]}"))
|
||||
app.after(0, lambda m=msg: _show_error_dialog("Blender 없음", m))
|
||||
app.after(0, lambda: app.set_status("Blender 실행파일 없음", "#E74C3C"))
|
||||
return
|
||||
|
||||
if not Path(params_json).is_file():
|
||||
app.after(0, lambda p=params_json: app.log(f" ✗ params_json 없음: {p}"))
|
||||
app.after(0, lambda p=params_json: _show_error_dialog("파일 없음",
|
||||
f"파라미터 JSON 파일을 찾을 수 없습니다:\n{p}\n\n"
|
||||
"구조물 상세 3D 빌드 단계에서 먼저 JSON을 생성해야 합니다."))
|
||||
app.after(0, lambda: app.set_status("params.json 없음", "#E74C3C"))
|
||||
return
|
||||
|
||||
if structure_kind is None:
|
||||
structure_kind = detect_structure_kind(params_json)
|
||||
if structure_kind not in _STRUCTURE_REGISTRY:
|
||||
app.after(0, lambda k=structure_kind: app.log(
|
||||
f" ✗ 구조물 종류 인식 실패 (kind={k!r})"))
|
||||
kinds = ', '.join(_STRUCTURE_REGISTRY.keys())
|
||||
app.after(0, lambda p=params_json, ks=kinds: _show_error_dialog(
|
||||
"구조물 인식 실패",
|
||||
f"JSON에서 구조물 종류를 자동 감지할 수 없습니다.\n"
|
||||
f"params_json: {p}\n"
|
||||
f"지원 종류: {ks}"))
|
||||
return
|
||||
|
||||
s_canvas_dir = str(Path(params_json).resolve().parent)
|
||||
builder_module = _STRUCTURE_REGISTRY[structure_kind]["module"]
|
||||
builder_path = Path(s_canvas_dir) / f"{builder_module}.py"
|
||||
if not builder_path.is_file():
|
||||
app.after(0, lambda bp=builder_path: app.log(f" ✗ 빌더 모듈 없음: {bp}"))
|
||||
app.after(0, lambda bm=builder_module, bp=builder_path: _show_error_dialog(
|
||||
"빌더 모듈 없음",
|
||||
f"{bm}.py 가 다음 위치에 없습니다:\n{bp}"))
|
||||
return
|
||||
|
||||
# ── 1) Harness — Job 시작 ────────────────────────────────────────────
|
||||
dxf_hash = ""
|
||||
prompt_hash = ""
|
||||
prompt_ver = "blender_v1"
|
||||
seed = 0
|
||||
|
||||
try:
|
||||
dxf_hash = app._get_dxf_hash()
|
||||
except Exception:
|
||||
try:
|
||||
import hashlib
|
||||
with open(params_json, "rb") as f:
|
||||
dxf_hash = hashlib.sha256(f.read()).hexdigest()[:16]
|
||||
except Exception:
|
||||
dxf_hash = ""
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
prompt_hash = app._get_prompt_hash(
|
||||
f"engine={engine}|time={time_preset}|samples={samples}|"
|
||||
f"transparent={transparent_bg}|kind={structure_kind}"
|
||||
)
|
||||
|
||||
if app.job_logger and _HARNESS_OK:
|
||||
try:
|
||||
db = get_db_session()
|
||||
job = app.job_logger.create_job(db, app.dxf_path or params_json, dxf_hash)
|
||||
job_id = job.id
|
||||
seed = app.seed_mgr.get_or_create_seed(db, job_id, dxf_hash)
|
||||
if app.prompt_reg:
|
||||
prompt_ver = app.prompt_reg.latest_version() or "blender_v1"
|
||||
app.job_logger.start_job(db, job_id, seed, prompt_ver, prompt_hash)
|
||||
app.after(0, lambda jid=job_id, s=seed: app.log(
|
||||
f" Harness: job#{jid}, {SeedManager.describe(s)}, "
|
||||
f"engine={engine}, samples={samples}, time={time_preset}, "
|
||||
f"bg={'transparent' if transparent_bg else 'sky'}"))
|
||||
except Exception as e:
|
||||
app.after(0, lambda em=str(e): app.log(f" Harness 초기화 경고: {em}"))
|
||||
|
||||
# ── 2) Wrapper script + subprocess 실행 ──────────────────────────────
|
||||
try:
|
||||
# 출력 해상도 — app.target_resolution 우선, 없으면 1920×1080
|
||||
# (구조물 단독 렌더는 AI 입력 해상도와 다를 수 있어 별도 속성도 검사)
|
||||
struct_tgt = getattr(app, "structure_render_resolution", None)
|
||||
if struct_tgt and struct_tgt[0] > 0 and struct_tgt[1] > 0:
|
||||
width, height = int(struct_tgt[0]), int(struct_tgt[1])
|
||||
else:
|
||||
tgt = getattr(app, "target_resolution", None)
|
||||
if tgt and tgt[0] > 0 and tgt[1] > 0:
|
||||
width, height = int(tgt[0]), int(tgt[1])
|
||||
else:
|
||||
width, height = 1920, 1080
|
||||
|
||||
out_abs = str(Path(output_path).resolve())
|
||||
blend_path = (Path(out_abs).with_suffix(".blend").as_posix()
|
||||
if save_blend else None)
|
||||
glb_path = (Path(out_abs).with_suffix(".glb").as_posix()
|
||||
if save_glb else None)
|
||||
|
||||
runner_src = _generate_blender_runner(
|
||||
s_canvas_dir=s_canvas_dir,
|
||||
structure_kind=structure_kind,
|
||||
params_json=str(Path(params_json).resolve()),
|
||||
output_path=out_abs,
|
||||
seed=seed,
|
||||
samples=samples,
|
||||
engine=engine,
|
||||
time_preset=time_preset,
|
||||
width=width,
|
||||
height=height,
|
||||
transparent_bg=transparent_bg,
|
||||
blend_path=blend_path,
|
||||
glb_path=glb_path,
|
||||
)
|
||||
|
||||
tmp_dir = tempfile.gettempdir()
|
||||
runner_path = Path(tmp_dir) / f"scanvas_blender_runner_{os.getpid()}.py"
|
||||
runner_path.write_text(runner_src, encoding="utf-8")
|
||||
|
||||
bg_label = "투명배경" if transparent_bg else "Sky 배경"
|
||||
app.after(0, lambda be=blender_exe, sk=structure_kind, w=width, h=height,
|
||||
s=seed, bl=bg_label: app.log(
|
||||
f" Blender 실행 중... ({Path(be).name}, {sk}, {w}×{h}, "
|
||||
f"seed={s}, {bl})"))
|
||||
app.after(0, lambda: app.set_status("Blender 렌더링 중...", "#3498DB"))
|
||||
|
||||
def _ui_log(msg: str):
|
||||
app.after(0, lambda m=msg: app.log(m))
|
||||
|
||||
rc, full_log = _run_blender_subprocess(
|
||||
blender_exe, str(runner_path),
|
||||
log_callback=_ui_log,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
tail = "\n".join(full_log.splitlines()[-30:])
|
||||
err_msg = f"Blender 종료 코드 {rc}.\n\n마지막 출력:\n{tail}"
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, f"rc={rc}")
|
||||
app.after(0, lambda r=rc: app.log(f" ✗ Blender 실패 (rc={r})"))
|
||||
app.after(0, lambda em=err_msg: _show_error_dialog("Blender 오류", em))
|
||||
app.after(0, lambda: app.set_status("Blender 렌더링 실패", "#E74C3C"))
|
||||
return
|
||||
|
||||
if not Path(out_abs).is_file():
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, "출력 PNG 없음")
|
||||
app.after(0, lambda o=out_abs: app.log(f" ✗ 출력 파일이 생성되지 않았습니다: {o}"))
|
||||
app.after(0, lambda: app.set_status("출력 파일 없음", "#E74C3C"))
|
||||
return
|
||||
|
||||
# ── 3) 출력 후처리 (해상도 강제 시) ─────────────────────────────
|
||||
rendered = _open_image(out_abs)
|
||||
|
||||
# 투명 PNG는 RGBA 유지 (RGB 변환하면 알파 손실)
|
||||
if transparent_bg and rendered.mode != "RGBA":
|
||||
rendered = rendered.convert("RGBA")
|
||||
|
||||
# 사용자가 명시적으로 다른 해상도를 요청한 경우만 리사이즈
|
||||
# (struct_tgt 기준; 일반적으론 빌더가 만든 그대로 사용)
|
||||
target_size = (width, height)
|
||||
if rendered.size != target_size:
|
||||
src_size = rendered.size
|
||||
app.after(0, lambda s=src_size, t=target_size: app.log(
|
||||
f" 화질 리사이즈: {s[0]}x{s[1]} → {t[0]}x{t[1]}"))
|
||||
rendered = rendered.resize(target_size, _image_lanczos())
|
||||
rendered.save(out_abs)
|
||||
|
||||
latency_ms = (_time.time() - t_start) * 1000
|
||||
|
||||
# ── 4) 품질 검증 ────────────────────────────────────────────────
|
||||
quality_score = 0.0
|
||||
if app.quality_val:
|
||||
try:
|
||||
vr = app.quality_val.validate(Path(out_abs))
|
||||
quality_score = vr.score
|
||||
app.after(0, lambda s=vr.summary: app.log(f" 품질검증: {s}"))
|
||||
except Exception as e:
|
||||
app.after(0, lambda em=str(e): app.log(f" 품질검증 오류: {em}"))
|
||||
|
||||
# ── 5) Job 완료 ─────────────────────────────────────────────────
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.complete_job(
|
||||
db, job_id, out_abs, quality_score, latency_ms
|
||||
)
|
||||
|
||||
app.after(0, lambda o=out_abs, sz=rendered.size, lm=latency_ms,
|
||||
q=quality_score, s=seed: app.log(
|
||||
f" Blender 렌더링 완료! → {o} ({sz}) "
|
||||
f"[{lm:.0f}ms, 품질={q:.2f}, seed={s}]"))
|
||||
app.after(0, lambda: app.set_status("Blender 렌더링 완료", "#2ECC71"))
|
||||
|
||||
# ── 6) 결과 표시 — AI 워크플로와 분리 ────────────────────────────
|
||||
# 우선 app._show_structure_render(path) 시도. 없으면 OS 기본 뷰어 폴백.
|
||||
def _present_result(path=out_abs):
|
||||
shown = False
|
||||
shower = getattr(app, "_show_structure_render", None)
|
||||
if callable(shower):
|
||||
try:
|
||||
shower(path)
|
||||
shown = True
|
||||
except Exception as e:
|
||||
app.log(f" 결과 표시 실패 (_show_structure_render): {e}")
|
||||
if not shown:
|
||||
if _open_in_os_default_viewer(path):
|
||||
app.log(f" 결과 PNG를 기본 뷰어로 열었습니다: {path}")
|
||||
else:
|
||||
app.log(f" 결과 PNG: {path} (뷰어 자동 실행 실패 — "
|
||||
f"파일을 직접 열어 확인하세요)")
|
||||
|
||||
app.after(0, _present_result)
|
||||
|
||||
except Exception as e:
|
||||
if app.job_logger and db and job_id:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, str(e))
|
||||
err_msg = str(e)[:300]
|
||||
app.after(0, lambda em=err_msg: app.log(f" Blender 워커 오류: {em}"))
|
||||
app.after(0, lambda: app.set_status("렌더링 실패", "#E74C3C"))
|
||||
app.after(0, lambda em=err_msg: _show_error_dialog(
|
||||
"Blender 워커 오류", f"실행 오류:\n{em}"))
|
||||
finally:
|
||||
if runner_path and runner_path.is_file():
|
||||
with contextlib.suppress(Exception):
|
||||
runner_path.unlink()
|
||||
if db:
|
||||
with contextlib.suppress(Exception):
|
||||
db.close()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CLI 진입점 (단독 실행 / 디버그용)
|
||||
# ===========================================================================
|
||||
|
||||
class _StubApp:
|
||||
"""app 인터페이스 흉내 — CLI 모드에서 가짜 app 객체로 사용."""
|
||||
def __init__(self, dxf_path: str = ""):
|
||||
self.dxf_path = dxf_path
|
||||
self.target_resolution = None
|
||||
self.structure_render_resolution = None
|
||||
self.job_logger = None
|
||||
self.seed_mgr = None
|
||||
self.prompt_reg = None
|
||||
self.quality_val = None
|
||||
|
||||
def log(self, msg: str):
|
||||
print(msg)
|
||||
|
||||
def set_status(self, text: str, color: str = ""):
|
||||
print(f"[status] {text}")
|
||||
|
||||
def after(self, delay_ms: int, fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
print(f"[after-error] {e}")
|
||||
|
||||
def _get_dxf_hash(self) -> str:
|
||||
return ""
|
||||
|
||||
def _get_prompt_hash(self, prompt: str) -> str:
|
||||
import hashlib
|
||||
return hashlib.sha256(prompt.encode()).hexdigest()[:16]
|
||||
|
||||
def _show_structure_render(self, path: str):
|
||||
print(f"[structure-render] {path}")
|
||||
|
||||
|
||||
def _cli():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Blender 헤드리스 구조물 렌더 (S-CANVAS 외부 단독 실행)"
|
||||
)
|
||||
ap.add_argument("--params", required=True, help="GateParams/IntakeTowerParams JSON")
|
||||
ap.add_argument("--blender", default=None,
|
||||
help="Blender 실행파일 경로 (없으면 자동 탐색)")
|
||||
ap.add_argument("--output", default="structure_render.png",
|
||||
help="출력 PNG (기본 'structure_render.png')")
|
||||
ap.add_argument("--time", default="daytime",
|
||||
choices=["daytime", "sunset", "overcast"])
|
||||
ap.add_argument("--engine", default="CYCLES",
|
||||
choices=["CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"])
|
||||
ap.add_argument("--samples", type=int, default=128)
|
||||
ap.add_argument("--width", type=int, default=1920)
|
||||
ap.add_argument("--height", type=int, default=1080)
|
||||
ap.add_argument("--transparent", action="store_true",
|
||||
help="투명 배경 RGBA PNG (지형 합성 입력용)")
|
||||
ap.add_argument("--save-blend", action="store_true")
|
||||
ap.add_argument("--save-glb", action="store_true")
|
||||
ap.add_argument("--kind", default=None,
|
||||
choices=list(_STRUCTURE_REGISTRY.keys()),
|
||||
help="구조물 종류 (없으면 JSON에서 자동 감지)")
|
||||
ap.add_argument("--timeout", type=int, default=1800,
|
||||
help="subprocess timeout (초)")
|
||||
args = ap.parse_args()
|
||||
|
||||
stub = _StubApp()
|
||||
stub.structure_render_resolution = (args.width, args.height)
|
||||
|
||||
run_blender_render(
|
||||
stub,
|
||||
blender_exe=args.blender,
|
||||
params_json=args.params,
|
||||
time_preset=args.time,
|
||||
engine=args.engine,
|
||||
samples=args.samples,
|
||||
output_path=args.output,
|
||||
transparent_bg=args.transparent,
|
||||
save_blend=args.save_blend,
|
||||
save_glb=args.save_glb,
|
||||
structure_kind=args.kind,
|
||||
timeout_sec=args.timeout,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
@@ -32,9 +32,9 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
import pyproj
|
||||
@@ -187,7 +187,7 @@ def _sample_grid_bilinear(elev: np.ndarray,
|
||||
return v0 * (1 - ty) + v1 * ty
|
||||
|
||||
|
||||
def _try_local_geotiff(cache_dir: Path) -> Optional[tuple[np.ndarray, tuple[float, float, float, float], str]]:
|
||||
def _try_local_geotiff(cache_dir: Path) -> tuple[np.ndarray, tuple[float, float, float, float], str] | None:
|
||||
"""cache/dem/local.tif 가 있으면 읽어 (elev, (lat_max,lon_min,lat_min,lon_max), label) 반환.
|
||||
|
||||
rasterio가 없거나 파일 없으면 None.
|
||||
@@ -272,15 +272,14 @@ def _densify_polygon(poly_xy: np.ndarray, max_seg_len: float) -> np.ndarray:
|
||||
seg = p1 - p0
|
||||
L = float(np.hypot(seg[0], seg[1]))
|
||||
k = max(1, int(np.ceil(L / max(max_seg_len, 1e-6))))
|
||||
for t in np.linspace(0.0, 1.0, k, endpoint=False):
|
||||
out.append(p0 + seg * t)
|
||||
out.extend(p0 + seg * t for t in np.linspace(0.0, 1.0, k, endpoint=False))
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
|
||||
|
||||
def _generate_ring_points_hull(outer_xyxy: tuple[float, float, float, float],
|
||||
inner_hull_xy: np.ndarray,
|
||||
grid_step: float,
|
||||
precomputed_boundary: Optional[np.ndarray] = None,
|
||||
precomputed_boundary: np.ndarray | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""hull 바깥쪽 격자점 + hull 엣지 세분화 점들을 결합해 링 XY 반환.
|
||||
|
||||
@@ -293,7 +292,6 @@ def _generate_ring_points_hull(outer_xyxy: tuple[float, float, float, float],
|
||||
pts: (M, 2) 전체 링 점
|
||||
n_hull_boundary: 끝 n개가 hull 경계 점. Z 오버라이드/feather용.
|
||||
"""
|
||||
from matplotlib.path import Path as MplPath
|
||||
ox0, oy0, ox1, oy1 = outer_xyxy
|
||||
xs = np.arange(ox0, ox1 + grid_step * 0.5, grid_step)
|
||||
ys = np.arange(oy0, oy1 + grid_step * 0.5, grid_step)
|
||||
@@ -349,10 +347,7 @@ def _generate_ring_points_hull(outer_xyxy: tuple[float, float, float, float],
|
||||
add[:, sort_col] = mids
|
||||
add[:, fixed_col] = fixed_val
|
||||
parts.append(add)
|
||||
if parts:
|
||||
boundary_pts = np.unique(np.round(np.vstack(parts), 3), axis=0)
|
||||
else:
|
||||
boundary_pts = raw
|
||||
boundary_pts = np.unique(np.round(np.vstack(parts), 3), axis=0) if parts else raw
|
||||
else:
|
||||
boundary_pts = _densify_polygon(inner_hull_xy, grid_step)
|
||||
|
||||
@@ -372,16 +367,16 @@ def build_extended_terrain_ring(projected_bounds: tuple[float, float, float, flo
|
||||
origin: np.ndarray,
|
||||
src_crs: str,
|
||||
buffer_m: float = 1000.0,
|
||||
tin_xyz_zerobased: Optional[np.ndarray] = None,
|
||||
grid_step_m: Optional[float] = None,
|
||||
tin_xyz_zerobased: np.ndarray | None = None,
|
||||
grid_step_m: float | None = None,
|
||||
feather_m: float = 80.0,
|
||||
zoom: int = 13,
|
||||
cache_dir: str | Path = "cache/dem",
|
||||
source: str = "auto",
|
||||
use_hull_boundary: bool = True,
|
||||
datum_offset_override: Optional[float] = None,
|
||||
elev_grid_override: Optional[np.ndarray] = None,
|
||||
grid_bounds_override: Optional[tuple[float, float, float, float]] = None,
|
||||
datum_offset_override: float | None = None,
|
||||
elev_grid_override: np.ndarray | None = None,
|
||||
grid_bounds_override: tuple[float, float, float, float] | None = None,
|
||||
log_fn: Callable[[str], None] = print) -> DemExtendResult:
|
||||
"""DXF bbox 외곽을 실제 DEM으로 확장한 메시 생성.
|
||||
|
||||
@@ -444,7 +439,6 @@ def build_extended_terrain_ring(projected_bounds: tuple[float, float, float, flo
|
||||
# bbox 사이 얇은 영역을 중복 덮거나 T-vertex/fin을 만들었다. bbox를 inner로
|
||||
# 쓰면 DEM 링 inner 경계가 TIN bbox 선과 정확히 맞닿아 중복/fin 제거.
|
||||
hull_xy_abs = None
|
||||
hull_z_abs = None
|
||||
n_hull_boundary = 0
|
||||
boundary_mode = "bbox"
|
||||
if use_hull_boundary and tin_xyz_zerobased is not None and len(tin_xyz_zerobased) >= 3:
|
||||
@@ -455,10 +449,6 @@ def build_extended_terrain_ring(projected_bounds: tuple[float, float, float, flo
|
||||
hull_xy_abs = np.array([
|
||||
[tbx0, tby0], [tbx1, tby0], [tbx1, tby1], [tbx0, tby1],
|
||||
], dtype=np.float64)
|
||||
_tin_tree_corner = cKDTree(tin_abs[:, :2])
|
||||
_, _ci = _tin_tree_corner.query(hull_xy_abs, k=1)
|
||||
hull_z_abs = tin_abs[_ci, 2]
|
||||
|
||||
# **TIN의 실제 bbox 변 정점**만 선별해 DEM 링 inner 경계로 사용 →
|
||||
# 두 메시가 동일 XY의 공유 정점을 가지므로 T-vertex/fin 원천 제거.
|
||||
bbox_tol_tin = max(tbx1 - tbx0, tby1 - tby0) * 1e-4 + 1e-3
|
||||
@@ -483,7 +473,7 @@ def build_extended_terrain_ring(projected_bounds: tuple[float, float, float, flo
|
||||
except Exception as e:
|
||||
log_fn(f" [DEM] bbox 경계 구성 실패 ({e}) — 단순 bbox 링 폴백")
|
||||
ring_xy = _generate_ring_points(inner, outer, grid_step_m)
|
||||
hull_xy_abs = None; hull_z_abs = None; n_hull_boundary = 0
|
||||
hull_xy_abs = None; n_hull_boundary = 0
|
||||
else:
|
||||
ring_xy = _generate_ring_points(inner, outer, grid_step_m)
|
||||
log_fn(f" [DEM] 링 격자점: {len(ring_xy)}개 (step={grid_step_m:.1f}m, "
|
||||
@@ -727,7 +717,7 @@ def build_extended_terrain_ring(projected_bounds: tuple[float, float, float, flo
|
||||
try:
|
||||
tri = Delaunay(ring_xyz[:, :2])
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"링 메시 Delaunay 실패: {e}")
|
||||
raise RuntimeError(f"링 메시 Delaunay 실패: {e}") from e
|
||||
|
||||
# inner(TIN bbox) 내부 삼각형 제거 — **3중 가드**로 침범 원천 차단.
|
||||
# (a) centroid 가 inner bbox 내부
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
@@ -37,7 +35,7 @@ class ParsedDimension:
|
||||
position: tuple # (x, y) 좌표
|
||||
confidence: float # 0.0 ~ 1.0 신뢰도
|
||||
unit: str = "m" # 단위 (m, mm)
|
||||
secondary: Optional[float] = None # 보조값 (slope의 경우 비율 등)
|
||||
secondary: float | None = None # 보조값 (slope의 경우 비율 등)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -24,7 +24,6 @@ import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
@@ -101,13 +100,13 @@ class GeometryResult:
|
||||
dimension_count: int = 0
|
||||
excluded_layers: list[str] = field(default_factory=list)
|
||||
|
||||
def largest_closed(self) -> Optional[Shape]:
|
||||
def largest_closed(self) -> Shape | None:
|
||||
"""면적이 가장 큰 closed shape 반환."""
|
||||
if not self.closed_shapes:
|
||||
return None
|
||||
return max(self.closed_shapes, key=lambda s: s.area)
|
||||
|
||||
def longest_polyline(self) -> Optional[Shape]:
|
||||
def longest_polyline(self) -> Shape | None:
|
||||
"""가장 긴 폴리라인 반환."""
|
||||
polys = [s for s in self.shapes if s.kind == "polyline" and len(s.points) > 2]
|
||||
if not polys:
|
||||
@@ -146,10 +145,7 @@ def is_excluded_layer(layer_name: str) -> bool:
|
||||
"""레이어명이 주석/치수/해치 등 비구조 레이어인지 확인."""
|
||||
if not layer_name:
|
||||
return False
|
||||
for pat in _EXCLUDE_LAYER_PATTERNS:
|
||||
if re.search(pat, layer_name, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
return any(re.search(pat, layer_name, re.IGNORECASE) for pat in _EXCLUDE_LAYER_PATTERNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -195,16 +191,13 @@ def detect_unit_scale(doc) -> tuple[float, str]:
|
||||
return 1.0, "m"
|
||||
|
||||
# 2) $INSUNITS header (DIMENSION이 없거나 모호할 때)
|
||||
# 0=unitless, 1=inch, 2=feet, 4=mm, 5=cm, 6=m
|
||||
# inch/feet는 한국 토목도면에서 거의 없음 → 무시하고 bbox로 판단
|
||||
_INSUNITS_TO_SCALE = {4: (0.001, "mm"), 5: (0.01, "cm"), 6: (1.0, "m")}
|
||||
try:
|
||||
insunits = int(doc.header.get("$INSUNITS", 0))
|
||||
# 0=unitless, 1=inch, 2=feet, 4=mm, 5=cm, 6=m
|
||||
if insunits == 4:
|
||||
return 0.001, "mm"
|
||||
elif insunits == 6:
|
||||
return 1.0, "m"
|
||||
elif insunits == 5:
|
||||
return 0.01, "cm"
|
||||
# inch/feet는 한국 토목도면에서 거의 없음 → 무시하고 bbox로 판단
|
||||
if insunits in _INSUNITS_TO_SCALE:
|
||||
return _INSUNITS_TO_SCALE[insunits]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -256,7 +249,7 @@ def extract_structural_geometry(
|
||||
exclude_layers: bool = True,
|
||||
include_open: bool = True,
|
||||
min_points: int = 2,
|
||||
unit_override: Optional[str] = None,
|
||||
unit_override: str | None = None,
|
||||
explode_blocks: bool = False,
|
||||
max_block_depth: int = 4,
|
||||
) -> GeometryResult:
|
||||
@@ -294,15 +287,12 @@ def extract_structural_geometry(
|
||||
|
||||
excluded_set = set()
|
||||
|
||||
def _process_entity(entity, inherited_layer: str = None, depth: int = 0):
|
||||
def _process_entity(entity, inherited_layer: str | None = None, depth: int = 0):
|
||||
"""단일 엔티티 처리. INSERT면 explode_blocks에 따라 재귀 확장."""
|
||||
etype = entity.dxftype()
|
||||
# 블록 내부 엔티티의 layer가 "0"이면 INSERT의 레이어를 상속
|
||||
raw_layer = getattr(entity.dxf, "layer", "")
|
||||
if inherited_layer and raw_layer in ("", "0"):
|
||||
layer = inherited_layer
|
||||
else:
|
||||
layer = raw_layer
|
||||
layer = inherited_layer if inherited_layer and raw_layer in ("", "0") else raw_layer
|
||||
|
||||
# 레이어 필터
|
||||
if exclude_layers and is_excluded_layer(layer):
|
||||
@@ -371,7 +361,7 @@ def extract_structural_geometry(
|
||||
return result
|
||||
|
||||
|
||||
def _extract_entity(entity, etype: str, scale: float) -> Optional[Shape]:
|
||||
def _extract_entity(entity, etype: str, scale: float) -> Shape | None:
|
||||
"""개별 DXF 엔티티 → Shape 변환."""
|
||||
if etype == "LWPOLYLINE":
|
||||
pts = [(p[0] * scale, p[1] * scale) for p in entity.get_points()]
|
||||
@@ -573,7 +563,7 @@ if __name__ == "__main__":
|
||||
print(f" 단위: {r.detected_unit} (scale={r.unit_scale})")
|
||||
print(f" 총 지오메트리: {len(r.shapes)}개")
|
||||
print(f" closed: {len(r.closed_shapes)}, open: {len(r.open_shapes)}")
|
||||
print(f" 레이어별:")
|
||||
print(" 레이어별:")
|
||||
for layer, shapes in sorted(r.by_layer.items(), key=lambda x: -len(x[1]))[:10]:
|
||||
print(f" {layer}: {len(shapes)}개")
|
||||
b = r.total_bounds
|
||||
|
||||
@@ -17,7 +17,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -121,7 +120,7 @@ SECONDARY_KEYWORDS = {
|
||||
# 분류 함수
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify_by_filename(filename: str) -> Optional[str]:
|
||||
def classify_by_filename(filename: str) -> str | None:
|
||||
"""파일명에서 구조물 유형을 추정.
|
||||
|
||||
Args:
|
||||
@@ -160,7 +159,7 @@ def classify_by_filename(filename: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def classify_by_filenames(filenames: list[str]) -> Optional[str]:
|
||||
def classify_by_filenames(filenames: list[str]) -> str | None:
|
||||
"""여러 파일의 이름을 종합해서 가장 가능성 높은 유형 추정."""
|
||||
votes = {}
|
||||
for f in filenames:
|
||||
@@ -174,7 +173,7 @@ def classify_by_filenames(filenames: list[str]) -> Optional[str]:
|
||||
return max(votes.items(), key=lambda x: x[1])[0]
|
||||
|
||||
|
||||
def suggest_with_confidence(filename: str) -> tuple[Optional[str], float]:
|
||||
def suggest_with_confidence(filename: str) -> tuple[str | None, float]:
|
||||
"""추정 결과 + 신뢰도 반환.
|
||||
|
||||
Returns:
|
||||
@@ -186,10 +185,8 @@ def suggest_with_confidence(filename: str) -> tuple[Optional[str], float]:
|
||||
|
||||
# 주 키워드 매칭 개수 및 매칭된 패턴 확인
|
||||
for template_id, patterns in FILENAME_PATTERNS.items():
|
||||
matched_patterns = []
|
||||
for pat in patterns:
|
||||
if re.search(pat, cleaned, re.IGNORECASE):
|
||||
matched_patterns.append(pat)
|
||||
matched_patterns = [pat for pat in patterns
|
||||
if re.search(pat, cleaned, re.IGNORECASE)]
|
||||
|
||||
if matched_patterns:
|
||||
# 매칭 개수에 따라 신뢰도 계산
|
||||
|
||||
99
fix_bpy_import.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""fix_bpy_import.py — scanvas_maker.py 의 bpy import 오류 핫픽스.
|
||||
|
||||
증상:
|
||||
[모듈 없음] Blender 렌더 모듈을 찾을 수 없습니다: No module named 'bpy'
|
||||
|
||||
원인:
|
||||
apply_blender_patch.py v1 의 P1 콜백 안에서 dump_params_to_json 을
|
||||
gate_3d_builder_bpy 에서 import 하도록 되어 있는데, 이 모듈은 첫 줄에
|
||||
`import bpy` 가 있어 S-CANVAS conda env(GUI 측)에서는 import 불가.
|
||||
|
||||
수정:
|
||||
동일 함수의 bpy-무의존 버전인 params_to_json.dump_dataclass_to_json 으로 교체.
|
||||
(params_to_json.py 는 이미 D:\\에 있음)
|
||||
|
||||
이 스크립트는 idempotent: 이미 수정돼있으면 변경 없음.
|
||||
백업: 실행 전 scanvas_maker.py.bak_bpyfix 자동 생성.
|
||||
|
||||
사용법:
|
||||
cd D:\\2026\\PROGRAM\\1_S-CANVAS
|
||||
python fix_bpy_import.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("scanvas_maker.py")
|
||||
BACKUP = Path("scanvas_maker.py.bak_bpyfix")
|
||||
|
||||
|
||||
# 정확한 한 줄을 교체 (CRLF/LF 무관 — bytes로 처리)
|
||||
OLD_LINE = b"from gate_3d_builder_bpy import dump_params_to_json as _dump_gate"
|
||||
NEW_LINE = b"from params_to_json import dump_dataclass_to_json as _dump_gate"
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.is_file():
|
||||
sys.exit(f"[ERR] {TARGET} 가 현재 폴더에 없습니다. "
|
||||
f"D:\\2026\\PROGRAM\\1_S-CANVAS 에서 실행하세요.")
|
||||
|
||||
raw = TARGET.read_bytes()
|
||||
print(f"파일: {TARGET} ({len(raw):,} bytes)")
|
||||
|
||||
if NEW_LINE in raw:
|
||||
print("\n→ 이미 수정됨. 추가 작업 없음.")
|
||||
# 그래도 import 실제로 동작하는지 확인
|
||||
if not Path("params_to_json.py").is_file():
|
||||
print("\n ⚠ 경고: params_to_json.py 가 현재 폴더에 없습니다.")
|
||||
print(" S-CANVAS 폴더에 이 모듈이 있어야 import 가 성공합니다.")
|
||||
return 0
|
||||
|
||||
if OLD_LINE not in raw:
|
||||
print("\n[INFO] OLD 패턴을 찾지 못했습니다.")
|
||||
print(" apply_blender_patch.py 를 먼저 실행했는지 확인하세요.")
|
||||
# 진단 — 어떤 import가 있는지
|
||||
for keyword in [b"gate_3d_builder_bpy", b"_dump_gate", b"dump_params_to_json"]:
|
||||
n = raw.count(keyword)
|
||||
print(f" '{keyword.decode()}' 발생: {n}회")
|
||||
sys.exit(1)
|
||||
|
||||
# params_to_json.py 가 실제로 있는지 미리 확인
|
||||
if not Path("params_to_json.py").is_file():
|
||||
sys.exit("[ERR] params_to_json.py 가 현재 폴더에 없습니다. "
|
||||
"이 모듈이 import 대상입니다.")
|
||||
|
||||
# 단일 라인 치환
|
||||
new_raw = raw.replace(OLD_LINE, NEW_LINE, 1)
|
||||
n_replaced = raw.count(OLD_LINE) - new_raw.count(OLD_LINE)
|
||||
print(f" 교체 라인 수: {n_replaced}")
|
||||
|
||||
# AST parse 검증 (utf-8 디코드 후)
|
||||
try:
|
||||
text = new_raw.decode("utf-8")
|
||||
# CRLF면 그대로, AST는 양쪽 다 받음
|
||||
ast.parse(text)
|
||||
print(f" AST parse: OK ({len(text.splitlines()):,} lines)")
|
||||
except SyntaxError as e:
|
||||
sys.exit(f"\n[ERR] 수정 후 syntax error: {e}\n파일 변경 안 함.")
|
||||
|
||||
# 백업
|
||||
shutil.copy2(TARGET, BACKUP)
|
||||
print(f"\n 백업: {BACKUP}")
|
||||
|
||||
# 저장
|
||||
TARGET.write_bytes(new_raw)
|
||||
print(f"\n✓ 핫픽스 적용 완료: {TARGET} ({len(new_raw):,} bytes)")
|
||||
print("\n변경 내용:")
|
||||
print(f" - {OLD_LINE.decode()}")
|
||||
print(f" + {NEW_LINE.decode()}")
|
||||
print("\n다음 단계:")
|
||||
print(" 1) S-CANVAS 재시작 (또는 그냥 다이얼로그를 다시 열어 다시 시도)")
|
||||
print(" 2) '🎨 Blender 렌더' 버튼 클릭")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
@@ -16,13 +16,12 @@ GateParams 객체를 받아 PyVista 메쉬들을 생성한다:
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
|
||||
from gate_parser import GateParams
|
||||
import contextlib
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,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
|
||||
@@ -92,7 +93,7 @@ class GateBuilder:
|
||||
z_min = min(zs) - 1.0 # 바닥 1m 확장
|
||||
|
||||
# 닫힌 단면 (상류시작 바닥 → 프로파일 → 하류끝 바닥 → 돌아옴)
|
||||
closed_pts_2d = [(xs[0], z_min)] + list(zip(xs, zs)) + [(x_max, z_min)]
|
||||
closed_pts_2d = [(xs[0], z_min), *list(zip(xs, zs, strict=False)), (x_max, z_min)]
|
||||
|
||||
# Y 방향(span)으로 extrude
|
||||
span = p.total_span
|
||||
@@ -102,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) 생성.
|
||||
|
||||
@@ -119,7 +164,7 @@ class GateBuilder:
|
||||
pts[i + n] = [span, y, z]
|
||||
return pts
|
||||
|
||||
def _triangulate_prism(self, pts: np.ndarray, n: int) -> Optional[pv.PolyData]:
|
||||
def _triangulate_prism(self, pts: np.ndarray, n: int) -> pv.PolyData | None:
|
||||
"""프리즘 메쉬 생성 (두 개의 n-gon 끝면 + 측면 스트립)."""
|
||||
if len(pts) != 2 * n:
|
||||
return None
|
||||
@@ -136,11 +181,9 @@ class GateBuilder:
|
||||
|
||||
# 양끝면 (fan triangulation)
|
||||
# 앞면 (X=0)
|
||||
for i in range(1, n - 1):
|
||||
faces.append([3, 0, i, i + 1])
|
||||
faces.extend([3, 0, i, i + 1] for i in range(1, n - 1))
|
||||
# 뒷면 (X=span)
|
||||
for i in range(1, n - 1):
|
||||
faces.append([3, n, n + i + 1, n + i])
|
||||
faces.extend([3, n, n + i + 1, n + i] for i in range(1, n - 1))
|
||||
|
||||
faces_flat = np.concatenate(faces)
|
||||
return pv.PolyData(pts, faces_flat)
|
||||
@@ -219,14 +262,12 @@ class GateBuilder:
|
||||
)
|
||||
nose = self._make_pier_nose(cx, pier_w, body_y0, pier_bot_el, pier_top_el)
|
||||
if nose is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
mesh = mesh.merge(nose)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.meshes.append((mesh, COLORS["pier"], 1.0))
|
||||
|
||||
def _extrude_polygon_xy(self, poly_xy: list, z_bot: float, z_top: float) -> Optional[pv.PolyData]:
|
||||
def _extrude_polygon_xy(self, poly_xy: list, z_bot: float, z_top: float) -> pv.PolyData | None:
|
||||
"""임의 XY 폴리곤을 Z 방향으로 extrude해 3D 프리즘 메시 생성.
|
||||
|
||||
poly_xy: [(x, y), ...] chamber-local 좌표, 폐합 가정.
|
||||
@@ -307,12 +348,10 @@ class GateBuilder:
|
||||
return False
|
||||
if not (total_span * 0.2 <= w <= total_span * 1.5):
|
||||
return False
|
||||
if not (pier_length * 0.05 <= h <= pier_length * 1.2):
|
||||
return False
|
||||
return True
|
||||
return pier_length * 0.05 <= h <= pier_length * 1.2
|
||||
|
||||
def _make_pier_nose(self, cx: float, width: float,
|
||||
y_front: float, z_bot: float, z_top: float) -> Optional[pv.PolyData]:
|
||||
y_front: float, z_bot: float, z_top: float) -> pv.PolyData | None:
|
||||
"""교각 상류측 삼각형 물가르기 노즈."""
|
||||
half_w = width / 2
|
||||
nose_len = width * 1.2 # 노즈 돌출 길이
|
||||
@@ -365,16 +404,13 @@ class GateBuilder:
|
||||
|
||||
mid_el = (sill_el + top_el) / 2
|
||||
trunnion_el_user = p.el_trunnion_pin
|
||||
if abs(trunnion_el_user - mid_el) > 0.5:
|
||||
trunnion_el = mid_el
|
||||
else:
|
||||
trunnion_el = trunnion_el_user
|
||||
trunnion_el = mid_el if abs(trunnion_el_user - mid_el) > 0.5 else trunnion_el_user
|
||||
|
||||
dz_half = abs(trunnion_el - sill_el)
|
||||
horizontal = math.sqrt(max(0.01, radius ** 2 - dz_half ** 2))
|
||||
|
||||
body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
|
||||
crest_y_candidate: Optional[float] = None
|
||||
crest_y_candidate: float | None = None
|
||||
if p.ogee_profile:
|
||||
best_diff = float("inf")
|
||||
for (x, z) in p.ogee_profile:
|
||||
@@ -438,7 +474,7 @@ class GateBuilder:
|
||||
def _make_radial_skin(self, cx: float, width: float,
|
||||
sill_el: float, top_el: float,
|
||||
gate_y: float, trunnion_y: float, trunnion_el: float,
|
||||
radius: float) -> Optional[pv.PolyData]:
|
||||
radius: float) -> pv.PolyData | None:
|
||||
"""래디얼 게이트의 스킨플레이트 (원통면 일부).
|
||||
|
||||
sill·top 각도는 각 점의 dz만 정확히 알고 있으므로
|
||||
@@ -469,8 +505,7 @@ class GateBuilder:
|
||||
dz = math.sin(ang) * radius
|
||||
y = trunnion_y + dy
|
||||
z = trunnion_el + dz
|
||||
for s in [-half_w, half_w]:
|
||||
pts.append([cx + s, y, z])
|
||||
pts.extend([cx + s, y, z] for s in (-half_w, half_w))
|
||||
|
||||
pts = np.array(pts)
|
||||
|
||||
@@ -491,7 +526,7 @@ class GateBuilder:
|
||||
def _make_gate_arms(self, cx: float, width: float,
|
||||
sill_el: float, top_el: float,
|
||||
gate_y: float, trunnion_y: float, trunnion_el: float,
|
||||
radius: float) -> Optional[pv.PolyData]:
|
||||
radius: float) -> pv.PolyData | None:
|
||||
"""게이트 암: trunnion → skin 양 끝으로 뻗는 빔."""
|
||||
half_w = width / 2 - 0.15
|
||||
arm_thick = 0.3
|
||||
@@ -501,9 +536,9 @@ class GateBuilder:
|
||||
for side_cx in [cx - half_w, cx + half_w]:
|
||||
# Trunnion 위치
|
||||
t_pt = np.array([side_cx, trunnion_y, trunnion_el])
|
||||
|
||||
|
||||
# 수정: 암이 수문의 깊은 곡면(볼록한 중앙)까지 완전히 닿도록 Y좌표 연장
|
||||
s_pt_y = trunnion_y + radius
|
||||
s_pt_y = trunnion_y + radius
|
||||
s_pt = np.array([side_cx, s_pt_y, mid_el])
|
||||
|
||||
dir_v = s_pt - t_pt
|
||||
@@ -577,13 +612,11 @@ class GateBuilder:
|
||||
source = "parametric"
|
||||
|
||||
# 건설 기록을 raw_text_annotations에 남김
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
p.raw_text_annotations.append((
|
||||
f"[builder] bridge source={source} bbox=({x0:.2f},{y0:.2f},{x1:.2f},{y1:.2f})",
|
||||
0.0, 0.0
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
deck = self._make_box(x0, x1, y0, y1, deck_bot, deck_top)
|
||||
self.meshes.append((deck, COLORS["bridge_deck"], 1.0))
|
||||
|
||||
1168
gate_3d_builder_bpy.py
Normal file
@@ -17,11 +17,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import contextlib
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import ClassVar
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
@@ -90,21 +92,21 @@ class GateParams:
|
||||
plan_outline_polygon: list = field(default_factory=list) # 외곽: [(x,y), ...]
|
||||
pier_plan_polygons: list = field(default_factory=list) # 각 교각: [[(x,y), ...], ...]
|
||||
# 공도교(service bridge)의 실제 plan bbox — (x0, y0, x1, y1) local m, None이면 폴백
|
||||
bridge_plan_bbox: Optional[tuple] = None
|
||||
bridge_plan_bbox: tuple | None = None
|
||||
# 공도교 Y 방향 두께(도면 기반 실측). None이면 bbox Y 길이 사용.
|
||||
bridge_deck_thickness_m: float = 1.2
|
||||
|
||||
# 사용자 직접 지정 공도교 위치 (UI 편집 가능; 4개 값 모두 유효하면 다른 경로 대신 사용)
|
||||
# 기본 None/-1: 사용자 미입력으로 간주. 양수로 편집 시 override 적용.
|
||||
bridge_x_start: Optional[float] = None
|
||||
bridge_x_end: Optional[float] = None
|
||||
bridge_y_start: Optional[float] = None
|
||||
bridge_y_end: Optional[float] = None
|
||||
bridge_x_start: float | None = None
|
||||
bridge_x_end: float | None = None
|
||||
bridge_y_start: float | None = None
|
||||
bridge_y_end: float | None = None
|
||||
|
||||
# FLOW 화살표로 검출된 유수 방향 단위벡터 (DXF XY frame, dx²+dy²=1)
|
||||
# None이면 PCA만 사용 (span 180° 모호성 존재). 검출 시 plan_frame_angle_deg가
|
||||
# 전체 -180..180 범위로 정확 설정됨.
|
||||
flow_direction_2d: Optional[tuple] = None
|
||||
flow_direction_2d: tuple | None = None
|
||||
|
||||
# 메타데이터
|
||||
source_files: list = field(default_factory=list)
|
||||
@@ -136,7 +138,7 @@ class GateParser:
|
||||
"""여수로 수문 설치도 파서."""
|
||||
|
||||
# 구조 레이어 (concrete 본체 geometry)
|
||||
STRUCT_LAYERS = {
|
||||
STRUCT_LAYERS: ClassVar[set[str]] = {
|
||||
"CS-CONC-Spillway",
|
||||
"CS-CONC-Bridge",
|
||||
}
|
||||
@@ -219,14 +221,11 @@ class GateParser:
|
||||
for e in msp:
|
||||
if e.dxf.layer != "CS-CONC-Spillway":
|
||||
continue
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
if e.dxftype() == "LWPOLYLINE":
|
||||
for p in e.get_points():
|
||||
y_values.append(p[1])
|
||||
y_values.extend(p[1] for p in e.get_points())
|
||||
elif e.dxftype() == "LINE":
|
||||
y_values.extend([e.dxf.start.y, e.dxf.end.y])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not y_values:
|
||||
return
|
||||
@@ -356,7 +355,7 @@ class GateParser:
|
||||
z_shift = params.el_upstream_bed - z_min_local
|
||||
z_local = arr[:, 1] / 1000.0 + z_shift
|
||||
|
||||
params.ogee_profile = list(zip(x_local.tolist(), z_local.tolist()))
|
||||
params.ogee_profile = list(zip(x_local.tolist(), z_local.tolist(), strict=False))
|
||||
|
||||
# ----- 부속 구조물 존재성 검출 (Phase A) -----
|
||||
#
|
||||
@@ -395,7 +394,7 @@ class GateParser:
|
||||
|
||||
def _detect_flow_direction(self, msp,
|
||||
search_radius_mm: float = 10000.0,
|
||||
shaft_min_len_mm: float = 2000.0) -> Optional[tuple]:
|
||||
shaft_min_len_mm: float = 2000.0) -> tuple | None:
|
||||
"""도면의 "FLOW" 텍스트와 인접 화살표(LINE 클러스터)에서 유수 방향 추출.
|
||||
|
||||
알고리즘:
|
||||
@@ -460,7 +459,7 @@ class GateParser:
|
||||
my = (sy + ey) * 0.5
|
||||
if math.hypot(mx - tx, my - ty) > search_radius_mm:
|
||||
continue
|
||||
if L >= shaft_min_len_mm:
|
||||
if shaft_min_len_mm <= L:
|
||||
shaft_cands.append((L, sx, sy, ex, ey))
|
||||
else:
|
||||
arrow_pts.append((sx, sy))
|
||||
@@ -568,10 +567,8 @@ class GateParser:
|
||||
if mid_y <= y_mid:
|
||||
continue
|
||||
is_closed_dxf = False
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
is_closed_dxf = bool(e.closed)
|
||||
except Exception:
|
||||
pass
|
||||
is_closed_geom = self._is_closed(pts, tol=5.0)
|
||||
if not (is_closed_dxf or is_closed_geom):
|
||||
continue
|
||||
@@ -648,7 +645,7 @@ class GateParser:
|
||||
0.0, 0.0
|
||||
))
|
||||
|
||||
def _compute_plan_origin_mm(self, params: GateParams, msp) -> Optional[tuple]:
|
||||
def _compute_plan_origin_mm(self, params: GateParams, msp) -> tuple | None:
|
||||
"""pier_plan_polygons 또는 plan_outline_polygon의 DXF mm origin을 역산.
|
||||
|
||||
각 추출 방법이 이미 (0,0)-기준 로컬 좌표로 저장했으므로, DXF mm 좌표에서
|
||||
@@ -666,10 +663,10 @@ class GateParser:
|
||||
return self._plan_bbox_origin_mm(msp)
|
||||
return None
|
||||
|
||||
def _plan_bbox_origin_mm(self, msp) -> Optional[tuple]:
|
||||
def _plan_bbox_origin_mm(self, msp) -> tuple | None:
|
||||
"""plan 영역 CS-CONC-Spillway 전체 geometry의 DXF mm bbox min 반환."""
|
||||
try:
|
||||
from view_detector import detect_view_regions
|
||||
from view_detector import detect_view_regions # noqa: F401 (protective availability check)
|
||||
except ImportError:
|
||||
return None
|
||||
for fn in getattr(self, "_cached_paths", []) or []:
|
||||
@@ -697,13 +694,13 @@ class GateParser:
|
||||
# plan 영역만: Y 중간값 이상만 남김 (기존 y_mid 휴리스틱과 동일)
|
||||
y_sorted = sorted(ys)
|
||||
y_mid = y_sorted[len(y_sorted) // 2]
|
||||
xs_plan = [x for x, y in zip(xs, ys) if y > y_mid]
|
||||
xs_plan = [x for x, y in zip(xs, ys, strict=False) if y > y_mid]
|
||||
ys_plan = [y for y in ys if y > y_mid]
|
||||
if not xs_plan:
|
||||
return None
|
||||
return (min(xs_plan), min(ys_plan))
|
||||
|
||||
def _extract_bridge_bbox_mm(self, msp) -> Optional[tuple]:
|
||||
def _extract_bridge_bbox_mm(self, msp) -> tuple | None:
|
||||
"""Bridge 관련 레이어(CS-CONC-Bridge / 공도교 / 관리도로 등)의
|
||||
DXF mm bbox (x_min, y_min, x_max, y_max) 반환. 없으면 None.
|
||||
|
||||
@@ -755,7 +752,6 @@ class GateParser:
|
||||
return False
|
||||
|
||||
segs: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
plan_bbox = None
|
||||
# plan_bounds_mm는 호출 컨텍스트에서 y_mid 계산에 반영됐으므로
|
||||
# 여기서는 y > y_mid 필터만 일관 적용
|
||||
for e in msp:
|
||||
@@ -776,8 +772,7 @@ class GateParser:
|
||||
continue
|
||||
if not pts or sum(p[1] for p in pts) / len(pts) <= y_mid:
|
||||
continue
|
||||
for i in range(len(pts) - 1):
|
||||
segs.append((pts[i], pts[i + 1]))
|
||||
segs.extend(pairwise(pts))
|
||||
# closed flag이면 마지막→첫 번째도 추가
|
||||
try:
|
||||
if bool(getattr(e, "closed", False)):
|
||||
@@ -896,8 +891,7 @@ class GateParser:
|
||||
pts = [(p[0], p[1]) for p in e.get_points()]
|
||||
except Exception:
|
||||
continue
|
||||
for i in range(len(pts) - 1):
|
||||
pairs.append((pts[i], pts[i + 1]))
|
||||
pairs.extend(pairwise(pts))
|
||||
for p1, p2 in pairs:
|
||||
dx = p2[0] - p1[0]; dy = p2[1] - p1[1]
|
||||
if abs(dy) < 100.0:
|
||||
@@ -1008,7 +1002,7 @@ class GateParser:
|
||||
# ----- 텍스트 주석 스캔 -----
|
||||
|
||||
# 핵심 키워드 → 파라미터 매핑
|
||||
_ELEVATION_PATTERNS = [
|
||||
_ELEVATION_PATTERNS: ClassVar[list[tuple[str, str]]] = [
|
||||
(r"Gate Sill\s*EL\.?\s*(\d+\.?\d*)", "el_gate_sill"),
|
||||
(r"Weir Crest\s*EL\.?\s*(\d+\.?\d*)", "el_weir_crest"),
|
||||
(r"Gate Top\s*EL\.?\s*(\d+\.?\d*)", "el_gate_top"),
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from tkinter import messagebox
|
||||
|
||||
from PIL import Image
|
||||
import contextlib
|
||||
|
||||
|
||||
# Harness 의존 (선택적 — 없어도 동작)
|
||||
@@ -62,7 +63,7 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
app.after(0, lambda: app.log(
|
||||
f" Harness: job#{job_id}, {SeedManager.describe(seed)}, prompt={prompt_ver}"))
|
||||
except Exception as e:
|
||||
app.after(0, lambda: app.log(f" Harness 초기화 경고: {e}"))
|
||||
app.after(0, lambda e=e: app.log(f" Harness 초기화 경고: {e}"))
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
@@ -176,12 +177,12 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
break
|
||||
if rendered:
|
||||
_m, _l = model_name, current_loc
|
||||
app.after(0, lambda: app.log(
|
||||
app.after(0, lambda _m=_m, _l=_l: app.log(
|
||||
f" [Vertex] 모델 {_m} @ {_l} 성공"))
|
||||
break
|
||||
except Exception as exc:
|
||||
_m, _e = model_name, str(exc)[:120]
|
||||
app.after(0, lambda: app.log(f" [Vertex] {_m}: {_e}"))
|
||||
app.after(0, lambda _m=_m, _e=_e: app.log(f" [Vertex] {_m}: {_e}"))
|
||||
|
||||
elif sdk_version == "new":
|
||||
client = genai.Client(api_key=credential)
|
||||
@@ -205,11 +206,11 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
break
|
||||
if rendered:
|
||||
_m = model_name
|
||||
app.after(0, lambda: app.log(f" 모델 {_m} 성공"))
|
||||
app.after(0, lambda _m=_m: app.log(f" 모델 {_m} 성공"))
|
||||
break
|
||||
except Exception as exc:
|
||||
_m, _e = model_name, str(exc)[:80]
|
||||
app.after(0, lambda: app.log(f" {_m}: {_e}"))
|
||||
app.after(0, lambda _m=_m, _e=_e: app.log(f" {_m}: {_e}"))
|
||||
|
||||
else:
|
||||
genai_legacy.configure(api_key=credential)
|
||||
@@ -223,17 +224,17 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
)
|
||||
if hasattr(response, 'candidates') and response.candidates:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if hasattr(part, 'inline_data') and part.inline_data:
|
||||
if part.inline_data.mime_type.startswith("image/"):
|
||||
rendered = Image.open(io.BytesIO(part.inline_data.data))
|
||||
break
|
||||
if (hasattr(part, 'inline_data') and part.inline_data
|
||||
and part.inline_data.mime_type.startswith("image/")):
|
||||
rendered = Image.open(io.BytesIO(part.inline_data.data))
|
||||
break
|
||||
if rendered:
|
||||
_m = model_name
|
||||
app.after(0, lambda: app.log(f" 모델 {_m} 성공"))
|
||||
app.after(0, lambda _m=_m: app.log(f" 모델 {_m} 성공"))
|
||||
break
|
||||
except Exception as exc:
|
||||
_m, _e = model_name, str(exc)[:80]
|
||||
app.after(0, lambda: app.log(f" {_m}: {_e}"))
|
||||
app.after(0, lambda _m=_m, _e=_e: app.log(f" {_m}: {_e}"))
|
||||
|
||||
if not rendered:
|
||||
if app.job_logger and db and job_id:
|
||||
@@ -242,6 +243,14 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
app.after(0, lambda: app.set_status("이미지 생성 실패", "#E74C3C"))
|
||||
return
|
||||
|
||||
# 출력 화질 후처리 — Step 4에서 고른 HD/FHD/UHD 로 리사이즈
|
||||
tgt = getattr(app, "target_resolution", None)
|
||||
if tgt and tgt[0] > 0 and tgt[1] > 0 and rendered.size != tuple(tgt):
|
||||
src_size = rendered.size
|
||||
app.after(0, lambda s=src_size, t=tgt: app.log(
|
||||
f" 화질 리사이즈: {s[0]}x{s[1]} → {t[0]}x{t[1]}"))
|
||||
rendered = rendered.resize(tuple(tgt), Image.LANCZOS)
|
||||
|
||||
output_path = "rendered_birdseye.png"
|
||||
rendered.save(output_path)
|
||||
latency_ms = (_time.time() - t_start) * 1000
|
||||
@@ -253,13 +262,11 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
quality_score = vr.score
|
||||
app.after(0, lambda: app.log(f" 품질검증: {vr.summary}"))
|
||||
except Exception as e:
|
||||
app.after(0, lambda: app.log(f" 품질검증 오류: {e}"))
|
||||
app.after(0, lambda e=e: app.log(f" 품질검증 오류: {e}"))
|
||||
|
||||
if app.job_logger and db and job_id:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.complete_job(db, job_id, output_path, quality_score, latency_ms)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app.after(0, lambda: app.log(
|
||||
f" Gemini 렌더링 완료! → {output_path} ({rendered.size}) "
|
||||
@@ -269,17 +276,13 @@ def run_gemini_render(app, credential: str, prompt: str,
|
||||
|
||||
except Exception as e:
|
||||
if app.job_logger and db and job_id:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
app.job_logger.fail_job(db, job_id, str(e))
|
||||
except Exception:
|
||||
pass
|
||||
err_msg = str(e)[:300]
|
||||
app.after(0, lambda: app.log(f" Gemini 오류: {err_msg}"))
|
||||
app.after(0, lambda: app.set_status("렌더링 실패", "#E74C3C"))
|
||||
app.after(0, lambda: messagebox.showerror("Gemini 오류", f"API 호출 오류:\n{err_msg}"))
|
||||
finally:
|
||||
if db:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Geo-Referencing: 구조물 평면도 ↔ TIN 평면도 4점 매칭 및 변환 계산.
|
||||
|
||||
사용자가 구조물 상세 도면(평면도)과 TIN 생성용 도면 각각에서
|
||||
@@ -36,6 +35,7 @@ from matplotlib.backends.backend_tkagg import (
|
||||
FigureCanvasTkAgg,
|
||||
NavigationToolbar2Tk,
|
||||
)
|
||||
import contextlib
|
||||
|
||||
try:
|
||||
matplotlib.rcParams["font.family"] = "Malgun Gothic"
|
||||
@@ -182,7 +182,7 @@ class PlacementTransform:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "PlacementTransform":
|
||||
def from_dict(cls, d: dict) -> PlacementTransform:
|
||||
if not isinstance(d, dict):
|
||||
return cls()
|
||||
known = {k: d.get(k) for k in (
|
||||
@@ -365,7 +365,7 @@ def extract_plan_shapes(dxf_paths: list, prefer_view_type: str = "plan"):
|
||||
# 블록 explode된 전체 shapes
|
||||
try:
|
||||
geom = extract_structural_geometry(path, explode_blocks=True)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if plan_view is not None:
|
||||
@@ -632,11 +632,9 @@ class DxfPickerCanvas(ctk.CTkFrame):
|
||||
|
||||
def _redraw_picks(self):
|
||||
for a in self._pick_artists:
|
||||
try: a.remove()
|
||||
except Exception: pass
|
||||
with contextlib.suppress(Exception): a.remove()
|
||||
for a in self._pick_label_artists:
|
||||
try: a.remove()
|
||||
except Exception: pass
|
||||
with contextlib.suppress(Exception): a.remove()
|
||||
self._pick_artists = []
|
||||
self._pick_label_artists = []
|
||||
|
||||
@@ -689,10 +687,8 @@ class DxfPickerCanvas(ctk.CTkFrame):
|
||||
self.status_var.set(f"{len(self.picks)}/{self.MAX_PICKS} 점 선택됨{snap_tag}")
|
||||
|
||||
if self.on_picks_changed is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.on_picks_changed(list(self.picks))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_scroll(self, event):
|
||||
"""커서 중심으로 줌 인/아웃."""
|
||||
@@ -750,10 +746,8 @@ class DxfPickerCanvas(ctk.CTkFrame):
|
||||
|
||||
def _clear_snap_hint(self, draw: bool = True):
|
||||
if self._snap_hint is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self._snap_hint.remove()
|
||||
except Exception:
|
||||
pass
|
||||
self._snap_hint = None
|
||||
self._snap_at = None
|
||||
if draw:
|
||||
@@ -765,10 +759,8 @@ class DxfPickerCanvas(ctk.CTkFrame):
|
||||
self.canvas.draw_idle()
|
||||
self.status_var.set(f"0/{self.MAX_PICKS} 점 선택됨")
|
||||
if self.on_picks_changed is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.on_picks_changed([])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_picks(self, pts: list):
|
||||
self.picks = [(float(p[0]), float(p[1])) for p in pts][: self.MAX_PICKS]
|
||||
@@ -776,24 +768,18 @@ class DxfPickerCanvas(ctk.CTkFrame):
|
||||
self.canvas.draw_idle()
|
||||
self.status_var.set(f"{len(self.picks)}/{self.MAX_PICKS} 점 선택됨")
|
||||
if self.on_picks_changed is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.on_picks_changed(list(self.picks))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_picks(self) -> list:
|
||||
return list(self.picks)
|
||||
|
||||
def cleanup(self):
|
||||
"""다이얼로그 close 시 matplotlib/Tk 리소스 해제."""
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.canvas.get_tk_widget().destroy()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
plt.close(self.fig)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -809,7 +795,7 @@ class GeoReferencingDialog(ctk.CTkToplevel):
|
||||
|
||||
def __init__(self, parent, structure_detail_dxf_paths: list,
|
||||
tin_dxf_path: str, layer_name: str = "",
|
||||
initial_transform: "PlacementTransform | None" = None,
|
||||
initial_transform: PlacementTransform | None = None,
|
||||
on_confirm=None,
|
||||
tin_origin=None, tin_view_bounds=None):
|
||||
super().__init__(parent)
|
||||
@@ -1042,18 +1028,12 @@ class GeoReferencingDialog(ctk.CTkToplevel):
|
||||
self._cleanup_and_destroy()
|
||||
|
||||
def _cleanup_and_destroy(self):
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.left_canvas.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.right_canvas.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
136
harness/crash_logger.py
Normal 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]
|
||||
219
harness/inline_panel.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""S-CANVAS InlinePanel — `ctk.CTkToplevel` 호환 인라인 오버레이 (#4).
|
||||
|
||||
피드백 #4: "프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서
|
||||
바로 구동되게끔 적용".
|
||||
|
||||
본 모듈은 `ctk.CTkToplevel(parent)` 자리에 그대로 들어가는 drop-in 대체. main 앱
|
||||
창의 `main_frame` 안에 floating overlay 로 렌더되어 별도 OS 창을 만들지 않는다.
|
||||
|
||||
호환 API (Toplevel subset):
|
||||
- title(str)
|
||||
- geometry("WxH" | "WxH+X+Y") — X,Y 는 무시 (centering)
|
||||
- transient(parent) — no-op
|
||||
- grab_set() / grab_release() — lift + focus 시뮬레이트
|
||||
- protocol("WM_DELETE_WINDOW", fn)
|
||||
- wait_window() — tk.Misc.wait_window 그대로 동작 (Frame 도 OK)
|
||||
- destroy()
|
||||
- iconbitmap / iconphoto / wm_* — no-op (Toplevel 전용)
|
||||
|
||||
사용:
|
||||
from harness.inline_panel import InlinePanel
|
||||
win = InlinePanel(self) # ctk.CTkToplevel(self) 와 동일하게
|
||||
win.title("DXF 레이어 분류")
|
||||
win.geometry("900x650")
|
||||
win.grab_set()
|
||||
# ... 자식 위젯 .pack() / .grid()
|
||||
btn_close = ctk.CTkButton(win, text="닫기", command=win.destroy)
|
||||
btn_close.pack()
|
||||
win.wait_window() # 패널 destroy 까지 블록
|
||||
|
||||
기존 Toplevel 와 거의 동일한 코드 변경 = 1줄 (`CTkToplevel` → `InlinePanel`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
|
||||
# Mastercard 팔레트 (scanvas_maker.py 와 일관) — 타이틀 바 색상.
|
||||
_MC_RED = "#EB001B"
|
||||
_MC_RED_DARK = "#A30013"
|
||||
_MC_WHITE = "#FFFFFF"
|
||||
|
||||
|
||||
class InlinePanel(ctk.CTkFrame):
|
||||
"""`ctk.CTkToplevel` 인터페이스를 구현한 inline overlay frame.
|
||||
|
||||
상위 SCanvasApp 인스턴스의 `main_frame` 안에 `place(relx=0.5, rely=0.5)` 로
|
||||
중앙 배치. 같은 시점에 여러 패널이 열리면 가장 마지막 것이 위 (lift).
|
||||
|
||||
구현 노트:
|
||||
- `tk.Misc.wait_window(self)` 는 widget destruction 을 기다리는 메커니즘
|
||||
이라 Toplevel 외 Frame 에서도 동작. CTkFrame → tk.Frame → tk.Widget.
|
||||
- `grab_set()` 은 OS-level focus 잡는 게 본래 의미인데, 인라인 오버레이는
|
||||
하나의 창 안이라 무의미 → lift + focus 로 시뮬레이트.
|
||||
- geometry 의 X+Y 좌표는 무시 (panel 은 항상 main_frame 중앙).
|
||||
"""
|
||||
|
||||
_z_counter = 0 # 다중 패널 z-order 카운터
|
||||
|
||||
def __init__(self, parent: ctk.CTkBaseClass, **kwargs):
|
||||
# 상위 SCanvasApp 찾기 — winfo_toplevel() 이 root window 반환.
|
||||
# parent 가 SCanvasApp 또는 다른 InlinePanel 둘 다 OK.
|
||||
# 안전 폴백: main_frame 없는 root 면 parent 자체에 그림.
|
||||
app = parent.winfo_toplevel()
|
||||
host = app.main_frame if hasattr(app, "main_frame") else parent
|
||||
|
||||
super().__init__(
|
||||
host,
|
||||
fg_color=("#FFFFFF", "#1A1A1A"),
|
||||
border_width=2,
|
||||
border_color=("#E0E0E0", "#333333"),
|
||||
corner_radius=10,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._app = app
|
||||
self._host = host
|
||||
self._title_text: str = ""
|
||||
self._title_bar: ctk.CTkFrame | None = None
|
||||
self._title_label: ctk.CTkLabel | None = None
|
||||
self._content_frame: ctk.CTkFrame | None = None
|
||||
self._on_close: Callable[[], None] | None = None
|
||||
self._closed = False
|
||||
self._geom_w = 640
|
||||
self._geom_h = 480
|
||||
|
||||
InlinePanel._z_counter += 1
|
||||
self._z = InlinePanel._z_counter
|
||||
|
||||
self._build_chrome()
|
||||
self._reposition()
|
||||
|
||||
# --- chrome (title bar + content area) -----------------------------------
|
||||
|
||||
def _build_chrome(self) -> None:
|
||||
"""타이틀 바 (MC red) + 컨텐츠 프레임. 자식 위젯은 컨텐츠 프레임에 들어감."""
|
||||
# title_bar: MC red 배경, 흰 텍스트, ✕ 버튼
|
||||
self._title_bar = ctk.CTkFrame(
|
||||
self, height=34, fg_color=(_MC_RED, _MC_RED_DARK), corner_radius=8,
|
||||
)
|
||||
self._title_bar.pack(side="top", fill="x", padx=4, pady=(4, 0))
|
||||
self._title_bar.pack_propagate(False)
|
||||
|
||||
self._title_label = ctk.CTkLabel(
|
||||
self._title_bar, text="", text_color=_MC_WHITE,
|
||||
font=ctk.CTkFont(size=13, weight="bold"),
|
||||
)
|
||||
self._title_label.pack(side="left", padx=12, pady=4)
|
||||
|
||||
close_btn = ctk.CTkButton(
|
||||
self._title_bar, text="✕", width=28, height=24,
|
||||
fg_color="transparent", hover_color=_MC_RED_DARK,
|
||||
text_color=_MC_WHITE, corner_radius=4,
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
command=self._user_close,
|
||||
)
|
||||
close_btn.pack(side="right", padx=4, pady=4)
|
||||
|
||||
# content frame: 자식 위젯 컨테이너. 사용자가 .pack/.grid(self)로 추가하면
|
||||
# 자동으로 여기 자식이 됨 (CTkFrame 의 _children 위임). 직접 호출은 따로.
|
||||
# 다만 기존 코드는 `widget(win, ...)` 처럼 win 자체를 parent 로 쓰니 그게
|
||||
# InlinePanel 의 직접 자식이 됨 — 그래서 content_frame 은 reserved 안 만들고
|
||||
# title_bar 만 packed 후 자식들이 그 아래로 채워짐 (pack 자동 layout).
|
||||
# title bar 와 content 사이 분리선
|
||||
sep = ctk.CTkFrame(self, height=1, fg_color=("#E0E0E0", "#333333"))
|
||||
sep.pack(side="top", fill="x", padx=8, pady=(0, 4))
|
||||
|
||||
def _reposition(self) -> None:
|
||||
"""geometry str 파싱 + 중앙 배치 (main_frame 의 중앙)."""
|
||||
try:
|
||||
mw = self._host.winfo_width() or 1200
|
||||
mh = self._host.winfo_height() or 800
|
||||
# main_frame 의 95% 안으로 cap
|
||||
w = min(self._geom_w, int(mw * 0.95))
|
||||
h = min(self._geom_h, int(mh * 0.95))
|
||||
self.place(relx=0.5, rely=0.5, anchor="center", width=w, height=h)
|
||||
self.lift()
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
self.place(relx=0.5, rely=0.5, anchor="center")
|
||||
self.lift()
|
||||
|
||||
# --- ctk.CTkToplevel 호환 메서드 -----------------------------------------
|
||||
|
||||
def title(self, t: str) -> None:
|
||||
self._title_text = t
|
||||
if self._title_label is not None:
|
||||
self._title_label.configure(text=t)
|
||||
|
||||
def geometry(self, geom_str: str) -> None:
|
||||
"""\"WxH\" 또는 \"WxH+X+Y\" 형식 파싱. X,Y 는 무시 (always center)."""
|
||||
try:
|
||||
size_part = geom_str.split("+", 1)[0]
|
||||
w_str, h_str = size_part.split("x")
|
||||
self._geom_w = int(w_str)
|
||||
self._geom_h = int(h_str)
|
||||
self._reposition()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
def transient(self, master) -> None:
|
||||
"""no-op (이미 main_frame 안)."""
|
||||
|
||||
def grab_set(self) -> None:
|
||||
"""모달 시뮬레이트 — lift + focus."""
|
||||
with contextlib.suppress(Exception):
|
||||
self.lift()
|
||||
self.focus_set()
|
||||
|
||||
def grab_release(self) -> None:
|
||||
"""no-op."""
|
||||
|
||||
def protocol(self, name: str, handler: Callable[[], None]) -> None:
|
||||
if name == "WM_DELETE_WINDOW":
|
||||
self._on_close = handler
|
||||
|
||||
def iconbitmap(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op (인라인 패널은 OS 창이 아님)."""
|
||||
|
||||
def iconphoto(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op."""
|
||||
|
||||
def wm_iconbitmap(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op."""
|
||||
|
||||
def wm_iconphoto(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op."""
|
||||
|
||||
def attributes(self, *args, **kwargs):
|
||||
"""\"-topmost\", \"-alpha\" 등 — no-op (인라인 패널은 z-order 관리만)."""
|
||||
return None
|
||||
|
||||
def overrideredirect(self, *args, **kwargs) -> None:
|
||||
"""no-op."""
|
||||
|
||||
# --- 종료 처리 -----------------------------------------------------------
|
||||
|
||||
def _user_close(self) -> None:
|
||||
"""타이틀 바 ✕ 또는 protocol(WM_DELETE_WINDOW) 호출 시 진입점."""
|
||||
if self._on_close is not None:
|
||||
try:
|
||||
self._on_close()
|
||||
except Exception:
|
||||
self.destroy()
|
||||
else:
|
||||
self.destroy()
|
||||
|
||||
def destroy(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
with contextlib.suppress(Exception):
|
||||
self.place_forget()
|
||||
super().destroy()
|
||||
|
||||
# wait_window() 는 별도 정의 안 함 — tk.Misc.wait_window(self) 가 widget
|
||||
# destruction 을 기다리는데, CTkFrame 이 tk.Widget 상속이라 Frame 에서도 동작.
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Column, DateTime, Float, Integer, String, Text, create_engine
|
||||
@@ -44,7 +43,7 @@ _SessionFactory = None
|
||||
|
||||
|
||||
def init_db(db_path: str | Path = "cad_aerial_gen.db"):
|
||||
global _engine, _SessionFactory
|
||||
global _engine, _SessionFactory # noqa: PLW0603 (module-level singleton init)
|
||||
_engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
Base.metadata.create_all(_engine)
|
||||
_SessionFactory = sessionmaker(bind=_engine)
|
||||
@@ -58,7 +57,7 @@ def get_db_session() -> Session:
|
||||
|
||||
# ──────────────────────────── structlog 설정 ────────────────────────────
|
||||
|
||||
def setup_logging(log_file: Optional[Path] = None, level: str = "INFO"):
|
||||
def setup_logging(log_file: Path | None = None, level: str = "INFO"):
|
||||
"""콘솔 + 파일 동시 로깅을 설정한다."""
|
||||
handlers = [logging.StreamHandler(sys.stdout)]
|
||||
if log_file:
|
||||
|
||||
64
harness/perf.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""S-CANVAS perf instrumentation — ms 단위 wall/CPU 시간 측정.
|
||||
|
||||
피드백 #11: "로딩이 오래 걸리는 부분(위성지도 결합·구조물 빌드 시 등)은
|
||||
CPU 이용률이 대폭 증가하는 프로세스를 ms 단위로 추적해서 원인을 규명하고
|
||||
최적화하는 조치 필요"
|
||||
|
||||
사용:
|
||||
from harness.perf import perf_block, set_perf_log
|
||||
|
||||
set_perf_log(app.log) # GUI 로그에 함께 기록 (옵션)
|
||||
|
||||
with perf_block("XYZ tiles 5x5"):
|
||||
download_tiles(...)
|
||||
|
||||
출력:
|
||||
[PERF] XYZ tiles 5x5: wall=2540.3ms cpu=120.1ms (I/O/Net-bound)
|
||||
|
||||
판별: cpu/wall > 0.5 → CPU-bound, 그 외 → I/O/Net-bound (GIL 풀린 시간 비율).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
|
||||
_log_callable: Callable[[str], None] | None = None
|
||||
_logger = logging.getLogger("scanvas.perf")
|
||||
|
||||
|
||||
def set_perf_log(fn: Callable[[str], None] | None) -> None:
|
||||
"""app.log 등 외부 sink로 perf 라인 라우팅. None이면 logger 만."""
|
||||
global _log_callable # noqa: PLW0603 (module-level singleton)
|
||||
_log_callable = fn
|
||||
|
||||
|
||||
def _emit(line: str) -> None:
|
||||
_logger.info(line)
|
||||
if _log_callable is not None:
|
||||
# 로그 sink 실패가 측정 흐름을 끊으면 안 됨 — 폭넓게 suppress.
|
||||
with contextlib.suppress(Exception):
|
||||
_log_callable(line)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def perf_block(label: str):
|
||||
"""블록 단위 wall-clock + CPU 시간을 한 줄로 출력.
|
||||
|
||||
Args:
|
||||
label: 출력 prefix (예: "TIN densify Phase C", "capture x3").
|
||||
|
||||
측정 단위는 ms. CPU-bound vs I/O/Net-bound를 cpu/wall 비율로 거칠게 분류.
|
||||
"""
|
||||
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
|
||||
ratio = dt_cpu / dt_wall if dt_wall > 1e-3 else 0.0
|
||||
kind = "CPU" if ratio > 0.5 else "I/O/Net"
|
||||
_emit(f"[PERF] {label}: wall={dt_wall:.1f}ms cpu={dt_cpu:.1f}ms ({kind}-bound)")
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -14,23 +13,23 @@ class PromptRegistry:
|
||||
def __init__(self, templates_dir: Path):
|
||||
self.templates_dir = templates_dir
|
||||
|
||||
def list_versions(self) -> List[str]:
|
||||
def list_versions(self) -> list[str]:
|
||||
"""사용 가능한 템플릿 버전 목록을 반환한다 (최신순)."""
|
||||
yamls = sorted(self.templates_dir.glob("prompt_v*.yaml"), reverse=True)
|
||||
return [p.stem for p in yamls]
|
||||
|
||||
def latest_version(self) -> Optional[str]:
|
||||
def latest_version(self) -> str | None:
|
||||
versions = self.list_versions()
|
||||
return versions[0] if versions else None
|
||||
|
||||
def load_template(self, version: str) -> Dict:
|
||||
def load_template(self, version: str) -> dict:
|
||||
path = self.templates_dir / f"{version}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"템플릿 버전 {version}이 없습니다.")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def compare(self, version_a: str, version_b: str) -> Dict:
|
||||
def compare(self, version_a: str, version_b: str) -> dict:
|
||||
"""두 버전의 차이점을 반환한다."""
|
||||
a = self.load_template(version_a)
|
||||
b = self.load_template(version_b)
|
||||
@@ -42,7 +41,7 @@ class PromptRegistry:
|
||||
diff[key] = {"old": va, "new": vb}
|
||||
return diff
|
||||
|
||||
def save_new_version(self, new_version: str, template: Dict) -> Path:
|
||||
def save_new_version(self, new_version: str, template: dict) -> Path:
|
||||
"""새 버전 템플릿을 저장한다."""
|
||||
path = self.templates_dir / f"{new_version}.yaml"
|
||||
if path.exists():
|
||||
@@ -51,8 +50,8 @@ class PromptRegistry:
|
||||
yaml.dump(template, f, allow_unicode=True, default_flow_style=False)
|
||||
return path
|
||||
|
||||
def get_version_for_hash(self, prompt_hash: str, db_session) -> Optional[str]:
|
||||
def get_version_for_hash(self, prompt_hash: str, db_session) -> str | None:
|
||||
"""프롬프트 해시로 사용된 버전을 역조회한다."""
|
||||
from harness.logger import JobRecord
|
||||
record = db_session.query(JobRecord).filter_by(prompt_hash=prompt_hash).first()
|
||||
return record.prompt_version if record else None
|
||||
return record.prompt_version if record else None
|
||||
|
||||
@@ -4,15 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
raise ImportError("opencv-python, Pillow이 필요합니다: pip install opencv-python Pillow")
|
||||
except ImportError as e:
|
||||
raise ImportError("opencv-python, Pillow이 필요합니다: pip install opencv-python Pillow") from e
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -22,7 +21,7 @@ class ValidationResult:
|
||||
resolution_ok: bool
|
||||
sharpness_ok: bool
|
||||
color_diversity_ok: bool
|
||||
messages: List[str]
|
||||
messages: list[str]
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
|
||||
@@ -4,11 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from harness.logger import JobRecord, get_db_session
|
||||
from harness.logger import JobRecord
|
||||
|
||||
|
||||
class SeedManager:
|
||||
@@ -19,7 +18,7 @@ class SeedManager:
|
||||
def get_seed(
|
||||
self,
|
||||
file_hash: str,
|
||||
fixed_seed: Optional[int] = None,
|
||||
fixed_seed: int | None = None,
|
||||
deterministic: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
@@ -41,7 +40,7 @@ class SeedManager:
|
||||
db: Session,
|
||||
job_id: int,
|
||||
file_hash: str,
|
||||
fixed_seed: Optional[int] = None,
|
||||
fixed_seed: int | None = None,
|
||||
deterministic: bool = True,
|
||||
) -> int:
|
||||
"""DB에서 기존 seed를 조회하거나 새로 생성한다."""
|
||||
|
||||
@@ -18,13 +18,11 @@ IntakeTowerParams → PyVista 메쉬 리스트 (구성요소별)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
|
||||
from intake_tower_parser import IntakeTowerParams, GatePosition
|
||||
from intake_tower_parser import IntakeTowerParams
|
||||
|
||||
|
||||
# 색상 팔레트
|
||||
@@ -154,7 +152,6 @@ class IntakeTowerBuilder:
|
||||
주의: 진짜 boolean cut 대신 어두운 사각형을 벽 앞면에 배치하여 시각적 표현.
|
||||
"""
|
||||
p = self.p
|
||||
hw = p.body_width / 2
|
||||
hd = p.body_depth / 2
|
||||
|
||||
for g in p.gates:
|
||||
@@ -280,7 +277,6 @@ class IntakeTowerBuilder:
|
||||
return
|
||||
|
||||
# 크레인 주황색 박스 (레일 중간에 매달린 형태)
|
||||
hw = p.body_width / 2
|
||||
crane_x = 0.0 # 중앙
|
||||
crane_w = 1.5
|
||||
crane_d = 2.5 # Y 방향
|
||||
@@ -337,7 +333,6 @@ class IntakeTowerBuilder:
|
||||
return
|
||||
|
||||
hw = p.body_width / 2
|
||||
hd = p.body_depth / 2
|
||||
|
||||
# 지상(body_bottom_el 주변)에서 body_top_el까지 계단
|
||||
# 좌측에서 진입 기본
|
||||
@@ -350,14 +345,11 @@ class IntakeTowerBuilder:
|
||||
n_steps = max(int(total_rise / 0.17), 10)
|
||||
step_h = total_rise / n_steps
|
||||
step_d = 0.28 # 디딤판 깊이
|
||||
total_run = n_steps * step_d
|
||||
|
||||
# 계단 위치: 본체 좌측 외부
|
||||
x_start = -hw - 1.0
|
||||
x_end = x_start - total_run # 서쪽 방향
|
||||
if p.stairs_side == "right":
|
||||
x_start = hw + 1.0
|
||||
x_end = x_start + total_run
|
||||
|
||||
# 계단을 한 덩어리 경사판으로 표현 (간략화)
|
||||
# 또는 각 단을 박스로
|
||||
@@ -446,7 +438,6 @@ def build_intake_tower_meshes(params: IntakeTowerParams):
|
||||
|
||||
if __name__ == "__main__":
|
||||
from intake_tower_parser import parse_intake_tower
|
||||
from pathlib import Path
|
||||
|
||||
paths = [
|
||||
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||||
|
||||
@@ -24,13 +24,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
|
||||
from view_detector import detect_view_regions, ViewRegion
|
||||
from dxf_geometry import extract_structural_geometry
|
||||
@@ -156,8 +152,8 @@ class IntakeTowerParser:
|
||||
|
||||
if el_texts:
|
||||
els = [v for (_, _, v) in el_texts]
|
||||
params.body_top_el = max(params.body_top_el, max(els))
|
||||
params.body_bottom_el = min(params.body_bottom_el, min(els))
|
||||
params.body_top_el = max(params.body_top_el, *els)
|
||||
params.body_bottom_el = min(params.body_bottom_el, *els)
|
||||
|
||||
# 2) 수문 개폐장치 원 검출 (정면도 내)
|
||||
front_view = self._find_view(views, "front")
|
||||
@@ -205,7 +201,7 @@ class IntakeTowerParser:
|
||||
continue
|
||||
return results
|
||||
|
||||
def _find_view(self, views: list[ViewRegion], view_type: str) -> Optional[ViewRegion]:
|
||||
def _find_view(self, views: list[ViewRegion], view_type: str) -> ViewRegion | None:
|
||||
for v in views:
|
||||
if v.view_type == view_type:
|
||||
return v
|
||||
@@ -257,12 +253,7 @@ class IntakeTowerParser:
|
||||
candidate_groups.sort(key=lambda g: (-g[0][2], -len(g)))
|
||||
main_group = candidate_groups[0]
|
||||
|
||||
# 수문 위치 확정: X 또는 Y 정렬 여부 확인
|
||||
xs = [c[0] for c in main_group]
|
||||
ys = [c[1] for c in main_group]
|
||||
x_var = max(xs) - min(xs)
|
||||
y_var = max(ys) - min(ys)
|
||||
|
||||
# 수문 위치 확정: X 또는 Y 정렬 여부 확인 (현재는 정면도 가정 — 좌우/상하 배치 분기는 미구현)
|
||||
# X 변화가 크면 → 수문이 좌우 배치 (평면도), Y 변화가 크면 → 상하 배치 (정면도, EL별)
|
||||
gates = []
|
||||
# 로컬 X 좌표 계산 (정면도 내에서 중심 기준)
|
||||
@@ -290,7 +281,7 @@ class IntakeTowerParser:
|
||||
|
||||
return gates
|
||||
|
||||
def _detect_hoist_rail(self, msp, scale: float, top_el: float) -> Optional[dict]:
|
||||
def _detect_hoist_rail(self, msp, scale: float, top_el: float) -> dict | None:
|
||||
"""상단 긴 수평선 검출 → 호이스트 레일."""
|
||||
best = None
|
||||
for e in msp.query("LINE"):
|
||||
@@ -331,9 +322,10 @@ class IntakeTowerParser:
|
||||
))
|
||||
|
||||
# 호이스트 레일 EL이 상단과 불일치하면 상단 - 2m로 조정
|
||||
if params.has_hoist:
|
||||
if params.hoist_rail_el > params.body_top_el or params.hoist_rail_el < params.body_bottom_el:
|
||||
params.hoist_rail_el = params.body_top_el - 2.0
|
||||
if (params.has_hoist
|
||||
and (params.hoist_rail_el > params.body_top_el
|
||||
or params.hoist_rail_el < params.body_bottom_el)):
|
||||
params.hoist_rail_el = params.body_top_el - 2.0
|
||||
|
||||
|
||||
# 편의 함수
|
||||
@@ -343,7 +335,6 @@ def parse_intake_tower(dxf_paths: list[str]) -> IntakeTowerParams:
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
paths = sys.argv[1:] if len(sys.argv) > 1 else [
|
||||
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||||
@@ -353,7 +344,7 @@ if __name__ == "__main__":
|
||||
params = parse_intake_tower(paths)
|
||||
print(params.summary())
|
||||
print()
|
||||
print(f"상세 수문 정보:")
|
||||
print("상세 수문 정보:")
|
||||
for g in params.gates:
|
||||
print(f" {g.label} @ X={g.center_x:+.1f}m, R={g.actuator_radius:.2f}m")
|
||||
print(f"\n바닥 EL 목록: {params.floor_elevations}")
|
||||
|
||||
@@ -31,7 +31,7 @@ DXF 레이어·엔티티·텍스트 신호로 검출하는 공통 헬퍼.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
# geometry로 간주할 엔티티 타입 (TEXT/MTEXT/DIMENSION 등 주석은 제외)
|
||||
@@ -136,19 +136,15 @@ def detect_component(msp, spec: ComponentSpec) -> ComponentReport:
|
||||
geom_count, matched = count_layer_geom(msp, spec.layer_tokens)
|
||||
text_count = count_text_hits(msp, spec.text_keywords)
|
||||
|
||||
if geom_count > 0:
|
||||
present = True
|
||||
elif spec.allow_text_only and text_count > 0:
|
||||
if geom_count > 0 or (spec.allow_text_only and text_count > 0):
|
||||
present = True
|
||||
elif spec.preserve_default_on_no_signal and geom_count == 0 and text_count == 0:
|
||||
present = spec.default
|
||||
else:
|
||||
# 신호 부재 → default를 False로 낮춤
|
||||
present = False if spec.default else False
|
||||
# 단, default=True 이지만 preserve_default_on_no_signal=False 인 경우,
|
||||
# text 약신호라도 있으면 True 유지 여지
|
||||
if spec.default and text_count > 0:
|
||||
present = True
|
||||
present = bool(spec.default and text_count > 0)
|
||||
|
||||
return ComponentReport(
|
||||
present=present,
|
||||
|
||||
119
params_to_json.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""파서 결과 → JSON 브리지 (S-CANVAS env 측 generic 헬퍼).
|
||||
|
||||
이 모듈은 bpy를 import하지 않으므로 기존 S-CANVAS conda 환경에서 그대로 사용 가능.
|
||||
파서가 생성한 dataclass 파라미터(IntakeTowerParams, GateParams, ...)를 JSON으로 직렬화.
|
||||
|
||||
사용:
|
||||
# 취수탑
|
||||
from intake_tower_parser import parse_intake_tower
|
||||
from params_to_json import dump_dataclass_to_json
|
||||
p = parse_intake_tower(dxf_paths)
|
||||
dump_dataclass_to_json(p, "intake_params.json")
|
||||
|
||||
# 여수로 수문
|
||||
from gate_parser import parse_gate_dxf
|
||||
from params_to_json import dump_dataclass_to_json
|
||||
p = parse_gate_dxf(plan_dxf, section_dxf)
|
||||
dump_dataclass_to_json(p, "gate_params.json")
|
||||
|
||||
이후 Blender 헤드리스로:
|
||||
blender --background --python <builder_bpy>.py -- ^
|
||||
--params <params>.json --blend out.blend --render out.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _to_serializable(obj):
|
||||
"""dataclass / list / tuple / dict / 기본형 → JSON 직렬화 가능 구조."""
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
return {k: _to_serializable(v) for k, v in asdict(obj).items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_to_serializable(x) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _to_serializable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
||||
return obj
|
||||
# numpy / 기타: 가능하면 float, 아니면 str로 폴백
|
||||
try:
|
||||
return float(obj)
|
||||
except (TypeError, ValueError):
|
||||
return str(obj)
|
||||
|
||||
|
||||
def dump_dataclass_to_json(params, path: str) -> str:
|
||||
"""dataclass 인스턴스 → JSON 파일.
|
||||
|
||||
구조 종류와 무관하게 동작 (IntakeTowerParams, GateParams, ...).
|
||||
Returns: 작성한 절대 경로 (str)
|
||||
"""
|
||||
payload = _to_serializable(params)
|
||||
out_path = Path(path).resolve()
|
||||
out_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(out_path)
|
||||
|
||||
|
||||
# 이전 버전 호환 alias (intake_tower_3d_builder_bpy 가이드와의 호환)
|
||||
dump_intake_tower_params = dump_dataclass_to_json
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: 구조물 종류 자동 감지 → JSON 변환
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cli_intake_tower(out_json: str, dxf_paths: list[str]) -> None:
|
||||
from intake_tower_parser import parse_intake_tower
|
||||
params = parse_intake_tower(dxf_paths)
|
||||
print(params.summary())
|
||||
final = dump_dataclass_to_json(params, out_json)
|
||||
print(f"\nJSON written: {final}")
|
||||
|
||||
|
||||
def _cli_gate(out_json: str, plan_dxf: str, section_dxf: str | None = None) -> None:
|
||||
from gate_parser import parse_gate_dxf
|
||||
params = parse_gate_dxf(plan_dxf, section_dxf)
|
||||
print(params.summary())
|
||||
final = dump_dataclass_to_json(params, out_json)
|
||||
print(f"\nJSON written: {final}")
|
||||
|
||||
|
||||
def _print_usage_and_exit():
|
||||
print("Usage:")
|
||||
print(" python params_to_json.py intake <out.json> <dxf_1> [dxf_2 ...]")
|
||||
print(" python params_to_json.py gate <out.json> <plan.dxf> [section.dxf]")
|
||||
print("")
|
||||
print("Examples:")
|
||||
print(" python params_to_json.py intake intake.json \\")
|
||||
print(" SAMPLE_CAD/취수탑1.dxf SAMPLE_CAD/취수탑2.dxf")
|
||||
print("")
|
||||
print(" python params_to_json.py gate gate.json \\")
|
||||
print(" Gate_Sample/수문_1.dxf Gate_Sample/수문_2.dxf")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
_print_usage_and_exit()
|
||||
|
||||
kind = sys.argv[1].lower()
|
||||
out_json = sys.argv[2]
|
||||
rest = sys.argv[3:]
|
||||
|
||||
if kind in ("intake", "intake_tower", "tower"):
|
||||
_cli_intake_tower(out_json, rest)
|
||||
elif kind in ("gate", "spillway", "weir"):
|
||||
plan = rest[0]
|
||||
section = rest[1] if len(rest) > 1 else None
|
||||
_cli_gate(out_json, plan, section)
|
||||
else:
|
||||
print(f"Unknown structure kind: {kind!r}")
|
||||
_print_usage_and_exit()
|
||||
@@ -23,7 +23,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Iterable
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
Point = tuple[float, float]
|
||||
@@ -31,7 +31,7 @@ Segment = tuple[Point, Point]
|
||||
|
||||
|
||||
def _grid_key(p: Point, grid: float) -> tuple[int, int]:
|
||||
return (int(math.floor(p[0] / grid)), int(math.floor(p[1] / grid)))
|
||||
return (math.floor(p[0] / grid), math.floor(p[1] / grid))
|
||||
|
||||
|
||||
class _VertexStore:
|
||||
@@ -205,10 +205,8 @@ def segments_from_lines(lines: Iterable[tuple[Point, Point]]) -> list[Segment]:
|
||||
|
||||
def segments_from_polyline(pts: list[Point]) -> list[Segment]:
|
||||
"""LWPOLYLINE 점 목록 → 인접 segment 쌍."""
|
||||
segs = []
|
||||
for i in range(len(pts) - 1):
|
||||
segs.append((pts[i], pts[i + 1]))
|
||||
return segs
|
||||
from itertools import pairwise
|
||||
return list(pairwise(pts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
143
pyproject.toml
Normal file
@@ -0,0 +1,143 @@
|
||||
# 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"
|
||||
# pyproj>=3.7 (py313 extras) 이 Py3.11+ 만 지원. CI matrix Py3.11/3.13 와 일치.
|
||||
# Py3.9/3.10 legacy 지원이 필요하면 pyproj 범위 좁혀야 함.
|
||||
requires-python = ">=3.11"
|
||||
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,<4", # 3.6.1 (build pin) ~ 3.7+ (py313 extras) 동시 수용. lock 파일이 정확 핀.
|
||||
"rasterio==1.4.3",
|
||||
|
||||
# --- Numerical ---
|
||||
"numpy>=2.0.2,<3", # py313 extras 와 충돌 방지 위해 범위 핀.
|
||||
"scipy>=1.13,<2", # 1.13.x (Py3.9~3.12) ~ 1.14+ (Py3.13) 둘 다 lock 가능.
|
||||
"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 자동 다운로드 가능)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 빌드 시스템 (편집 가능 설치 / 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 등) 호출",
|
||||
]
|
||||
48
requirements-py313.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
# S-CANVAS — runtime dependencies
|
||||
# Pinned to versions verified working on the build machine (2026-04).
|
||||
#
|
||||
# Install:
|
||||
# pip install -r requirements.txt
|
||||
#
|
||||
# Python: 3.9+ recommended (tested on 3.9.13). 3.11+ ideal for performance.
|
||||
# Platform: Windows 10/11. Linux/macOS likely works for Python deps but the GUI
|
||||
# (CustomTkinter, tkintermapview) and PyInstaller build are Windows-tuned.
|
||||
|
||||
# --- GUI ---
|
||||
customtkinter==5.2.2
|
||||
tkintermapview==1.29
|
||||
Pillow==11.3.0
|
||||
|
||||
# --- 3D / mesh ---
|
||||
pyvista==0.46.5
|
||||
# vtk is pulled in automatically by pyvista (~150MB)
|
||||
|
||||
# --- Geospatial / DXF ---
|
||||
ezdxf==1.4.2
|
||||
pyproj>=3.7,<4
|
||||
rasterio==1.4.3 # optional — for cache/dem/local.tif (NGII GeoTIFF). 미설치 시 AWS Terrarium 만 사용.
|
||||
|
||||
# --- Numerical ---
|
||||
numpy>=2.0.2
|
||||
scipy>=1.14
|
||||
matplotlib==3.9.4 # signed-distance polygon paths
|
||||
|
||||
# --- Image / video ---
|
||||
opencv-python==4.13.0.92 # splash MP4 + harness QualityValidator
|
||||
|
||||
# --- Network ---
|
||||
requests==2.32.5
|
||||
|
||||
# --- AI rendering (Gemini) ---
|
||||
google-genai==1.47.0
|
||||
# google-auth is pulled in by google-genai; pinned for reproducibility
|
||||
google-auth==2.49.2
|
||||
|
||||
# --- Persistence / logging ---
|
||||
SQLAlchemy==2.0.49
|
||||
structlog==25.5.0
|
||||
PyYAML==6.0.3
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# (선택) 배포용 .exe 빌드를 새 PC 에서 만들고 싶을 때만:
|
||||
# pip install pyinstaller==6.18.0
|
||||
@@ -14,7 +14,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
@@ -85,7 +84,6 @@ class RetainingWallBuilder:
|
||||
z_bot = p.bottom_el + p.base_slab_thickness
|
||||
z_top = p.top_el
|
||||
|
||||
H = z_top - z_bot
|
||||
W_bot = p.avg_bottom_width
|
||||
W_top = p.avg_top_width
|
||||
|
||||
@@ -99,9 +97,7 @@ class RetainingWallBuilder:
|
||||
# 단면:
|
||||
# 전면(Y-): (-W_bot/2 + batter*H, z_bot) → (-W_top/2, z_top)
|
||||
# 배면(Y+): (W_bot/2, z_bot) → (W_bot/2, z_top) (수직)
|
||||
# batter: 전면이 안쪽으로 기움
|
||||
|
||||
batter_shift = p.front_batter_ratio * H # 전면이 상단에서 안쪽으로 이 만큼 이동
|
||||
# batter: 전면이 안쪽으로 기움 (현재는 W_top - W_bot 차이로 묵시적 처리)
|
||||
|
||||
# 8개 코너 점
|
||||
y_front_bot = -W_bot / 2
|
||||
@@ -139,7 +135,6 @@ class RetainingWallBuilder:
|
||||
p = self.p
|
||||
L = p.total_length
|
||||
z_top = p.top_el
|
||||
z_bot = p.bottom_el + p.base_slab_thickness
|
||||
|
||||
# 배면에서 뒤로 뻗은 토사 (30 길이)
|
||||
back_depth = 15.0
|
||||
@@ -177,7 +172,6 @@ class RetainingWallBuilder:
|
||||
return
|
||||
|
||||
L = p.total_length
|
||||
H = p.total_height()
|
||||
z_bot = p.bottom_el + p.base_slab_thickness
|
||||
z_top = p.top_el - 1.0 # 상단은 난간 영역 제외
|
||||
|
||||
@@ -257,7 +251,6 @@ class RetainingWallBuilder:
|
||||
|
||||
# 난간 (수평 바)
|
||||
rail_t = 0.08
|
||||
rail_spacing = 0.4
|
||||
for rz in [z0 + p.parapet_height * 0.7, z0 + p.parapet_height * 0.4]:
|
||||
self._add_box(-L/2, L/2, y_front, y_front + rail_t, rz, rz + rail_t, COLORS["rail"])
|
||||
|
||||
|
||||
@@ -18,13 +18,10 @@ from __future__ import annotations
|
||||
import re
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
|
||||
from view_detector import detect_view_regions, ViewRegion
|
||||
from view_detector import detect_view_regions
|
||||
from dxf_geometry import extract_structural_geometry
|
||||
from view_reconstructor import compute_oriented_bbox
|
||||
|
||||
@@ -156,8 +153,8 @@ class RetainingWallParser:
|
||||
|
||||
if els:
|
||||
ev = [v for _, _, v in els]
|
||||
params.top_el = max(params.top_el, max(ev))
|
||||
params.bottom_el = min(params.bottom_el, min(ev))
|
||||
params.top_el = max(params.top_el, *ev)
|
||||
params.bottom_el = min(params.bottom_el, *ev)
|
||||
params.raw_annotations.extend(
|
||||
[(f"EL.{v:.2f}", x, y) for x, y, v in els]
|
||||
)
|
||||
|
||||
58
ruff.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
# S-CANVAS ruff config — Python 3.13
|
||||
# 의도적 코드베이스 컨벤션 보호 + Korean text 오탐 무력화.
|
||||
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
|
||||
exclude = [
|
||||
"workspace", # agents-workspace clone (개발 메타툴)
|
||||
"jarvis", # jarvis 메모/세션 저장소
|
||||
"_unused", # 보존된 레거시 코드 (분석 대상 아님)
|
||||
"venv313",
|
||||
"venv",
|
||||
".git",
|
||||
"__pycache__",
|
||||
"*.bak",
|
||||
"*.bak_*",
|
||||
]
|
||||
|
||||
[lint]
|
||||
# 기본 select(E, F, W) 외에 modernization/bug 카테고리 추가.
|
||||
extend-select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # bugbear
|
||||
"SIM", # simplify
|
||||
"RUF", # ruff-specific
|
||||
"PERF", # performance
|
||||
"PLE", # pylint errors
|
||||
"PLW", # pylint warnings
|
||||
]
|
||||
|
||||
ignore = [
|
||||
# 라인 길이는 저자 재량 (mixed Korean/English 코드 + 긴 f-string 다수).
|
||||
"E501",
|
||||
|
||||
# 저자의 one-line 스타일 — 짧은 if/for/setattr 모음에서 의도적으로 사용.
|
||||
"E701", # multi-statement-on-one-line-colon
|
||||
"E702", # multi-statement-on-one-line-semicolon
|
||||
|
||||
# 변수명 l, I, O 등 — 수학/배열 컨벤션에서 자연스러움.
|
||||
"E741",
|
||||
|
||||
# regex 문자열의 \\d 등 — 일반적인 raw-ish 패턴.
|
||||
"W605",
|
||||
|
||||
# Korean 코드 주석/문자열은 × − ° 등 unicode 기호를 자연스럽게 사용 — 오탐.
|
||||
"RUF001",
|
||||
"RUF002",
|
||||
"RUF003",
|
||||
|
||||
# `for _ in range(N):` placeholder 패턴은 intentional.
|
||||
"B007",
|
||||
|
||||
# 함수 길이/branches 경고는 informational (S-CANVAS는 도메인 로직이 본래 큼).
|
||||
"PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR0916", "PLR0917",
|
||||
"PLR0904", # too-many-public-methods
|
||||
"PLR2004", # magic-value-comparison (수치 도메인 코드에서 빈번)
|
||||
"PLR1702", # too-many-nested-blocks
|
||||
]
|
||||
1598
scanvas_maker.py
187
splash.py
@@ -1,187 +0,0 @@
|
||||
"""S-CANVAS 인트로 로딩 스플래시.
|
||||
|
||||
Design/logo_intro.mp4 를 frameless Toplevel 중앙에 재생한 뒤 자동 종료.
|
||||
메인 앱(`scanvas_maker.SCanvasApp`) 기동 직전에 호출한다.
|
||||
|
||||
기술 스택:
|
||||
- cv2 (VideoCapture) : MP4 프레임 디코드 (harness/quality_validator 가
|
||||
이미 의존하는 프로젝트 공통 dep).
|
||||
- PIL + tkinter.PhotoImage : 프레임 렌더.
|
||||
- tk.Tk + attributes("-alpha", ...) : frameless + 페이드 인/아웃 효과.
|
||||
|
||||
dynamic effects(사용자 요구 "역동적으로"):
|
||||
1. 시작 시 알파 0 → 1 페이드인 (400ms)
|
||||
2. 비디오 자체 애니메이션(logo_intro.mp4 는 고유 모션 포함)
|
||||
3. 종료 전 알파 1 → 0 페이드아웃 (400ms)
|
||||
4. 비디오 하단에 브랜드 tagline bar (오렌지 italic)
|
||||
5. max_duration_s 초과 시 강제 종료(safety)
|
||||
|
||||
실패 조건(조용히 skip):
|
||||
- logo_intro.mp4 없음
|
||||
- cv2/PIL import 실패
|
||||
- VideoCapture.open 실패
|
||||
메인 앱 기동은 항상 보장된다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def show_intro_splash(
|
||||
video_path,
|
||||
max_duration_s: float = 12.0,
|
||||
fade_ms: int = 400,
|
||||
tagline: str = "S-CANVAS — Generative Design & Visualization Engine",
|
||||
max_display_w: int = 1000,
|
||||
) -> None:
|
||||
"""video_path 의 MP4 를 스플래시로 재생 후 블로킹 반환.
|
||||
|
||||
Args:
|
||||
video_path: Path-like, .mp4 파일.
|
||||
max_duration_s: 비디오가 이 시간을 넘기면 강제 종료.
|
||||
fade_ms: 페이드인/아웃 각 구간 길이(ms).
|
||||
tagline: 비디오 아래에 표시할 문구.
|
||||
max_display_w: 화면상 최대 가로(px). 이보다 크면 aspect 유지 축소.
|
||||
|
||||
동작:
|
||||
- 임시 tk.Tk 루트를 만들고 mainloop → 종료 시 완전 destroy.
|
||||
- 이후 `SCanvasApp()` 이 새 ctk.CTk 인스턴스를 만들어도 충돌 없음
|
||||
(Tk 루트가 이미 파괴됐으므로).
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image, ImageTk
|
||||
except ImportError as e:
|
||||
print(f"[Intro] cv2/PIL 미설치 — 스플래시 skip ({e})")
|
||||
return
|
||||
|
||||
vp = Path(video_path)
|
||||
if not vp.exists():
|
||||
print(f"[Intro] 비디오 없음 ({vp}) — 스플래시 skip")
|
||||
return
|
||||
|
||||
cap = cv2.VideoCapture(str(vp))
|
||||
if not cap.isOpened():
|
||||
print(f"[Intro] VideoCapture 열기 실패 — 스플래시 skip")
|
||||
return
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
frame_ms = max(int(1000.0 / max(fps, 1.0)), 8)
|
||||
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 800)
|
||||
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 450)
|
||||
|
||||
if vw > max_display_w:
|
||||
disp_w = max_display_w
|
||||
disp_h = max(1, int(vh * max_display_w / vw))
|
||||
else:
|
||||
disp_w, disp_h = vw, vh
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
screen_w = root.winfo_screenwidth()
|
||||
screen_h = root.winfo_screenheight()
|
||||
|
||||
TAG_H = 44
|
||||
total_h = disp_h + TAG_H
|
||||
x = max(0, (screen_w - disp_w) // 2)
|
||||
y = max(0, (screen_h - total_h) // 2)
|
||||
|
||||
splash = tk.Toplevel(root)
|
||||
splash.overrideredirect(True)
|
||||
try:
|
||||
splash.attributes("-topmost", True)
|
||||
splash.attributes("-alpha", 0.0)
|
||||
except tk.TclError:
|
||||
pass
|
||||
splash.configure(bg="#0A0F1C")
|
||||
splash.geometry(f"{disp_w}x{total_h}+{x}+{y}")
|
||||
|
||||
vid_label = tk.Label(splash, bg="#0A0F1C", borderwidth=0, highlightthickness=0)
|
||||
vid_label.place(x=0, y=0, width=disp_w, height=disp_h)
|
||||
|
||||
tag_label = tk.Label(
|
||||
splash, text=tagline,
|
||||
bg="#0A0F1C", fg="#E67E22",
|
||||
font=("Segoe UI", 10, "italic"),
|
||||
borderwidth=0, highlightthickness=0,
|
||||
)
|
||||
tag_label.place(x=0, y=disp_h, width=disp_w, height=TAG_H)
|
||||
|
||||
state = {"closed": False, "t_start": time.time()}
|
||||
|
||||
def _safe_alpha(a):
|
||||
try:
|
||||
splash.attributes("-alpha", max(0.0, min(1.0, a)))
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _fade_to(target, duration_ms, done_cb=None):
|
||||
steps = max(int(duration_ms / 16), 1)
|
||||
start_alpha = float(splash.attributes("-alpha") or 0.0)
|
||||
|
||||
def _step(i):
|
||||
if state["closed"]:
|
||||
return
|
||||
ratio = i / steps
|
||||
_safe_alpha(start_alpha + (target - start_alpha) * ratio)
|
||||
if i < steps:
|
||||
splash.after(16, lambda: _step(i + 1))
|
||||
elif done_cb:
|
||||
done_cb()
|
||||
|
||||
_step(0)
|
||||
|
||||
def _close():
|
||||
if state["closed"]:
|
||||
return
|
||||
state["closed"] = True
|
||||
try:
|
||||
cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
splash.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
root.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _play_next_frame():
|
||||
if state["closed"]:
|
||||
return
|
||||
if (time.time() - state["t_start"]) > max_duration_s:
|
||||
_fade_to(0.0, fade_ms, done_cb=_close)
|
||||
return
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
# 비디오 끝 — 페이드아웃 후 종료
|
||||
_fade_to(0.0, fade_ms, done_cb=_close)
|
||||
return
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
if (vw, vh) != (disp_w, disp_h):
|
||||
rgb = cv2.resize(rgb, (disp_w, disp_h), interpolation=cv2.INTER_AREA)
|
||||
img = Image.fromarray(rgb)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
vid_label.configure(image=photo)
|
||||
vid_label.image = photo # GC 방지
|
||||
splash.after(frame_ms, _play_next_frame)
|
||||
|
||||
# 페이드인 → 첫 프레임 재생 시작
|
||||
_fade_to(1.0, fade_ms, done_cb=_play_next_frame)
|
||||
|
||||
try:
|
||||
root.mainloop()
|
||||
finally:
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 단독 테스트
|
||||
show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")
|
||||
@@ -25,7 +25,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
@@ -45,7 +44,7 @@ def rotate_points_z(points: np.ndarray, angle_rad: float,
|
||||
|
||||
|
||||
def interpolate_terrain_z(tin_mesh: pv.PolyData, xy: tuple[float, float],
|
||||
origin: np.ndarray = None) -> Optional[float]:
|
||||
origin: np.ndarray = None) -> float | None:
|
||||
"""TIN 표면의 (x, y) 위치에서 Z값 보간.
|
||||
|
||||
Args:
|
||||
@@ -90,14 +89,14 @@ def apply_placement(
|
||||
plan_centroid: tuple[float, float],
|
||||
rotation_deg: float = 0.0,
|
||||
z_mode: str = "design",
|
||||
terrain_mesh: Optional[pv.PolyData] = None,
|
||||
terrain_origin: Optional[np.ndarray] = None,
|
||||
terrain_mesh: pv.PolyData | None = None,
|
||||
terrain_origin: np.ndarray | None = None,
|
||||
z_offset: float = 0.0,
|
||||
structure_bottom_el: Optional[float] = None,
|
||||
structure_bottom_el: float | None = None,
|
||||
skip_ground: bool = False,
|
||||
scale: float = 1.0,
|
||||
skip_terrain: bool = False,
|
||||
pad_surface_z: Optional[float] = None,
|
||||
pad_surface_z: float | None = None,
|
||||
embed_offset: float = 0.02,
|
||||
) -> list[tuple[pv.PolyData, str, float]]:
|
||||
"""구조물 메쉬 리스트를 지형 위 해당 위치에 배치.
|
||||
@@ -210,7 +209,7 @@ def apply_placement(
|
||||
new_mesh = mesh.copy()
|
||||
new_mesh.points = new_pts
|
||||
out.append((new_mesh, color, opacity))
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# 개별 메쉬 변환 실패시 건너뜀
|
||||
continue
|
||||
|
||||
@@ -220,17 +219,17 @@ def apply_placement(
|
||||
def fit_meshes_to_quad(
|
||||
meshes: list[tuple[pv.PolyData, str, float]],
|
||||
quad_world_pts: list,
|
||||
terrain_mesh: Optional[pv.PolyData] = None,
|
||||
terrain_origin: Optional[np.ndarray] = None,
|
||||
structure_bottom_el: Optional[float] = None,
|
||||
terrain_mesh: pv.PolyData | None = None,
|
||||
terrain_origin: np.ndarray | None = None,
|
||||
structure_bottom_el: float | None = None,
|
||||
z_mode: str = "terrain",
|
||||
z_offset: float = 0.0,
|
||||
skip_ground: bool = True,
|
||||
skip_terrain: bool = True,
|
||||
scale_mode: str = "none",
|
||||
pad_surface_z: Optional[float] = None,
|
||||
pad_surface_z: float | None = None,
|
||||
embed_offset: float = 0.02,
|
||||
detail_quad_pts: Optional[list] = None,
|
||||
detail_quad_pts: list | None = None,
|
||||
plan_frame_angle_deg: float = 0.0,
|
||||
flip_y_for_cw_quad: bool = True,
|
||||
) -> list[tuple[pv.PolyData, str, float]]:
|
||||
|
||||
@@ -12,8 +12,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
@@ -390,8 +389,6 @@ def _build_ground_plane(bounds: tuple, base_el: float = 0.0,
|
||||
h = max(maxy - miny, 1.0)
|
||||
mx = w * margin
|
||||
my = h * margin
|
||||
cx = (minx + maxx) / 2
|
||||
cy = (miny + maxy) / 2
|
||||
g = make_flat_rect(
|
||||
-(w / 2 + mx), (w / 2 + mx),
|
||||
-(h / 2 + my), (h / 2 + my),
|
||||
@@ -422,7 +419,7 @@ class BuildingTemplate(StructureTemplate):
|
||||
]
|
||||
|
||||
def parse(self, dxf_paths: list[str]) -> StructureParams:
|
||||
from detail_parser import DetailParser, dimensions_to_structure_params
|
||||
from detail_parser import DetailParser
|
||||
from dxf_geometry import extract_all
|
||||
|
||||
params = StructureParams(template_id=self.template_id, name="건축물")
|
||||
@@ -474,14 +471,12 @@ class BuildingTemplate(StructureTemplate):
|
||||
candidates.sort(key=lambda s: -s.area)
|
||||
candidates = candidates[:max_buildings]
|
||||
|
||||
outlines = []
|
||||
for s in candidates:
|
||||
outlines.append({
|
||||
"points": s.points,
|
||||
"closed": s.closed,
|
||||
"layer": s.layer,
|
||||
"area": s.area,
|
||||
})
|
||||
outlines = [{
|
||||
"points": s.points,
|
||||
"closed": s.closed,
|
||||
"layer": s.layer,
|
||||
"area": s.area,
|
||||
} for s in candidates]
|
||||
params.set("outlines", outlines)
|
||||
return params
|
||||
|
||||
@@ -551,7 +546,7 @@ class RetainingWallTemplate(StructureTemplate):
|
||||
]
|
||||
|
||||
def parse(self, dxf_paths: list[str]) -> StructureParams:
|
||||
from detail_parser import DetailParser, dimensions_to_structure_params
|
||||
from detail_parser import DetailParser
|
||||
from dxf_geometry import extract_all
|
||||
|
||||
params = StructureParams(template_id=self.template_id, name="옹벽")
|
||||
@@ -593,15 +588,13 @@ class RetainingWallTemplate(StructureTemplate):
|
||||
# 최소 길이 이상의 폴리라인/선을 모두 벽체 경로로 사용
|
||||
min_len = params.get("min_length", 5.0)
|
||||
max_walls = int(params.get("max_walls", 10))
|
||||
paths = []
|
||||
for s in geom.shapes:
|
||||
if s.kind in ("polyline", "line") and s.length >= min_len:
|
||||
paths.append({
|
||||
"points": s.points,
|
||||
"closed": s.closed,
|
||||
"layer": s.layer,
|
||||
"length": s.length,
|
||||
})
|
||||
paths = [{
|
||||
"points": s.points,
|
||||
"closed": s.closed,
|
||||
"layer": s.layer,
|
||||
"length": s.length,
|
||||
} for s in geom.shapes
|
||||
if s.kind in ("polyline", "line") and s.length >= min_len]
|
||||
|
||||
# 길이 내림차순 정렬 → 상위 N개만
|
||||
paths.sort(key=lambda x: -x["length"])
|
||||
@@ -743,13 +736,9 @@ class BridgeTemplate(StructureTemplate):
|
||||
params.set("deck_outline", deck_outline)
|
||||
|
||||
# 교각 위치: 작은 closed 폴리곤들 (교각 단면)
|
||||
piers = []
|
||||
for s in geom.closed_shapes:
|
||||
if 0.2 <= s.area <= 30.0: # 교각 단면 크기 범위
|
||||
piers.append({
|
||||
"centroid": s.centroid,
|
||||
"area": s.area,
|
||||
})
|
||||
piers = [{"centroid": s.centroid, "area": s.area}
|
||||
for s in geom.closed_shapes
|
||||
if 0.2 <= s.area <= 30.0] # 교각 단면 크기 범위
|
||||
# 중심점이 너무 가까운 것은 제거 (1m 이내)
|
||||
unique_piers = []
|
||||
for p in piers:
|
||||
@@ -931,7 +920,6 @@ class TunnelPortalTemplate(StructureTemplate):
|
||||
|
||||
meshes = []
|
||||
cx = (bounds[0] + bounds[2]) / 2
|
||||
cy = (bounds[1] + bounds[3]) / 2
|
||||
|
||||
if portal:
|
||||
# 갱구 외곽을 Y 방향으로 extrude (depth만큼)
|
||||
@@ -969,8 +957,6 @@ class TunnelPortalTemplate(StructureTemplate):
|
||||
# 터널 단면을 갱구에서 length만큼 Y 방향으로 extrude
|
||||
if tunnel:
|
||||
ts_pts = tunnel["points"]
|
||||
ts_centroid = tunnel["centroid"]
|
||||
tcx, tcy = ts_centroid[0] - cx, ts_centroid[1]
|
||||
|
||||
# 단면을 Y 방향으로 extrude (ext_depth → ext_depth + length)
|
||||
front = [(p[0] - cx, ext_depth, p[1]) for p in ts_pts]
|
||||
@@ -1037,7 +1023,7 @@ class GenericStructureTemplate(StructureTemplate):
|
||||
]
|
||||
|
||||
def parse(self, dxf_paths: list[str]) -> StructureParams:
|
||||
from detail_parser import DetailParser, dimensions_to_structure_params
|
||||
from detail_parser import DetailParser
|
||||
from dxf_geometry import extract_all
|
||||
|
||||
params = StructureParams(template_id=self.template_id, name="일반 구조물")
|
||||
@@ -1073,16 +1059,14 @@ class GenericStructureTemplate(StructureTemplate):
|
||||
params.set("_geom_unit", geom.detected_unit)
|
||||
|
||||
# 모든 shape 저장
|
||||
shapes_data = []
|
||||
for s in geom.shapes:
|
||||
shapes_data.append({
|
||||
"kind": s.kind,
|
||||
"points": s.points,
|
||||
"closed": s.closed,
|
||||
"layer": s.layer,
|
||||
"area": s.area,
|
||||
"length": s.length,
|
||||
})
|
||||
shapes_data = [{
|
||||
"kind": s.kind,
|
||||
"points": s.points,
|
||||
"closed": s.closed,
|
||||
"layer": s.layer,
|
||||
"area": s.area,
|
||||
"length": s.length,
|
||||
} for s in geom.shapes]
|
||||
params.set("shapes", shapes_data)
|
||||
return params
|
||||
|
||||
@@ -1136,13 +1120,12 @@ class GenericStructureTemplate(StructureTemplate):
|
||||
result = _extrude_polyline_wall(centered, base, h, wall_t, color)
|
||||
if result:
|
||||
meshes.append(result)
|
||||
else:
|
||||
# 모든 요소 선으로만
|
||||
if len(centered) >= 2:
|
||||
pts_3d = np.array([[p[0], p[1], base + h / 2] for p in centered])
|
||||
n = len(pts_3d)
|
||||
line = pv.PolyData(pts_3d, lines=np.concatenate([[n], np.arange(n)]))
|
||||
meshes.append((line, color, 1.0))
|
||||
# 모든 요소 선으로만
|
||||
elif len(centered) >= 2:
|
||||
pts_3d = np.array([[p[0], p[1], base + h / 2] for p in centered])
|
||||
n = len(pts_3d)
|
||||
line = pv.PolyData(pts_3d, lines=np.concatenate([[n], np.arange(n)]))
|
||||
meshes.append((line, color, 1.0))
|
||||
|
||||
if not meshes:
|
||||
# 폴백
|
||||
@@ -1534,8 +1517,8 @@ class DetailedRetainingWallTemplate(StructureTemplate):
|
||||
class TemplateRegistry:
|
||||
"""구조물 템플릿 레지스트리 (싱글톤 패턴)."""
|
||||
|
||||
_instance = None
|
||||
_templates: dict[str, StructureTemplate] = {}
|
||||
_instance: ClassVar[TemplateRegistry | None] = None
|
||||
_templates: ClassVar[dict[str, StructureTemplate]] = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, fields, is_dataclass
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -53,10 +53,7 @@ def render_dxf_to_png(dxf_paths: list[str] | str,
|
||||
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
|
||||
from ezdxf.addons.drawing.config import Configuration
|
||||
|
||||
if isinstance(dxf_paths, (list, tuple)):
|
||||
dxf_path = dxf_paths[0]
|
||||
else:
|
||||
dxf_path = dxf_paths
|
||||
dxf_path = dxf_paths[0] if isinstance(dxf_paths, (list, tuple)) else dxf_paths
|
||||
|
||||
doc = ezdxf.readfile(dxf_path)
|
||||
msp = doc.modelspace()
|
||||
@@ -245,7 +242,7 @@ def request_structure_diff(client,
|
||||
try:
|
||||
from google.genai import types as gtypes
|
||||
except ImportError as e:
|
||||
raise RuntimeError(f"google.genai SDK 필요: {e}")
|
||||
raise RuntimeError(f"google.genai SDK 필요: {e}") from e
|
||||
|
||||
params_json = json.dumps(params_dict, ensure_ascii=False, indent=2)
|
||||
prompt = _DIFF_SCHEMA_PROMPT.format(
|
||||
@@ -274,7 +271,7 @@ def request_structure_diff(client,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Gemini 호출 실패: {e}")
|
||||
raise RuntimeError(f"Gemini 호출 실패: {e}") from e
|
||||
|
||||
text = getattr(resp, "text", None) or ""
|
||||
if not text:
|
||||
@@ -300,9 +297,9 @@ def request_structure_diff(client,
|
||||
try:
|
||||
diff = json.loads(m.group(0))
|
||||
except Exception:
|
||||
raise RuntimeError(f"JSON 파싱 실패: {e}\n원문: {text[:300]}")
|
||||
raise RuntimeError(f"JSON 파싱 실패: {e}\n원문: {text[:300]}") from e
|
||||
else:
|
||||
raise RuntimeError(f"JSON 파싱 실패: {e}\n원문: {text[:300]}")
|
||||
raise RuntimeError(f"JSON 파싱 실패: {e}\n원문: {text[:300]}") from e
|
||||
|
||||
log_fn(f" [VLM] 응답 수신: match_score={diff.get('match_score', '?')} "
|
||||
f"updates={len(diff.get('param_updates', []))} "
|
||||
@@ -317,7 +314,7 @@ def request_structure_diff(client,
|
||||
|
||||
def apply_diff_to_params(params: Any,
|
||||
diff: dict,
|
||||
selections: Optional[dict] = None,
|
||||
selections: dict | None = None,
|
||||
log_fn: Callable[[str], None] = print) -> dict:
|
||||
"""diff를 params 객체에 in-place 적용.
|
||||
|
||||
@@ -433,10 +430,10 @@ def _set_by_path(obj: Any, path: str, value: Any):
|
||||
# 클라이언트 생성 (scanvas_maker의 패턴 재사용)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_genai_client(project: Optional[str] = None,
|
||||
def build_genai_client(project: str | None = None,
|
||||
location: str = "global",
|
||||
use_vertex: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
api_key: str | None = None,
|
||||
log_fn: Callable[[str], None] = print):
|
||||
"""Gemini 클라이언트 생성. Vertex AI 우선, 실패 시 API Key 폴백.
|
||||
|
||||
@@ -445,7 +442,7 @@ def build_genai_client(project: Optional[str] = None,
|
||||
try:
|
||||
from google import genai
|
||||
except ImportError as e:
|
||||
raise RuntimeError(f"google-genai SDK 필요: pip install google-genai ({e})")
|
||||
raise RuntimeError(f"google-genai SDK 필요: pip install google-genai ({e})") from e
|
||||
|
||||
if use_vertex:
|
||||
proj = project or os.environ.get("GCP_PROJECT_ID", "")
|
||||
@@ -488,7 +485,7 @@ def run_feedback_once(params: Any,
|
||||
|
||||
log_fn(" [VLM] 도면 PNG 렌더링...")
|
||||
render_dxf_to_png(dxf_paths, drawing_png)
|
||||
log_fn(f" [VLM] 3D top-down 렌더링...")
|
||||
log_fn(" [VLM] 3D top-down 렌더링...")
|
||||
render_meshes_topdown(meshes, render_png)
|
||||
|
||||
params_dict = params_to_dict(params)
|
||||
|
||||
0
tests/__init__.py
Normal file
28
tests/conftest.py
Normal 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
@@ -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})"
|
||||
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import math
|
||||
import random
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
701
validate_gate_params.py
Normal file
@@ -0,0 +1,701 @@
|
||||
"""validate_gate_params.py — Gate(여수로 수문) Params JSON 정적 검증기.
|
||||
|
||||
Blender 헤드리스 실행 *전*에 JSON 파일 하나만 가지고:
|
||||
- 필수 필드 / 타입 / 물리적 일관성 검사
|
||||
- 빌더(gate_3d_builder_bpy.py)가 어떤 분기로 갈지 예측
|
||||
(pier: Phase B' polygon vs parametric / bridge: user vs extracted vs parametric)
|
||||
- 예상 객체 수 계산
|
||||
- 잠재 이슈 경고
|
||||
|
||||
bpy를 import하지 않으므로 S-CANVAS env / 일반 Python / 어디서든 실행 가능.
|
||||
|
||||
검증 로직은 빌더의 `_validate_pier_polys` / `_validate_bridge_bbox` 과 정확히 동일.
|
||||
즉 본 검증이 통과하면 빌더에서도 같은 분기 선택 보장.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
사용법
|
||||
----------------------------------------------------------------------
|
||||
python validate_gate_params.py gate_params.json
|
||||
|
||||
Exit code:
|
||||
0 PASS — 빌드 진행 가능, 모든 분기가 의도대로 작동
|
||||
1 WARN — 빌드는 되나 일부 폴백 적용 (parametric 경로 등)
|
||||
2 FAIL — 빌드 시 빈 결과/예외 가능성 높음 (필수 필드 누락 등)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 검증 로직 — 빌더와 정확히 동일한 sanity check
|
||||
# ===========================================================================
|
||||
|
||||
def validate_pier_polys(pier_polys: list,
|
||||
pier_width: float,
|
||||
pier_length: float,
|
||||
tol_ratio: float = 0.5) -> tuple[bool, list[str]]:
|
||||
"""gate_3d_builder_bpy.GateBuilderBpy._validate_pier_polys 와 동일.
|
||||
|
||||
Returns: (passed, [개별 사유 메시지])
|
||||
"""
|
||||
reasons = []
|
||||
w_lo = pier_width * (1 - tol_ratio)
|
||||
w_hi = pier_width * (1 + tol_ratio)
|
||||
l_lo = pier_length * 0.4
|
||||
l_hi = pier_length * 1.5
|
||||
all_ok = True
|
||||
for i, poly in enumerate(pier_polys):
|
||||
xs = [p[0] for p in poly]
|
||||
ys = [p[1] for p in poly]
|
||||
if len(xs) < 3:
|
||||
reasons.append(f"pier#{i}: vertex < 3 ({len(xs)})")
|
||||
all_ok = False
|
||||
continue
|
||||
w = max(xs) - min(xs)
|
||||
l = max(ys) - min(ys)
|
||||
if not (w_lo <= w <= w_hi):
|
||||
reasons.append(f"pier#{i}: width {w:.2f}m 범위 [{w_lo:.2f},{w_hi:.2f}] 벗어남")
|
||||
all_ok = False
|
||||
if not (l_lo <= l <= l_hi):
|
||||
reasons.append(f"pier#{i}: length {l:.2f}m 범위 [{l_lo:.2f},{l_hi:.2f}] 벗어남")
|
||||
all_ok = False
|
||||
return all_ok, reasons
|
||||
|
||||
|
||||
def validate_bridge_bbox(bbox: tuple | None,
|
||||
total_span: float,
|
||||
pier_length: float) -> tuple[bool, str]:
|
||||
"""gate_3d_builder_bpy.GateBuilderBpy._validate_bridge_bbox 와 동일.
|
||||
|
||||
Returns: (passed, 사유 메시지)
|
||||
"""
|
||||
if bbox is None:
|
||||
return False, "bbox None"
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||
return False, f"bbox 형식 오류 (len={len(bbox) if hasattr(bbox, '__len__') else '?'})"
|
||||
x0, y0, x1, y1 = bbox
|
||||
w = x1 - x0
|
||||
h = y1 - y0
|
||||
if w < 1.0 or h < 0.5:
|
||||
return False, f"너무 작음 (W={w:.2f}m, H={h:.2f}m)"
|
||||
if not (total_span * 0.2 <= w <= total_span * 1.5):
|
||||
return False, (
|
||||
f"width {w:.2f}m 가 total_span 범위 "
|
||||
f"[{total_span * 0.2:.2f}, {total_span * 1.5:.2f}] 벗어남"
|
||||
)
|
||||
if not (pier_length * 0.05 <= h <= pier_length * 1.2):
|
||||
return False, (
|
||||
f"depth {h:.2f}m 가 pier_length 범위 "
|
||||
f"[{pier_length * 0.05:.2f}, {pier_length * 1.2:.2f}] 벗어남"
|
||||
)
|
||||
return True, "OK"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 색상 출력 (Windows cmd / PowerShell / Linux 모두 지원)
|
||||
# ===========================================================================
|
||||
|
||||
class C:
|
||||
"""ANSI 컬러 — Windows 10+ cmd는 자동 활성화. 미지원 환경은 빈 문자열."""
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
GRAY = "\033[90m"
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
BLUE = "\033[94m"
|
||||
CYAN = "\033[96m"
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
if sys.platform == "win32":
|
||||
# Windows 10 1607+ 에서 ANSI 지원. 환경변수로 비활성화 가능.
|
||||
if "NO_COLOR" in __import__("os").environ:
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
|
||||
if not _supports_color():
|
||||
for _attr in ("RESET", "BOLD", "GRAY", "RED", "GREEN", "YELLOW", "BLUE", "CYAN"):
|
||||
setattr(C, _attr, "")
|
||||
|
||||
|
||||
def _ok(msg: str) -> str: return f"{C.GREEN}✓{C.RESET} {msg}"
|
||||
def _warn(msg: str) -> str: return f"{C.YELLOW}⚠{C.RESET} {msg}"
|
||||
def _fail(msg: str) -> str: return f"{C.RED}✗{C.RESET} {msg}"
|
||||
def _info(msg: str) -> str: return f"{C.CYAN}·{C.RESET} {msg}"
|
||||
def _h(title: str) -> str:
|
||||
return f"\n{C.BOLD}{C.BLUE}── {title} {'─' * max(2, 60 - len(title))}{C.RESET}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 검증 컨텍스트
|
||||
# ===========================================================================
|
||||
|
||||
class ReportLevel:
|
||||
PASS = 0
|
||||
WARN = 1
|
||||
FAIL = 2
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self):
|
||||
self.level = ReportLevel.PASS
|
||||
self.lines: list[str] = []
|
||||
self.fail_count = 0
|
||||
self.warn_count = 0
|
||||
|
||||
def ok(self, msg: str):
|
||||
self.lines.append(_ok(msg))
|
||||
|
||||
def info(self, msg: str):
|
||||
self.lines.append(_info(msg))
|
||||
|
||||
def warn(self, msg: str):
|
||||
self.lines.append(_warn(msg))
|
||||
self.warn_count += 1
|
||||
self.level = max(self.level, ReportLevel.WARN)
|
||||
|
||||
def fail(self, msg: str):
|
||||
self.lines.append(_fail(msg))
|
||||
self.fail_count += 1
|
||||
self.level = ReportLevel.FAIL
|
||||
|
||||
def header(self, title: str):
|
||||
self.lines.append(_h(title))
|
||||
|
||||
def blank(self):
|
||||
self.lines.append("")
|
||||
|
||||
def raw(self, text: str):
|
||||
self.lines.append(text)
|
||||
|
||||
def emit(self):
|
||||
for line in self.lines:
|
||||
print(line)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 검증 단계들
|
||||
# ===========================================================================
|
||||
|
||||
def _is_number(x: Any) -> bool:
|
||||
return isinstance(x, (int, float)) and not isinstance(x, bool)
|
||||
|
||||
|
||||
def check_required_fields(d: dict, R: Report) -> bool:
|
||||
"""필수 필드 존재 + 타입 검증. False 반환 시 후속 검사 skip."""
|
||||
R.header("1. 필수 필드 / 타입")
|
||||
|
||||
required_numeric = [
|
||||
"n_gates", "gate_width", "gate_height",
|
||||
"pier_width", "pier_length",
|
||||
"el_gate_sill", "el_weir_crest", "el_gate_top",
|
||||
"el_trunnion_pin", "el_mwl", "el_nhwl", "el_lwl",
|
||||
"el_downstream", "el_upstream_bed", "el_bridge_top",
|
||||
"total_span", "total_length",
|
||||
]
|
||||
required_lists = ["ogee_profile", "gate_centers_x"]
|
||||
|
||||
fatal = False
|
||||
for key in required_numeric:
|
||||
if key not in d:
|
||||
R.fail(f"필드 누락: {key}")
|
||||
fatal = True
|
||||
elif not _is_number(d[key]):
|
||||
R.fail(f"필드 {key}={d[key]!r} (number 아님)")
|
||||
fatal = True
|
||||
for key in required_lists:
|
||||
if key not in d:
|
||||
R.fail(f"필드 누락: {key}")
|
||||
fatal = True
|
||||
elif not isinstance(d[key], list):
|
||||
R.fail(f"필드 {key}={d[key]!r} (list 아님)")
|
||||
fatal = True
|
||||
|
||||
if not fatal:
|
||||
R.ok(f"필수 numeric 필드 {len(required_numeric)}개 존재")
|
||||
R.ok(f"필수 list 필드 {len(required_lists)}개 존재")
|
||||
return not fatal
|
||||
|
||||
|
||||
def check_physical_consistency(d: dict, R: Report) -> None:
|
||||
"""표고 순서 / 치수 양수 / 게이트 개수 일관성."""
|
||||
R.header("2. 물리적 일관성")
|
||||
|
||||
# 표고 순서
|
||||
bed = d["el_upstream_bed"]
|
||||
sill = d["el_gate_sill"]
|
||||
crest = d["el_weir_crest"]
|
||||
top = d["el_gate_top"]
|
||||
bridge = d["el_bridge_top"]
|
||||
|
||||
el_chain = [
|
||||
("el_upstream_bed", bed),
|
||||
("el_gate_sill", sill),
|
||||
("el_weir_crest", crest),
|
||||
("el_gate_top", top),
|
||||
("el_bridge_top", bridge),
|
||||
]
|
||||
from itertools import pairwise
|
||||
chain_ok = True
|
||||
for (n_a, v_a), (n_b, v_b) in pairwise(el_chain):
|
||||
if v_a > v_b + 1e-6:
|
||||
R.fail(f"표고 역전: {n_a}={v_a:.3f} > {n_b}={v_b:.3f}")
|
||||
chain_ok = False
|
||||
if chain_ok:
|
||||
R.ok(f"표고 단조 증가: {bed:.2f} ≤ {sill:.2f} ≤ {crest:.2f} ≤ {top:.2f} ≤ {bridge:.2f}")
|
||||
|
||||
# 수위 표고
|
||||
lwl = d["el_lwl"]; nhwl = d["el_nhwl"]; mwl = d["el_mwl"]
|
||||
wl_ok = True
|
||||
if not (lwl <= nhwl + 1e-6):
|
||||
R.fail(f"수위 역전: LWL={lwl:.2f} > NHWL={nhwl:.2f}")
|
||||
wl_ok = False
|
||||
if not (nhwl <= mwl + 1e-6):
|
||||
R.fail(f"수위 역전: NHWL={nhwl:.2f} > MWL={mwl:.2f}")
|
||||
wl_ok = False
|
||||
if wl_ok:
|
||||
R.ok(f"수위 단조: LWL {lwl:.2f} ≤ NHWL {nhwl:.2f} ≤ MWL {mwl:.2f}")
|
||||
|
||||
# NHWL이 게이트 sill 위쪽인지 (수면이 수문 아래면 본체가 물에 잠기지 않는 경우)
|
||||
if nhwl <= sill:
|
||||
R.warn(f"NHWL={nhwl:.2f}이 sill={sill:.2f}보다 낮음 — 상류 수면이 수문 아래로 그려짐")
|
||||
|
||||
# 양의 치수
|
||||
n_gates = d["n_gates"]
|
||||
gate_w = d["gate_width"]
|
||||
gate_h_param = d["gate_height"]
|
||||
pier_w = d["pier_width"]
|
||||
pier_l = d["pier_length"]
|
||||
total_span = d["total_span"]
|
||||
|
||||
if n_gates < 1:
|
||||
R.fail(f"n_gates={n_gates} (1 이상이어야 함)")
|
||||
elif n_gates > 20:
|
||||
R.warn(f"n_gates={n_gates} (>20, 비정상적으로 많음)")
|
||||
else:
|
||||
R.ok(f"n_gates={n_gates}")
|
||||
|
||||
for name, val in [("gate_width", gate_w), ("gate_height", gate_h_param),
|
||||
("pier_width", pier_w), ("pier_length", pier_l),
|
||||
("total_span", total_span)]:
|
||||
if val <= 0:
|
||||
R.fail(f"{name}={val} ≤ 0")
|
||||
|
||||
# gate_height 와 sill→top 일관성
|
||||
gate_h_calc = top - sill
|
||||
if abs(gate_h_param - gate_h_calc) > 0.5:
|
||||
R.warn(
|
||||
f"gate_height={gate_h_param:.2f} 가 (top - sill)={gate_h_calc:.2f} 와 0.5m 이상 차이"
|
||||
)
|
||||
else:
|
||||
R.ok(f"gate_height={gate_h_param:.2f}m ≈ (top - sill)={gate_h_calc:.2f}m")
|
||||
|
||||
# gate_centers_x 와 n_gates 일관성
|
||||
gate_centers = d["gate_centers_x"]
|
||||
if len(gate_centers) != n_gates:
|
||||
R.warn(
|
||||
f"len(gate_centers_x)={len(gate_centers)} ≠ n_gates={n_gates} "
|
||||
f"— builder 내부에서 보정될 수 있음"
|
||||
)
|
||||
else:
|
||||
R.ok(f"gate_centers_x: {n_gates}개 일치")
|
||||
|
||||
# gate_centers_x 단조 증가 (sorted)
|
||||
if len(gate_centers) >= 2:
|
||||
if not all(gate_centers[i] <= gate_centers[i + 1] + 1e-6
|
||||
for i in range(len(gate_centers) - 1)):
|
||||
R.warn("gate_centers_x 가 정렬되지 않음 — pier_x_centers 계산이 비정상이 될 수 있음")
|
||||
else:
|
||||
spacings = [gate_centers[i+1] - gate_centers[i]
|
||||
for i in range(len(gate_centers) - 1)]
|
||||
avg = sum(spacings) / len(spacings)
|
||||
min_s = min(spacings); max_s = max(spacings)
|
||||
R.ok(
|
||||
f"gate_centers_x 정렬 OK — 평균 간격 {avg:.2f}m "
|
||||
f"(범위 {min_s:.2f}~{max_s:.2f})"
|
||||
)
|
||||
|
||||
# pier_count
|
||||
pier_count = d.get("pier_count", n_gates + 1)
|
||||
if pier_count != n_gates + 1:
|
||||
R.warn(f"pier_count={pier_count} ≠ n_gates+1={n_gates + 1}")
|
||||
else:
|
||||
R.ok(f"pier_count = n_gates+1 = {pier_count}")
|
||||
|
||||
|
||||
def check_ogee_profile(d: dict, R: Report) -> bool:
|
||||
"""ogee_profile 점 개수 / 단조 증가 / 표고 범위.
|
||||
|
||||
Returns: True if 본체 빌드가 가능 (점 ≥ 3)
|
||||
"""
|
||||
R.header("3. Ogee 프로파일 (여수로 본체)")
|
||||
|
||||
profile = d.get("ogee_profile", [])
|
||||
n = len(profile)
|
||||
|
||||
if n < 3:
|
||||
R.fail(f"ogee_profile 점 {n}개 (< 3) — 빌더가 본체를 그리지 못함")
|
||||
return False
|
||||
|
||||
# 각 점이 (x, z) 쌍인지
|
||||
bad = 0
|
||||
for i, pt in enumerate(profile):
|
||||
if not (isinstance(pt, (list, tuple)) and len(pt) == 2
|
||||
and _is_number(pt[0]) and _is_number(pt[1])):
|
||||
bad += 1
|
||||
if bad:
|
||||
R.fail(f"ogee_profile 에서 {bad}개 점이 (x, z) 형식 아님")
|
||||
return False
|
||||
|
||||
R.ok(f"ogee_profile: {n}개 점, 모두 (x, z) 형식 OK")
|
||||
|
||||
xs = [pt[0] for pt in profile]
|
||||
zs = [pt[1] for pt in profile]
|
||||
|
||||
# x 단조 증가 (엄격하지 않음 — ogee의 상류 수직 부분은 x 동일점 허용)
|
||||
decreasing = sum(1 for i in range(n - 1) if xs[i + 1] < xs[i] - 1e-6)
|
||||
if decreasing > 0:
|
||||
R.warn(
|
||||
f"ogee_profile.x 가 {decreasing}회 감소 — "
|
||||
f"prism 단면이 자기교차할 수 있음"
|
||||
)
|
||||
else:
|
||||
R.ok(f"ogee_profile.x 단조 증가 (range {min(xs):.2f}~{max(xs):.2f}m)")
|
||||
|
||||
# z 범위 표고와 일치
|
||||
z_min = min(zs); z_max = max(zs)
|
||||
sill = d["el_gate_sill"]; crest = d["el_weir_crest"]
|
||||
bed = d["el_upstream_bed"]
|
||||
if z_min > sill - 0.1:
|
||||
R.warn(
|
||||
f"ogee z_min={z_min:.2f} 가 sill={sill:.2f}보다 높음 — "
|
||||
f"본체 바닥이 게이트 sill 위로 올라감"
|
||||
)
|
||||
if z_max < crest - 0.5:
|
||||
R.warn(
|
||||
f"ogee z_max={z_max:.2f} 가 crest={crest:.2f}보다 낮음 — "
|
||||
f"본체가 weir crest에 미치지 못함"
|
||||
)
|
||||
if z_min < bed - 5.0:
|
||||
R.warn(
|
||||
f"ogee z_min={z_min:.2f}가 upstream_bed={bed:.2f}보다 5m 이상 깊음"
|
||||
)
|
||||
|
||||
R.info(f"ogee z-range: {z_min:.2f} ~ {z_max:.2f}m (span {z_max - z_min:.2f}m)")
|
||||
return True
|
||||
|
||||
|
||||
def predict_pier_branch(d: dict, R: Report) -> tuple[str, int]:
|
||||
"""빌더의 pier 빌드 분기 예측.
|
||||
|
||||
Returns: (branch_name, expected_pier_object_count)
|
||||
branch_name ∈ {"phase_b_polygon", "parametric"}
|
||||
"""
|
||||
R.header("4. Pier 빌드 분기 예측")
|
||||
|
||||
pier_polys = d.get("pier_plan_polygons", [])
|
||||
n_gates = d["n_gates"]
|
||||
expected_n_piers = n_gates + 1
|
||||
pier_w = d["pier_width"]
|
||||
pier_l = d["pier_length"]
|
||||
|
||||
R.info(f"기대 pier 개수: n_gates+1 = {expected_n_piers}")
|
||||
R.info(f"sanity 기준: pier_width={pier_w:.2f}m × {0.5}~{1.5}, "
|
||||
f"pier_length={pier_l:.2f}m × {0.4}~{1.5}")
|
||||
|
||||
# Phase B' 경로 후보
|
||||
if not pier_polys:
|
||||
R.warn("pier_plan_polygons 비어있음 → parametric 폴백")
|
||||
# parametric: body + nose × n_piers
|
||||
return "parametric", expected_n_piers * 2
|
||||
|
||||
if len(pier_polys) != expected_n_piers:
|
||||
R.warn(
|
||||
f"pier_plan_polygons 개수={len(pier_polys)} ≠ {expected_n_piers} "
|
||||
f"→ parametric 폴백"
|
||||
)
|
||||
return "parametric", expected_n_piers * 2
|
||||
|
||||
# 각 폴리곤 sanity
|
||||
passed, reasons = validate_pier_polys(pier_polys, pier_w, pier_l)
|
||||
if not passed:
|
||||
R.warn("pier 폴리곤 sanity 실패 → parametric 폴백:")
|
||||
for r in reasons:
|
||||
R.raw(f" - {r}")
|
||||
return "parametric", expected_n_piers * 2
|
||||
|
||||
R.ok(f"Phase B' 경로 통과 — 폴리곤 {len(pier_polys)}개 sanity OK")
|
||||
return "phase_b_polygon", expected_n_piers
|
||||
|
||||
|
||||
def predict_bridge_branch(d: dict, R: Report) -> tuple[str, int]:
|
||||
"""빌더의 bridge 빌드 분기 예측.
|
||||
|
||||
Returns: (branch_name, expected_object_count)
|
||||
branch_name ∈ {"none", "user", "extracted", "parametric"}
|
||||
(deck 1 + rail 2 = 3 또는 0)
|
||||
"""
|
||||
R.header("5. Bridge 빌드 분기 예측")
|
||||
|
||||
if not d.get("has_service_bridge", False):
|
||||
R.info("has_service_bridge=False → 공도교 빌드 안 함")
|
||||
return "none", 0
|
||||
|
||||
total_span = d["total_span"]
|
||||
pier_l = d["pier_length"]
|
||||
|
||||
# 1순위: 사용자 명시
|
||||
ux0 = d.get("bridge_x_start")
|
||||
ux1 = d.get("bridge_x_end")
|
||||
uy0 = d.get("bridge_y_start")
|
||||
uy1 = d.get("bridge_y_end")
|
||||
user_complete = (
|
||||
ux0 is not None and ux1 is not None and ux1 > ux0
|
||||
and uy0 is not None and uy1 is not None and uy1 > uy0
|
||||
)
|
||||
if user_complete:
|
||||
cand = (float(ux0), float(uy0), float(ux1), float(uy1))
|
||||
ok, reason = validate_bridge_bbox(cand, total_span, pier_l)
|
||||
if ok:
|
||||
R.ok(
|
||||
f"User override 통과: bbox=({cand[0]:.2f},{cand[1]:.2f}) "
|
||||
f"→ ({cand[2]:.2f},{cand[3]:.2f})"
|
||||
)
|
||||
return "user", 3
|
||||
else:
|
||||
R.warn(f"User override 실패 ({reason}) → 다음 단계 검사")
|
||||
|
||||
# 2순위: 파서 추출
|
||||
bbox = d.get("bridge_plan_bbox")
|
||||
if bbox is not None:
|
||||
if isinstance(bbox, list):
|
||||
bbox = tuple(bbox)
|
||||
ok, reason = validate_bridge_bbox(bbox, total_span, pier_l)
|
||||
if ok:
|
||||
R.ok(
|
||||
f"파서 추출 bbox 통과: ({bbox[0]:.2f},{bbox[1]:.2f}) "
|
||||
f"→ ({bbox[2]:.2f},{bbox[3]:.2f})"
|
||||
)
|
||||
return "extracted", 3
|
||||
else:
|
||||
R.warn(f"파서 추출 bbox 실패 ({reason}) → parametric 폴백")
|
||||
else:
|
||||
R.info("bridge_plan_bbox=None → parametric 폴백")
|
||||
|
||||
# 3순위: parametric
|
||||
pier_w = d["pier_width"]
|
||||
px0 = -pier_w * 0.5
|
||||
px1 = total_span + pier_w * 0.5
|
||||
py0 = pier_l * 0.3
|
||||
py1 = pier_l * 0.55
|
||||
R.info(
|
||||
f"Parametric bbox 사용: ({px0:.2f},{py0:.2f}) → ({px1:.2f},{py1:.2f}) "
|
||||
f"= W{px1-px0:.1f}m × H{py1-py0:.1f}m"
|
||||
)
|
||||
return "parametric", 3
|
||||
|
||||
|
||||
def predict_object_count(d: dict, R: Report,
|
||||
pier_branch: str, pier_objs: int,
|
||||
bridge_branch: str, bridge_objs: int) -> int:
|
||||
"""전체 예상 객체 수 합산."""
|
||||
R.header("6. 예상 객체 수")
|
||||
|
||||
n_gates = d["n_gates"]
|
||||
n_piers = n_gates + 1
|
||||
has_hoist = d.get("has_hoist_housings", True)
|
||||
has_water = d.get("has_water_surface", True)
|
||||
has_apron = d.get("has_downstream_apron", True)
|
||||
|
||||
# ogee_profile이 있어야 본체 빌드됨
|
||||
ogee_ok = len(d.get("ogee_profile", [])) >= 3
|
||||
body = 1 if ogee_ok else 0
|
||||
|
||||
# gates: skin + arms × 2 = 3 per gate
|
||||
gates = n_gates * 3 if d.get("gate_centers_x") else 0
|
||||
|
||||
# hoists: body + roof = 2 per pier
|
||||
hoists = n_piers * 2 if has_hoist else 0
|
||||
|
||||
water = 1 if has_water else 0
|
||||
apron = 1 if has_apron else 0
|
||||
|
||||
rows = [
|
||||
("SpillwayBody", body, "(ogee_profile ≥ 3pts)" if ogee_ok else "(ogee 부족)"),
|
||||
("Piers", pier_objs, f"({pier_branch})"),
|
||||
("Gates (skin+arms)", gates, f"({n_gates} × 3)"),
|
||||
("ServiceBridge", bridge_objs, f"({bridge_branch})"),
|
||||
("Hoists", hoists, f"({n_piers} × 2)" if has_hoist else "(disabled)"),
|
||||
("Water", water, "" if has_water else "(disabled)"),
|
||||
("Apron", apron, "" if has_apron else "(disabled)"),
|
||||
]
|
||||
total = sum(n for _, n, _ in rows)
|
||||
|
||||
R.raw("")
|
||||
R.raw(f" {C.BOLD}{'Component':<22}{'Count':>6} Notes{C.RESET}")
|
||||
R.raw(f" {'─' * 22}{'─' * 6} {'─' * 30}")
|
||||
for name, count, note in rows:
|
||||
color = C.GRAY if (count == 0 and "disabled" not in note and "부족" not in note) or count == 0 else C.RESET
|
||||
R.raw(f" {color}{name:<22}{count:>6} {note}{C.RESET}")
|
||||
R.raw(f" {'─' * 22}{'─' * 6}")
|
||||
R.raw(f" {C.BOLD}{'Total':<22}{total:>6}{C.RESET}")
|
||||
R.raw("")
|
||||
R.raw(f" {C.CYAN}→ Blender 콘솔에서 '[bpy-gate] Created {total} objects' "
|
||||
f"가 보여야 정상{C.RESET}")
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def check_secondary(d: dict, R: Report) -> None:
|
||||
"""추가 sanity / 정보성 검사."""
|
||||
R.header("7. 추가 검사 (정보·경고)")
|
||||
|
||||
# flow_direction_2d 단위벡터 확인
|
||||
fd = d.get("flow_direction_2d")
|
||||
if fd is not None:
|
||||
if isinstance(fd, list):
|
||||
fd = tuple(fd)
|
||||
if len(fd) == 2 and _is_number(fd[0]) and _is_number(fd[1]):
|
||||
mag = math.sqrt(fd[0] ** 2 + fd[1] ** 2)
|
||||
if abs(mag - 1.0) > 0.05:
|
||||
R.warn(f"flow_direction_2d magnitude={mag:.4f} (1.0과 0.05 이상 차이)")
|
||||
else:
|
||||
R.ok(f"flow_direction_2d=({fd[0]:+.3f},{fd[1]:+.3f}), |v|={mag:.4f}")
|
||||
else:
|
||||
R.warn(f"flow_direction_2d 형식 비정상: {fd!r}")
|
||||
|
||||
# plan_outline_polygon
|
||||
outline = d.get("plan_outline_polygon", [])
|
||||
if outline:
|
||||
n = len(outline)
|
||||
if n < 4:
|
||||
R.warn(f"plan_outline_polygon 점 {n}개 (< 4) — 평면 외곽이 너무 단순")
|
||||
else:
|
||||
R.ok(f"plan_outline_polygon: {n}개 점")
|
||||
|
||||
# Trunnion이 mid_el과 너무 다르면 빌더가 mid로 강제
|
||||
sill = d["el_gate_sill"]; top = d["el_gate_top"]
|
||||
mid_el = (sill + top) / 2
|
||||
trun = d["el_trunnion_pin"]
|
||||
if abs(trun - mid_el) > 0.5:
|
||||
R.info(
|
||||
f"trunnion EL.{trun:.2f} ↔ mid EL.{mid_el:.2f} (차이 {abs(trun-mid_el):.2f}m) "
|
||||
f"→ 빌더가 trunnion_el = mid_el로 강제 사용"
|
||||
)
|
||||
else:
|
||||
R.ok(f"trunnion EL.{trun:.2f} ≈ mid EL.{mid_el:.2f} (그대로 사용)")
|
||||
|
||||
# source_files 정보
|
||||
sf = d.get("source_files", [])
|
||||
if sf:
|
||||
R.info(f"source_files: {len(sf)}개")
|
||||
for f in sf:
|
||||
R.raw(f" - {Path(f).name if isinstance(f, str) else f}")
|
||||
|
||||
# raw_text_annotations 양 (참고용)
|
||||
rta = d.get("raw_text_annotations", [])
|
||||
R.info(f"raw_text_annotations: {len(rta)}개 (디버그 정보)")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 진입점
|
||||
# ===========================================================================
|
||||
|
||||
def validate_file(json_path: str) -> int:
|
||||
R = Report()
|
||||
R.raw("")
|
||||
R.raw(f"{C.BOLD}Gate Params 검증{C.RESET} — {C.GRAY}{json_path}{C.RESET}")
|
||||
|
||||
p = Path(json_path)
|
||||
if not p.exists():
|
||||
R.fail(f"파일 없음: {json_path}")
|
||||
R.emit()
|
||||
return ReportLevel.FAIL
|
||||
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = p.read_text(encoding="utf-8-sig")
|
||||
|
||||
try:
|
||||
d = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
R.fail(f"JSON 파싱 실패: {e}")
|
||||
R.emit()
|
||||
return ReportLevel.FAIL
|
||||
|
||||
if not isinstance(d, dict):
|
||||
R.fail(f"최상위가 dict 아님: {type(d).__name__}")
|
||||
R.emit()
|
||||
return ReportLevel.FAIL
|
||||
|
||||
R.info(f"필드 수: {len(d)}, 파일 크기: {p.stat().st_size:,} bytes")
|
||||
|
||||
# 1. 필수 필드
|
||||
if not check_required_fields(d, R):
|
||||
R.header("결론")
|
||||
R.fail("필수 필드 누락 — 빌더 실행 불가")
|
||||
R.emit()
|
||||
return ReportLevel.FAIL
|
||||
|
||||
# 2. 물리적 일관성
|
||||
check_physical_consistency(d, R)
|
||||
|
||||
# 3. ogee_profile
|
||||
check_ogee_profile(d, R)
|
||||
|
||||
# 4. pier 분기 예측
|
||||
pier_branch, pier_objs = predict_pier_branch(d, R)
|
||||
|
||||
# 5. bridge 분기 예측
|
||||
bridge_branch, bridge_objs = predict_bridge_branch(d, R)
|
||||
|
||||
# 6. 예상 객체 수
|
||||
total = predict_object_count(d, R, pier_branch, pier_objs, bridge_branch, bridge_objs)
|
||||
|
||||
# 7. 추가 검사
|
||||
check_secondary(d, R)
|
||||
|
||||
# 결론
|
||||
R.header("결론")
|
||||
if R.level == ReportLevel.PASS:
|
||||
R.raw(f"{C.GREEN}{C.BOLD}✓ PASS{C.RESET} — 빌드 진행 가능. "
|
||||
f"예상 객체 {total}개")
|
||||
elif R.level == ReportLevel.WARN:
|
||||
R.raw(f"{C.YELLOW}{C.BOLD}⚠ WARN{C.RESET} — "
|
||||
f"빌드는 가능하나 폴백/주의 사항 {R.warn_count}건. "
|
||||
f"예상 객체 {total}개")
|
||||
else:
|
||||
R.raw(f"{C.RED}{C.BOLD}✗ FAIL{C.RESET} — "
|
||||
f"FAIL {R.fail_count}건 / WARN {R.warn_count}건. 빌드 전 수정 권장")
|
||||
R.raw("")
|
||||
|
||||
R.emit()
|
||||
return R.level
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python validate_gate_params.py <gate_params.json>")
|
||||
sys.exit(2)
|
||||
json_path = sys.argv[1]
|
||||
sys.exit(validate_file(json_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,13 +14,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
|
||||
from valve_chamber_parser import ValveChamberParams, Valve, Pipe
|
||||
from valve_chamber_parser import ValveChamberParams, Valve
|
||||
|
||||
|
||||
COLORS = {
|
||||
@@ -87,10 +85,7 @@ class ValveChamberBuilder:
|
||||
n = p.hatch_count
|
||||
# 뚜껑 여러 개면 X방향 분산
|
||||
for i in range(n):
|
||||
if n == 1:
|
||||
hx = 0
|
||||
else:
|
||||
hx = -hw * 0.5 + (i / (n - 1)) * hw
|
||||
hx = 0 if n == 1 else -hw * 0.5 + i / (n - 1) * hw
|
||||
|
||||
# 상판은 뚜껑 기준 좌우로 분할
|
||||
# 좌측
|
||||
@@ -293,10 +288,7 @@ class ValveChamberBuilder:
|
||||
z = p.top_el + 0.5 # 상판 위
|
||||
|
||||
for i in range(p.hatch_count):
|
||||
if p.hatch_count == 1:
|
||||
hx = 0
|
||||
else:
|
||||
hx = -hw * 0.5 + (i / (p.hatch_count - 1)) * hw
|
||||
hx = 0 if p.hatch_count == 1 else -hw * 0.5 + i / (p.hatch_count - 1) * hw
|
||||
# 뚜껑 (약간 돌출)
|
||||
self._add_box(
|
||||
hx - hs, hx + hs,
|
||||
|
||||
@@ -16,15 +16,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
|
||||
from view_detector import detect_view_regions, ViewRegion
|
||||
from view_detector import detect_view_regions
|
||||
from dxf_geometry import extract_structural_geometry
|
||||
|
||||
|
||||
@@ -109,7 +106,7 @@ class ValveChamberParams:
|
||||
has_entry_stairs: bool = True
|
||||
|
||||
# 지반 / 환경
|
||||
ground_level: Optional[float] = None # 실 바닥보다 높은 지반
|
||||
ground_level: float | None = None # 실 바닥보다 높은 지반
|
||||
|
||||
# 소스
|
||||
source_files: list = field(default_factory=list)
|
||||
@@ -168,7 +165,7 @@ def _clean_mtext(raw: str) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _mleader_endpoint(ml) -> Optional[tuple[float, float]]:
|
||||
def _mleader_endpoint(ml) -> tuple[float, float] | None:
|
||||
"""MLEADER에서 첫 leader line의 끝점(화살표 위치, DXF raw 좌표) 반환."""
|
||||
try:
|
||||
ctx = ml.context
|
||||
@@ -221,8 +218,8 @@ class ValveChamberParser:
|
||||
el_texts = self._collect_el(msp, scale)
|
||||
if el_texts:
|
||||
els = [v for _, _, v in el_texts]
|
||||
params.top_el = max(params.top_el, max(els))
|
||||
params.bottom_el = min(params.bottom_el, min(els))
|
||||
params.top_el = max(params.top_el, *els)
|
||||
params.bottom_el = min(params.bottom_el, *els)
|
||||
# 중간 EL들 추출 (바닥 단)
|
||||
mid_els = sorted(set(
|
||||
round(v, 1) for _, _, v in el_texts
|
||||
@@ -252,8 +249,8 @@ class ValveChamberParser:
|
||||
continue
|
||||
try:
|
||||
if e.dxftype() == "LWPOLYLINE":
|
||||
for p in e.get_points():
|
||||
chamber_pts.append((p[0] * scale, p[1] * scale))
|
||||
chamber_pts.extend((p[0] * scale, p[1] * scale)
|
||||
for p in e.get_points())
|
||||
elif e.dxftype() == "LINE":
|
||||
chamber_pts.append((e.dxf.start.x * scale, e.dxf.start.y * scale))
|
||||
chamber_pts.append((e.dxf.end.x * scale, e.dxf.end.y * scale))
|
||||
@@ -273,13 +270,9 @@ class ValveChamberParser:
|
||||
# MLEADER 결과가 우선. 같은 M-NNN이 양쪽에 있으면 MLEADER 사용.
|
||||
ml_names = {v.name for v in ml_valves} | {p.name for p in ml_pipes}
|
||||
merged_valves = list(ml_valves)
|
||||
for v in txt_valves:
|
||||
if v.name and v.name not in ml_names:
|
||||
merged_valves.append(v)
|
||||
merged_valves.extend(v for v in txt_valves if v.name and v.name not in ml_names)
|
||||
merged_pipes = list(ml_pipes)
|
||||
for p in txt_pipes:
|
||||
if p.name and p.name not in ml_names:
|
||||
merged_pipes.append(p)
|
||||
merged_pipes.extend(p for p in txt_pipes if p.name and p.name not in ml_names)
|
||||
|
||||
# dedupe by name — 같은 M-NNN이 여러 엔티티(label column TEXT + in-drawing MTEXT)
|
||||
# 에서 잡혀 중복 생성되는 케이스 방지. 직경이 명시된 항목 우선.
|
||||
@@ -470,10 +463,7 @@ class ValveChamberParser:
|
||||
continue
|
||||
try:
|
||||
raw = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "")
|
||||
if e.dxftype() == "MTEXT":
|
||||
txt = _clean_mtext(raw)
|
||||
else:
|
||||
txt = raw.strip()
|
||||
txt = _clean_mtext(raw) if e.dxftype() == "MTEXT" else raw.strip()
|
||||
if not txt:
|
||||
continue
|
||||
pos = e.dxf.insert
|
||||
@@ -502,7 +492,7 @@ class ValveChamberParser:
|
||||
)
|
||||
return is_p, is_v, is_dest
|
||||
|
||||
def _parse_dia(text: str) -> Optional[float]:
|
||||
def _parse_dia(text: str) -> float | None:
|
||||
m = re.search(r"(?:Φ|%%[cC]|\bD[\s=:]*)(\d[\d,.]*)", text)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
@@ -17,18 +17,15 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
|
||||
from dxf_geometry import (
|
||||
extract_structural_geometry,
|
||||
GeometryResult,
|
||||
Shape,
|
||||
is_excluded_layer,
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +108,7 @@ class ViewRegion:
|
||||
bounds: tuple # (xmin, ymin, xmax, ymax) in m
|
||||
shapes: list # 이 뷰 안의 Shape 목록
|
||||
scale_hint: str = "" # "1:100" 등
|
||||
scale_value: Optional[int] = None # 100 (축척 분모)
|
||||
scale_value: int | None = None # 100 (축척 분모)
|
||||
has_frame: bool = True # 사각형 프레임이 명시적으로 있는지
|
||||
|
||||
@property
|
||||
@@ -155,7 +152,7 @@ class ViewRegion:
|
||||
# 라벨 검출
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify_view_label(text: str) -> Optional[str]:
|
||||
def classify_view_label(text: str) -> str | None:
|
||||
"""텍스트가 뷰 라벨인지 확인하고 타입 반환."""
|
||||
# 매우 긴 텍스트는 라벨이 아닐 가능성 (NOTES 등)
|
||||
if len(text) > 30:
|
||||
@@ -168,7 +165,7 @@ def classify_view_label(text: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def extract_scale(text: str) -> tuple[str, Optional[int]]:
|
||||
def extract_scale(text: str) -> tuple[str, int | None]:
|
||||
"""텍스트에서 축척 추출 (e.g., 'S=1:100')."""
|
||||
m = SCALE_PATTERN.search(text)
|
||||
if m:
|
||||
@@ -315,7 +312,7 @@ def _dist_point_to_bbox(pt: tuple, bbox: tuple) -> float:
|
||||
|
||||
|
||||
def match_label_to_rectangle(label_pos: tuple, rectangles: list[Shape],
|
||||
max_distance: float = 30.0) -> Optional[Shape]:
|
||||
max_distance: float = 30.0) -> Shape | None:
|
||||
"""라벨 위치에 가장 적합한 사각형을 찾기.
|
||||
|
||||
우선순위:
|
||||
@@ -377,11 +374,9 @@ def estimate_region_without_frame(label_pos: tuple, all_labels: list[dict],
|
||||
others = [l["pos"] for l in all_labels
|
||||
if abs(l["pos"][0] - lx) > 0.01 or abs(l["pos"][1] - ly) > 0.01]
|
||||
|
||||
# 기본: 전체 bbox의 1/2 정도
|
||||
# 기본: 전체 bbox 기준 (default_w/h 사용 안 함 — 라벨 간 분할만 적용)
|
||||
total_w = geom_bounds[2] - geom_bounds[0]
|
||||
total_h = geom_bounds[3] - geom_bounds[1]
|
||||
default_w = total_w * 0.5
|
||||
default_h = total_h * 0.5
|
||||
|
||||
# 이웃 라벨과의 중간 지점까지를 경계로
|
||||
x_min = geom_bounds[0]
|
||||
@@ -487,7 +482,7 @@ def detect_views_multi(dxf_paths: list[str]) -> list[ViewRegion]:
|
||||
return all_views
|
||||
|
||||
|
||||
def get_view_by_type(views: list[ViewRegion], view_type: str) -> Optional[ViewRegion]:
|
||||
def get_view_by_type(views: list[ViewRegion], view_type: str) -> ViewRegion | None:
|
||||
"""타입으로 뷰 검색. 같은 타입이 여러 개면 첫 번째."""
|
||||
for v in views:
|
||||
if v.view_type == view_type:
|
||||
|
||||
@@ -26,14 +26,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
|
||||
from view_detector import ViewRegion
|
||||
from dxf_geometry import Shape
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -90,7 +87,7 @@ def _polyline_length(points: list) -> float:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pick_main_view(views: list[ViewRegion], view_type: str,
|
||||
template_id: Optional[str] = None) -> Optional[ViewRegion]:
|
||||
template_id: str | None = None) -> ViewRegion | None:
|
||||
"""주어진 타입의 뷰 중 가장 대표적인 것 선택.
|
||||
|
||||
선택 기준:
|
||||
@@ -121,7 +118,7 @@ def pick_main_view(views: list[ViewRegion], view_type: str,
|
||||
# 뷰에서 메인 실루엣(silhouette) 추출
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_main_silhouette(view: ViewRegion) -> Optional[list]:
|
||||
def extract_main_silhouette(view: ViewRegion) -> list | None:
|
||||
"""뷰에서 구조물의 외곽선을 단일 폴리곤으로 추출.
|
||||
|
||||
전략 (실루엣 면적이 뷰 면적의 일정 비율 이상이어야 진짜 외곽으로 간주):
|
||||
@@ -161,9 +158,8 @@ def extract_main_silhouette(view: ViewRegion) -> Optional[list]:
|
||||
if s.closed:
|
||||
if _polygon_area(s.points) >= view_area * 0.001:
|
||||
significant_shapes.append(s)
|
||||
else:
|
||||
if _polyline_length(s.points) >= max(view.width, view.height) * 0.05:
|
||||
significant_shapes.append(s)
|
||||
elif _polyline_length(s.points) >= max(view.width, view.height) * 0.05:
|
||||
significant_shapes.append(s)
|
||||
|
||||
if significant_shapes:
|
||||
try:
|
||||
@@ -219,7 +215,7 @@ def _view_rect_silhouette(view: ViewRegion) -> list:
|
||||
# Oriented Bounding Box (PCA 기반) — 가늘고 긴 구조 감지용
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_oriented_bbox(points: list) -> Optional[dict]:
|
||||
def compute_oriented_bbox(points: list) -> dict | None:
|
||||
"""점들의 PCA로 oriented bounding box 계산.
|
||||
|
||||
Returns:
|
||||
@@ -285,7 +281,7 @@ def compute_oriented_bbox(points: list) -> Optional[dict]:
|
||||
|
||||
|
||||
def is_elongated_structure(plan_view: ViewRegion,
|
||||
min_aspect: float = 2.5) -> Optional[dict]:
|
||||
min_aspect: float = 2.5) -> dict | None:
|
||||
"""평면도가 가늘고 긴 구조물(옹벽 등)인지 감지.
|
||||
|
||||
Returns:
|
||||
@@ -308,9 +304,8 @@ def is_elongated_structure(plan_view: ViewRegion,
|
||||
if s.closed:
|
||||
if _polygon_area(s.points) < min_sig_area:
|
||||
continue
|
||||
else:
|
||||
if _polyline_length(s.points) < min_sig_len:
|
||||
continue
|
||||
elif _polyline_length(s.points) < min_sig_len:
|
||||
continue
|
||||
significant_pts.extend(s.points)
|
||||
|
||||
# 점이 충분치 않으면 모든 점 사용
|
||||
@@ -411,7 +406,7 @@ def compute_3d_dimensions(silhouettes: dict) -> dict:
|
||||
|
||||
def _build_extrude_mesh(profile: list, base_z: float, height: float,
|
||||
color: str = "#BDC3C7", opacity: float = 1.0
|
||||
) -> Optional[tuple]:
|
||||
) -> tuple | None:
|
||||
"""폴리곤(2D)을 Z방향으로 extrude.
|
||||
|
||||
profile: [(x, y), ...] 로컬 좌표
|
||||
@@ -449,7 +444,7 @@ def _build_extrude_mesh(profile: list, base_z: float, height: float,
|
||||
|
||||
def _build_extrude_along_y(profile_xz: list, depth: float, base_y: float = 0,
|
||||
color: str = "#BDC3C7", opacity: float = 1.0
|
||||
) -> Optional[tuple]:
|
||||
) -> tuple | None:
|
||||
"""X-Z 프로파일을 Y방향으로 depth만큼 extrude.
|
||||
|
||||
profile_xz: [(x, z), ...] 로컬 좌표
|
||||
@@ -487,7 +482,7 @@ def _build_extrude_along_y(profile_xz: list, depth: float, base_y: float = 0,
|
||||
|
||||
def _build_extrude_along_x(profile_yz: list, width: float, base_x: float = 0,
|
||||
color: str = "#BDC3C7", opacity: float = 1.0
|
||||
) -> Optional[tuple]:
|
||||
) -> tuple | None:
|
||||
"""Y-Z 프로파일을 X방향으로 width만큼 extrude."""
|
||||
if not profile_yz or len(profile_yz) < 3:
|
||||
return None
|
||||
@@ -523,7 +518,7 @@ def _build_extrude_along_x(profile_yz: list, width: float, base_x: float = 0,
|
||||
def _build_wall_swept(obb: dict, section_2d: list,
|
||||
fallback_height: float,
|
||||
color: str = "#7F8C8D",
|
||||
opacity: float = 1.0) -> Optional[tuple]:
|
||||
opacity: float = 1.0) -> tuple | None:
|
||||
"""OBB 길이방향을 따라 단면(section)을 sweep.
|
||||
|
||||
section_2d: [(perp_offset, z_height), ...] 단면 프로파일 점들
|
||||
@@ -628,7 +623,7 @@ def _center_silhouette(silhouette: list, axis: str = "xy") -> list:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reconstruct_from_views(views: list[ViewRegion],
|
||||
template_id: Optional[str] = None
|
||||
template_id: str | None = None
|
||||
) -> list[tuple]:
|
||||
"""검출된 뷰들로부터 단일 통합 3D 구조물 메쉬 생성.
|
||||
|
||||
@@ -709,10 +704,10 @@ def reconstruct_from_views(views: list[ViewRegion],
|
||||
|
||||
|
||||
def _build_unified_structure(silhouettes: dict, dims: dict,
|
||||
color: str, template_id: Optional[str],
|
||||
plan_view: Optional[ViewRegion] = None,
|
||||
section_silhouette: Optional[list] = None,
|
||||
) -> Optional[tuple]:
|
||||
color: str, template_id: str | None,
|
||||
plan_view: ViewRegion | None = None,
|
||||
section_silhouette: list | None = None,
|
||||
) -> tuple | None:
|
||||
"""가용 실루엣들로부터 단일 3D 구조물 메쉬 생성.
|
||||
|
||||
우선순위:
|
||||
@@ -803,7 +798,7 @@ def _build_unified_structure(silhouettes: dict, dims: dict,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def diagnose_views(views: list[ViewRegion],
|
||||
template_id: Optional[str] = None) -> dict:
|
||||
template_id: str | None = None) -> dict:
|
||||
"""뷰 검출/재구성 진단 정보 반환.
|
||||
|
||||
Returns:
|
||||
@@ -905,7 +900,7 @@ if __name__ == "__main__":
|
||||
views = detect_view_regions(path)
|
||||
info = diagnose_views(views, tid)
|
||||
print(f" 뷰 {info['total_views']}개 검출")
|
||||
print(f" 메인 뷰:")
|
||||
print(" 메인 뷰:")
|
||||
for vt, label in info["main_views"].items():
|
||||
ok = "✓" if info["silhouettes_extracted"].get(vt) else "✗"
|
||||
print(f" [{vt}] {ok} \"{label}\"")
|
||||
|
||||