Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진. ~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수). 이 커밋은 7-iter cleanup이 적용된 상태로 import: - F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError (Py3.13에서 reproduce 확인된 진짜 버그) - RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화 - F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization 신규 파일: - ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화 - requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel) - .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외 검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# ===== Secrets =====
|
||||||
|
gcp-key.json
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# ===== Python =====
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# ===== Virtual envs =====
|
||||||
|
venv/
|
||||||
|
venv*/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# ===== Backups =====
|
||||||
|
*.bak
|
||||||
|
*.bak_*
|
||||||
|
|
||||||
|
# ===== IDE / OS =====
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ===== Tooling metadata =====
|
||||||
|
.claude/
|
||||||
|
workspace/
|
||||||
|
|
||||||
|
# ===== 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
|
||||||
1924
CHANGELOG.md
Normal file
BIN
Design/Logo.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
BIN
Design/S-CANVAS_AI_Visualization.pdf
Normal file
BIN
Design/SAMAN_CI.gif
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Design/homepage_sample.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
Design/logo_V2.png
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
BIN
Design/logo_intro.mp4
Normal file
BIN
Design/page_sample.png
Normal file
|
After Width: | Height: | Size: 594 KiB |
737
S-CANVAS_brief.md
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
# S-CANVAS — 프로그램 핵심 소개 (NotebookLM 발표자료 소스)
|
||||||
|
|
||||||
|
> **목적**: 본 문서는 NotebookLM 에 업로드하여 발표 슬라이드, Audio Overview,
|
||||||
|
> 마인드맵 등을 자동 생성하기 위한 **단일 소스 텍스트** 입니다. S-CANVAS
|
||||||
|
> 프로그램의 정체성·아키텍처·핵심 기술·워크플로·차별화 포인트를 한 파일에
|
||||||
|
> 응축했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 한 줄 요약
|
||||||
|
|
||||||
|
**S-CANVAS** — *Generative Design & Visualization Engine*. CAD 도면(DXF)과 실제
|
||||||
|
공개 DEM·위성영상·Generative AI 를 결합해 **건설/토목 프로젝트의 3D 조감도와
|
||||||
|
구조물 시각화**를 자동 생성하는 데스크톱 엔진. **Saman Corp.** 자체 개발.
|
||||||
|
|
||||||
|
- 원래 명칭: `EG-VIEW` (AI 기반 조감도 생성 시스템)
|
||||||
|
- 2026-04-24 리브랜딩: `S-CANVAS` (Generative Design & Visualization Engine)
|
||||||
|
- 메인 진입점: `scanvas_maker.py`
|
||||||
|
- 플랫폼: Windows 10/11, Python + CustomTkinter GUI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 배경 & 문제 정의
|
||||||
|
|
||||||
|
### 2.1 기존 토목·건설 시각화의 한계
|
||||||
|
|
||||||
|
전통적인 CAD 평면도는 **2D**이며, 등고선·구조물 윤곽만 담겨 있어 실제 지형
|
||||||
|
맥락(주변 산세, 강, 식생, 도로망)이 빠져 있다. 발주처 보고·주민 설명회에서는
|
||||||
|
**"실제 어떻게 보이는가"** 가 중요하지만, 기존 워크플로에서는:
|
||||||
|
|
||||||
|
1. CAD 도면을 3D 모델러(Civil 3D/Revit/Rhino) 로 다시 그리고
|
||||||
|
2. GIS 데이터를 별도로 받아 좌표 정합
|
||||||
|
3. 위성영상을 매핑하고
|
||||||
|
4. 렌더 엔진(Lumion/Twinmotion/V-Ray) 으로 조명·재질·하늘 설정
|
||||||
|
5. 카메라 앵글 잡고 이미지 출력
|
||||||
|
|
||||||
|
이 과정이 **수일에서 수주** 걸리며, 변경이 생길 때마다 대부분의 단계를 다시
|
||||||
|
수행해야 한다.
|
||||||
|
|
||||||
|
### 2.2 S-CANVAS 의 가설
|
||||||
|
|
||||||
|
> *"DXF 등고선·구조물 레이어 + 공개 DEM + 위성타일 + Generative AI 만으로,
|
||||||
|
> 클릭 4번에 사실 기반 3D 조감도를 만들 수 있다."*
|
||||||
|
|
||||||
|
- DXF 의 등고선 → TIN(Triangulated Irregular Network) 자동 생성
|
||||||
|
- DXF 범위 밖 지형 → AWS Open Terrain Tiles(글로벌 DEM) 자동 채움
|
||||||
|
- 표면 텍스처 → Google/ArcGIS/Vworld 위성 타일을 UV 매핑
|
||||||
|
- 최종 사실감 향상 → Gemini/Stability AI 가 **구조 보존 모드**로 재렌더
|
||||||
|
|
||||||
|
### 2.3 핵심 가치 제안 (Value Proposition)
|
||||||
|
|
||||||
|
| 기존 | S-CANVAS |
|
||||||
|
|---|---|
|
||||||
|
| 모델링 수일 | TIN 생성 30초 |
|
||||||
|
| 외부 라이선스 GIS | 공개 DEM 자동 페치 |
|
||||||
|
| 수동 좌표 정합 | 4점 매칭 자동 GeoRef |
|
||||||
|
| 렌더 1회 수시간 | AI 렌더 1분 이내 |
|
||||||
|
| 변경 → 재모델링 | 변경 → 재실행 (캐시 재활용) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 시스템 아키텍처
|
||||||
|
|
||||||
|
### 3.1 모듈 구성 (Top Level)
|
||||||
|
|
||||||
|
```
|
||||||
|
scanvas_maker.py # 메인 GUI 엔트리 (~6300 LOC, CustomTkinter)
|
||||||
|
├── splash.py # 인트로 MP4 스플래시 (cv2 + Tk Toplevel)
|
||||||
|
├── dem_extender.py # AWS Terrarium DEM 페치 + 도넛 링 메시
|
||||||
|
├── geo_referencing.py # 4점 매칭 GeoReferencing 워크플로
|
||||||
|
├── structure_placement.py # 구조물 위치/방향 자동 인식 + TIN 굴착
|
||||||
|
├── structure_templates.py # 구조물 유형 레지스트리 (Registry 패턴)
|
||||||
|
├── intake_tower_3d_builder.py # 취수탑 3D
|
||||||
|
├── intake_tower_parser.py # 취수탑 DXF 치수 파싱
|
||||||
|
├── retaining_wall_3d_builder.py # 옹벽 3D
|
||||||
|
├── retaining_wall_parser.py
|
||||||
|
├── gate_3d_builder.py # 수문 3D
|
||||||
|
├── gate_parser.py
|
||||||
|
├── valve_chamber_3d_builder.py # 제수변실 3D
|
||||||
|
├── valve_chamber_parser.py
|
||||||
|
├── detail_parser.py # 상세도면 DXF 치수 파서 (TEXT/MTEXT/DIMENSION)
|
||||||
|
├── filename_classifier.py # 파일명 → 구조물 유형 추정
|
||||||
|
├── polygon_reconstructor.py
|
||||||
|
├── dxf_geometry.py # DXF 엔티티 → 좌표
|
||||||
|
├── tile_downloader.py # 위성 XYZ 타일 다운로드
|
||||||
|
├── view_detector.py / view_reconstructor.py # 도면 뷰 영역 인식
|
||||||
|
├── optional_detector.py
|
||||||
|
├── gemini_renderer.py # Gemini API/Vertex AI 렌더링 워커
|
||||||
|
├── structure_vlm_feedback.py # Gemini Vision 으로 구조물 결과 검증
|
||||||
|
├── egview_maker.py 의 후신: # (이전 명칭, 현재 scanvas_maker.py 로 통합/리네임)
|
||||||
|
│
|
||||||
|
└── harness/ # 품질·재현성·이력 서브시스템
|
||||||
|
├── seed_manager.py # DXF 해시 기반 결정론적 seed
|
||||||
|
├── quality_validator.py # OpenCV 기반 이미지 자동 QA
|
||||||
|
├── prompt_registry.py # 프롬프트 버전 관리 (YAML)
|
||||||
|
└── logger.py # SQLite ORM (JobRecord) + structlog
|
||||||
|
|
||||||
|
prompt_templates/prompt_v1.yaml # 렌더 프롬프트 (시간대/앙각/구조보존)
|
||||||
|
structure_types/structure_v1.yaml # 구조물 유형 정의
|
||||||
|
|
||||||
|
Design/ # 브랜딩 자산
|
||||||
|
├── logo_V2.png # 로고 (다크 bg → 런타임 스트립)
|
||||||
|
├── Logo.png # 이전 로고 (투명 bg)
|
||||||
|
├── SAMAN_CI.gif # Saman Corp 크레딧
|
||||||
|
├── logo_intro.mp4 # 인트로 스플래시 (8s, 24fps, 1280×720)
|
||||||
|
├── homepage_sample.png # UI 디자인 레퍼런스
|
||||||
|
└── page_sample.png
|
||||||
|
|
||||||
|
cache/dem/ # AWS Terrarium 타일 캐시
|
||||||
|
scanvas_jobs.db # SQLite 작업 이력 (Harness)
|
||||||
|
scanvas_harness.log # structlog 출력
|
||||||
|
scanvas_diagnostic.log # Step 1 진단 로그
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 데이터 흐름 (Pipeline)
|
||||||
|
|
||||||
|
```
|
||||||
|
[DXF 도면] → [레이어 분류] → [등고선/구조물 분리]
|
||||||
|
↓
|
||||||
|
[GeoRef 4점 매칭] → [투영 CRS 결정 (EPSG:5187 등)]
|
||||||
|
↓
|
||||||
|
[TIN 생성 (Delaunay)] → [TIN core 정밀 영역 지정 (선택)]
|
||||||
|
↓
|
||||||
|
[DEM 자동 페치 (AWS Terrarium)] → [도넛 링 메시] → [smoothstep blend]
|
||||||
|
↓
|
||||||
|
[seam-free 통합 메시 (merge_points + compute_normals)]
|
||||||
|
↓
|
||||||
|
[위성 타일 다운로드] → [UV 매핑 (texture_map_to_plane)]
|
||||||
|
↓
|
||||||
|
[3D 미리보기 (PyVista)] → [카메라 앵글 캡처] → [제어맵]
|
||||||
|
↓
|
||||||
|
[Gemini/Stability AI 렌더링 (구조 보존 모드)]
|
||||||
|
↓
|
||||||
|
[QualityValidator 자동 검증] → [scanvas_jobs.db 이력 기록]
|
||||||
|
↓
|
||||||
|
[고해상도 PNG 출력]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 외부 의존성
|
||||||
|
|
||||||
|
**Python 라이브러리** (주요):
|
||||||
|
- `customtkinter` — 모던 Tk GUI 프레임워크
|
||||||
|
- `tkintermapview` — 위성지도 뷰
|
||||||
|
- `ezdxf` — DXF 파싱
|
||||||
|
- `numpy`, `scipy` — 수치 연산, Delaunay
|
||||||
|
- `pyvista` + `vtk` — 3D 메시 렌더링
|
||||||
|
- `pyproj` — 좌표계 변환
|
||||||
|
- `Pillow (PIL)` — 이미지 처리
|
||||||
|
- `opencv-python (cv2)` — 비디오 / 이미지 검증
|
||||||
|
- `requests` — HTTP 타일 페치
|
||||||
|
- `google-genai` — Gemini SDK (Vertex AI 또는 API Key)
|
||||||
|
- `sqlalchemy` + `structlog` — 이력 추적
|
||||||
|
- `PyYAML` — 프롬프트/구조물 정의
|
||||||
|
|
||||||
|
**외부 데이터 소스**:
|
||||||
|
- AWS Open Terrain Tiles (`s3.amazonaws.com/elevation-tiles-prod/terrarium`)
|
||||||
|
— 글로벌 DEM, ~30m 정확도, API 키 불필요
|
||||||
|
- Google Satellite / ArcGIS World Imagery / Bing Aerial / Vworld
|
||||||
|
— 위성 타일 (사용자 선택)
|
||||||
|
- Google Vertex AI / Gemini API — 생성형 렌더링
|
||||||
|
- (옵션) Stability AI API — img2img 폴백
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 핵심 워크플로 (Step-by-Step)
|
||||||
|
|
||||||
|
### Step 0 — DXF 로드 & 레이어 분류
|
||||||
|
|
||||||
|
DXF 를 열면 자동으로 레이어를 분석해 **구조물 유형**(취수탑/제수변실/옹벽/수문/
|
||||||
|
지형 등고선/도로/하천 등)을 추정. `filename_classifier.py` 가 파일명 패턴(예:
|
||||||
|
"신설 취수탑.dxf") 으로 1차 후보를 제시하고, 사용자가 GUI 다이얼로그에서 최종
|
||||||
|
확정. 결과는 `structure_v1.yaml` 의 정의에 따라 빌더로 라우팅.
|
||||||
|
|
||||||
|
### Step 1 — TIN 생성 (DXF)
|
||||||
|
|
||||||
|
지형 레이어의 등고선 점·LINE/LWPOLYLINE 정점을 추출 → Delaunay 삼각화 →
|
||||||
|
**zero-basing** (원점을 도면 bbox 좌하단으로 평행이동, 부동소수점 정밀도 보존) →
|
||||||
|
PyVista PolyData 메시 생성.
|
||||||
|
|
||||||
|
**핵심 디테일**:
|
||||||
|
- 단위 자동 감지 → KATEC 좌표(EPSG:5187 등) 는 항상 m 로 강제 (`unit_override="m"`)
|
||||||
|
- DEM datum offset 사전 계산 → 이후 단계에서 **공통 offset 재사용**으로 seam Z 단차 0 보장
|
||||||
|
- DEM 격자도 사전 페치 → `_dem_elev_grid` / `_dem_grid_bounds` 로 캐시
|
||||||
|
|
||||||
|
### Step 1.5 — DEM 으로 TIN 확장
|
||||||
|
|
||||||
|
DXF 범위 밖이 절벽처럼 잘리는 문제 해결. AWS Terrarium 에서 **외곽 도넛 링**을
|
||||||
|
받아 TIN 과 이어 붙임.
|
||||||
|
|
||||||
|
**3-Zone 구조** (메모리화된 핵심 결정):
|
||||||
|
1. **Core** (`tin_core_bbox` 안): 원본 TIN 100% 보존. 사용자가 정밀 영역으로 지정.
|
||||||
|
2. **Transition** (core ~ bbox, `blend_width_m`): smoothstep `3t² − 2t³` 으로 TIN ↔ DEM Z 부드럽게 블렌드.
|
||||||
|
3. **DEM 외곽 링**: AWS DEM 으로 채움.
|
||||||
|
|
||||||
|
**Seam 처리 (다층 방어)**:
|
||||||
|
- **수직 datum 보정**: TIN 경계 근처 점들의 (TIN Z – DEM Z) 중앙값을 offset 으로 차감
|
||||||
|
- **공유 정점 weld**: TIN bbox 변 정점을 DEM 링 inner 경계로 강제 → 동일 XY 공유
|
||||||
|
- **smoothstep feather**: 경계 0 = TIN, feather_m = DEM 으로 C¹ 연속
|
||||||
|
- **벽 컷 (slope_ratio 기반)**: `slope_ratio = z_span / max_edge` 가 4.0 (≈76°) 초과 + Z스팬 30m + max_edge 5m 충족 시 "벽 삼각형" 으로 컷. **절대 Z 컷은 절대 사용하지 않음** (구멍이 뚫림 — 메모리화된 교훈)
|
||||||
|
- **링 Laplacian 1pass**: 톱니 fin 마지막 평활. 경계 정점은 pin
|
||||||
|
|
||||||
|
### Step 2 — 위성지도 결합 (Draping)
|
||||||
|
|
||||||
|
선택한 타일 서버에서 **확장된 bbox 전체**의 위성 이미지 다운로드 → 단일 큰
|
||||||
|
이미지로 합성 → `texture_map_to_plane` 으로 UV 매핑 → 텍스처 입힌 3D 메시.
|
||||||
|
|
||||||
|
**Seam-free 통합 렌더링** (2026-04-24 후속 수정):
|
||||||
|
- 이전: `tin_mesh` 와 `tin_extension_mesh` 가 두 개의 별도 PolyData → 경계에서
|
||||||
|
쉐이딩 불연속 (사각 선이 보이는 증상)
|
||||||
|
- 수정: `merge(merge_points=True, tolerance=0.01)` 로 공유 정점 weld → 위상적
|
||||||
|
단일 표면. `compute_normals(feature_angle=180)` 로 모든 edge smooth → seam
|
||||||
|
법선 평활. UV 좌표는 `_uv_mapping_params` 저장 후 merge 후 재적용.
|
||||||
|
|
||||||
|
### Step 3 — 제어맵 추출
|
||||||
|
|
||||||
|
PyVista 카메라 앵글로 오프스크린 캡처:
|
||||||
|
- `capture_textured.png` — 위성+DEM 합성된 control map
|
||||||
|
- `capture_depth.png` — 깊이 맵 (Eye-Dome Lighting 활성화)
|
||||||
|
|
||||||
|
이 2개 이미지가 Step 4 의 AI 렌더 입력.
|
||||||
|
|
||||||
|
### Step 4 — AI 렌더링 (구조 보존 모드)
|
||||||
|
|
||||||
|
`prompt_templates/prompt_v1.yaml` 기반으로 프롬프트 구성:
|
||||||
|
- **시간대 프리셋**: daytime / sunset / night / dawn / overcast
|
||||||
|
- **앙각 프리셋**: top_down / high_angle / oblique / low_angle
|
||||||
|
- **구조 보존 지시**: "maintain exact terrain shape, contours, and layout from the input image"
|
||||||
|
- **품질 향상**: "8K ultra sharp detail, professional drone photography quality"
|
||||||
|
- **네거티브**: "blurry, distorted, watermark, changed terrain layout, moved structures..."
|
||||||
|
|
||||||
|
**렌더 엔진 3종**:
|
||||||
|
1. **Gemini (Vertex AI)** — google-genai SDK, GCP 인증
|
||||||
|
2. **Gemini (API Key)** — google-genai SDK, AI Studio 키
|
||||||
|
3. **Stability AI (API)** — 3단계 폴백:
|
||||||
|
1. Conservative Upscale (원본 보존 최우선)
|
||||||
|
2. Creative Upscale (조금 더 창의적)
|
||||||
|
3. img2img 초저강도 (strength=0.2)
|
||||||
|
|
||||||
|
각 렌더 호출은 Harness 가 **결정론적 seed** + **prompt 버전** + **품질 점수**를
|
||||||
|
자동 기록 → 동일 입력에 동일 출력 보장.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 기술 컴포넌트 / 혁신 포인트
|
||||||
|
|
||||||
|
### 5.1 DEM 자동 통합 (`dem_extender.py`)
|
||||||
|
|
||||||
|
- **AWS Open Terrain Tiles** terrarium PNG 디코딩:
|
||||||
|
`elev_m = (R*256 + G + B/256) - 32768`
|
||||||
|
- 좌표계 변환: 투영 CRS(예: EPSG:5187) → WGS84 → 타일 좌표 (Web Mercator z/x/y)
|
||||||
|
- 캐시: SHA1(bbox+zoom) 으로 PNG 캐시 → 재실행 시 네트워크 0
|
||||||
|
- **로컬 GeoTIFF 우선**: `cache/dem/local.tif` 가 있으면 NGII 5m DEM 등 고정밀
|
||||||
|
데이터 우선 사용 (rasterio 필요)
|
||||||
|
|
||||||
|
### 5.2 Seam-free 메시 통합 (Render-fix 2026-04-24)
|
||||||
|
|
||||||
|
> 원래 메모리: "벽 이슈는 create_tin_from_dxf 부터 점검. 판정은 slope_ratio 만,
|
||||||
|
> 절대 Z 컷은 구멍 낸다."
|
||||||
|
|
||||||
|
**문제**: TIN+DEM 을 두 개 별도 PolyData 로 add_mesh 하면 normal 평균이 메시
|
||||||
|
경계를 못 넘어 사각 쉐이딩 선이 보임.
|
||||||
|
|
||||||
|
**해결 (1순위 + 2순위 동시)**:
|
||||||
|
```python
|
||||||
|
merged = target.merge(ext_mesh, merge_points=True, tolerance=0.01)
|
||||||
|
if not isinstance(merged, pv.PolyData):
|
||||||
|
merged = merged.extract_surface()
|
||||||
|
# 텍스처 모드: UV 재적용 (TCoords 가 merge 에서 소실되므로)
|
||||||
|
if textured and uv_params:
|
||||||
|
merged = merged.texture_map_to_plane(origin=..., point_u=..., point_v=...,
|
||||||
|
inplace=False)
|
||||||
|
merged.compute_normals(feature_angle=180.0, auto_orient_normals=True,
|
||||||
|
consistent_normals=True, inplace=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `merge_points=True` → 공유 경계 정점 **물리적 weld** (1순위 unified Delaunay 효과)
|
||||||
|
- `feature_angle=180` → 모든 edge smooth, 경계 normal 평균 (2순위 normal averaging 효과)
|
||||||
|
- 폴백: 실패 시 기존 2-mesh 렌더 경로 유지
|
||||||
|
|
||||||
|
### 5.3 구조물 자동 빌드 (`structure_*_3d_builder.py`)
|
||||||
|
|
||||||
|
| 유형 | 빌더 | 입력 |
|
||||||
|
|---|---|---|
|
||||||
|
| 취수탑 | `intake_tower_3d_builder.py` | DXF 평면도 → 외곽 폴리곤 + 높이 |
|
||||||
|
| 옹벽 | `retaining_wall_3d_builder.py` | DXF 단면도 → 단면 + 길이 |
|
||||||
|
| 수문 | `gate_3d_builder.py` | DXF 정면도 → 게이트 형상 + 슬라브 |
|
||||||
|
| 제수변실 | `valve_chamber_3d_builder.py` | DXF 평면도 → 박스 + 슬래브 + 점검구 |
|
||||||
|
|
||||||
|
**구조물 위치인식 → 굴착 → TIN수정 → 3D배치** (메모리화된 워크플로):
|
||||||
|
1. DXF 레이어에서 폴리곤 인식
|
||||||
|
2. PCA 로 frame 방향 결정 (`compute_orientation_from_points`)
|
||||||
|
3. TIN 에 **굴착 pad** 영역 평탄화 + smoothstep 전이 + Delaunay 재계산
|
||||||
|
4. 메시 4개 quad 코너에 fit (`fit_meshes_to_quad`)
|
||||||
|
5. 구조물 메시를 `embed_offset` 으로 약간 묻어 TIN 관통 방지
|
||||||
|
|
||||||
|
**메시 방향 보정** (메모리화된 디테일):
|
||||||
|
- CW(시계방향) picks + Y-flip + detail/TIN 상대회전 + PCA frame_angle
|
||||||
|
→ 앞뒤 뒤집힘 문제 해결
|
||||||
|
|
||||||
|
### 5.4 VLM 피드백 루프 (`structure_vlm_feedback.py`)
|
||||||
|
|
||||||
|
Gemini Vision 으로 **빌더 결과물의 시각적 정합성**을 자동 검증.
|
||||||
|
- Render 후 이미지 + 원본 DXF 평면도를 함께 모델에 보냄
|
||||||
|
- 차이점 자연어로 응답 받음
|
||||||
|
- 사용자에게 표시해 재시도 결정
|
||||||
|
|
||||||
|
### 5.5 Geo-Referencing 3단계 (`geo_referencing.py`)
|
||||||
|
|
||||||
|
> 메모리: "미리보기 → 위치설정(4점 매칭) → 확정"
|
||||||
|
|
||||||
|
1. **미리보기**: DXF bbox 를 위성지도에 임시 투영 (대략 위치)
|
||||||
|
2. **위치설정**: 사용자가 DXF 의 4 모서리를 실제 위성지도 위치와 매칭 클릭
|
||||||
|
3. **확정**: Affine transform 행렬 계산 → 투영 CRS(EPSG:5187 등) 자동 결정
|
||||||
|
|
||||||
|
### 5.6 Harness — 재현성 + 품질 + 이력
|
||||||
|
|
||||||
|
세 컴포넌트가 모든 AI 렌더 호출을 감싼다:
|
||||||
|
|
||||||
|
**SeedManager**: `SHA256(dxf_file_hash)[:4] → uint32 seed`. 같은 DXF → 같은 seed.
|
||||||
|
|
||||||
|
**QualityValidator**: 3개 게이트
|
||||||
|
1. 해상도 ≥ 1024px
|
||||||
|
2. Laplacian variance ≥ 50.0 (선명도)
|
||||||
|
3. HSV saturation 평균 ≥ 0.15 (단색 평면 출력 탐지)
|
||||||
|
|
||||||
|
**JobLogger** + SQLite `JobRecord` ORM:
|
||||||
|
```
|
||||||
|
id · dxf_path · dxf_hash · timestamp
|
||||||
|
seed · prompt_version · prompt_hash ← 재현성 3종
|
||||||
|
status (pending/running/done/failed)
|
||||||
|
output_path · quality_score · latency_ms
|
||||||
|
error_message
|
||||||
|
```
|
||||||
|
|
||||||
|
**PromptRegistry**: `prompt_v*.yaml` 버전 관리. 비교/저장/해시 역조회 API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. GUI 디자인 & 브랜딩
|
||||||
|
|
||||||
|
### 6.1 디자인 철학 (homepage_sample 참고)
|
||||||
|
|
||||||
|
- **Light 기본 테마** (사용자가 Dark 토글 가능)
|
||||||
|
- **카드형 시각 계층화**: 헤더 → SETTINGS → WORKFLOW → OPTIONS → 크레딧 푸터
|
||||||
|
- **1px 구분선**: `("#DEE2E6", "#3F3F3F")` 테마 쌍으로 자동 스위칭
|
||||||
|
- **uppercase 섹션 헤더**: 10pt bold muted gray
|
||||||
|
- **메인 액션 강조**: Step 4 버튼만 Saman 오렌지 `#E67E22`, 나머지는 blue 테마 파생
|
||||||
|
|
||||||
|
### 6.2 사이드바 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[ S-CANVAS Logo (logo_V2.png, 다크 bg 소프트 스트립) ]
|
||||||
|
Generative Design & Visualization Engine
|
||||||
|
─────────────────────────────────
|
||||||
|
SETTINGS
|
||||||
|
Satellite Source / Vworld Key (프리필 완료) / AI Engine /
|
||||||
|
GCP/API Key / Vertex Location / Project CRS
|
||||||
|
─────────────────────────────────
|
||||||
|
WORKFLOW
|
||||||
|
1. TIN 생성 (DXF) [filled blue]
|
||||||
|
🎯 TIN 이용 범위 (정밀 구역) [outlined]
|
||||||
|
1.5 DEM으로 TIN 확장 [outlined]
|
||||||
|
2. 위성지도 결합 [outlined]
|
||||||
|
3. 제어맵 추출 [outlined]
|
||||||
|
4. AI 렌더링 [filled ORANGE — primary CTA]
|
||||||
|
구조물 상세 3D 빌드 [filled green]
|
||||||
|
간단 치수 추가 (구) [muted]
|
||||||
|
🗔 3D 뷰 다시 열기 [filled dark]
|
||||||
|
─────────────────────────────────
|
||||||
|
OPTIONS
|
||||||
|
□ 와이어프레임 보기
|
||||||
|
뷰 버퍼 (%) [Step2/3]
|
||||||
|
□ 지형 확장 (DEM)
|
||||||
|
[Light ▼]
|
||||||
|
─────────────────────────────────
|
||||||
|
[ Saman CI (흰배경 알파 변환) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 메인 영역
|
||||||
|
|
||||||
|
- **map_frame**: tkintermapview, corner_radius=12, border_color 테마쌍
|
||||||
|
- **textbox**: Consolas 12pt 로그
|
||||||
|
- **status_bar**: `● READY` 인디케이터 + 한 줄 상태
|
||||||
|
|
||||||
|
### 6.4 인트로 스플래시 (`splash.py`)
|
||||||
|
|
||||||
|
- **트리거**: `__main__` 에서 `SCanvasApp()` 생성 직전
|
||||||
|
- **재생**: cv2 VideoCapture 로 logo_intro.mp4 (24fps, 8s, 1280×720) 프레임 디코드 → PIL → Tk Label
|
||||||
|
- **효과**:
|
||||||
|
- 알파 0→1 페이드인 400ms
|
||||||
|
- MP4 자체 애니메이션 재생
|
||||||
|
- 알파 1→0 페이드아웃 400ms 후 destroy
|
||||||
|
- frameless overrideredirect, topmost, 화면 중앙 배치
|
||||||
|
- 비디오 아래 44px 오렌지 italic tagline bar
|
||||||
|
- **안정성**:
|
||||||
|
- max_duration_s=12 safety cap
|
||||||
|
- 비디오 끝 자동 감지 → 페이드아웃
|
||||||
|
- 임시 tk.Tk → 완전 destroy → SCanvasApp() 의 새 ctk.CTk 충돌 없음
|
||||||
|
- 파일 없음/cv2 실패 → silent skip
|
||||||
|
|
||||||
|
### 6.5 자산 처리 헬퍼
|
||||||
|
|
||||||
|
```python
|
||||||
|
_load_image_strip_white_bg(path, threshold=240) # 흰 배경 → 알파 0 (SAMAN_CI)
|
||||||
|
_load_image_strip_dark_bg(path, v_low=30, v_high=80) # 어두운 bg 소프트 전이 (logo_V2)
|
||||||
|
```
|
||||||
|
|
||||||
|
logo_V2 결과: 36.7% 완전투명 / 49.5% 부분알파 / 13.8% 불투명 → halo 없는 부드러운 엣지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. AI 렌더링 파이프라인 상세
|
||||||
|
|
||||||
|
### 7.1 프롬프트 구성 (`prompt_v1.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
time_presets:
|
||||||
|
daytime: "bright daylight, clear blue sky, sharp shadows, vivid green vegetation"
|
||||||
|
sunset: "golden hour sunset, warm orange light, long dramatic shadows"
|
||||||
|
night: "nighttime aerial view, moonlight reflections, city lights in distance"
|
||||||
|
dawn: "early dawn, soft pink and purple sky, morning mist over valleys"
|
||||||
|
overcast: "overcast sky, diffused soft light, muted colors, atmospheric fog"
|
||||||
|
|
||||||
|
angle_presets:
|
||||||
|
top_down: "top-down overhead aerial view, directly above"
|
||||||
|
high_angle: "high-angle bird's-eye view, slightly tilted"
|
||||||
|
oblique: "oblique aerial perspective, 3/4 view showing terrain depth"
|
||||||
|
low_angle: "low-angle dramatic perspective, cinematic sweep"
|
||||||
|
|
||||||
|
structure_preservation:
|
||||||
|
- "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"
|
||||||
|
|
||||||
|
quality_enhancement:
|
||||||
|
- "photorealistic architectural visualization"
|
||||||
|
- "professional drone photography quality"
|
||||||
|
- "8K ultra sharp detail, high dynamic range"
|
||||||
|
- "realistic vegetation depth and canopy textures"
|
||||||
|
|
||||||
|
negative_prompt: |
|
||||||
|
blurry, low quality, distorted, watermark, text, logo,
|
||||||
|
cartoon, anime, illustration, painting, sketch,
|
||||||
|
oversaturated, underexposed, noisy, artifacts,
|
||||||
|
changed terrain layout, moved structures, wrong topology
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Stability AI 3단계 폴백
|
||||||
|
|
||||||
|
**1단계 — Conservative Upscale**
|
||||||
|
- mode: "conservative"
|
||||||
|
- creativity: UI 슬라이더 값 그대로 (기본 0.3)
|
||||||
|
- 목표: 원본 위성 텍스처 최대 보존
|
||||||
|
|
||||||
|
**2단계 — Creative Upscale**
|
||||||
|
- mode: "creative"
|
||||||
|
- creativity: min(strength, 0.35)
|
||||||
|
- 목표: 약간 더 사실적인 디테일 추가
|
||||||
|
|
||||||
|
**3단계 — img2img 초저강도**
|
||||||
|
- strength: min(strength, 0.2)
|
||||||
|
- 모델: sd3.5-large
|
||||||
|
- 목표: 위 2개 실패 시 백업
|
||||||
|
|
||||||
|
각 단계 실패 → 다음 단계 자동 폴백. 3개 모두 실패 시 `fail_job(error="모든 API 방법 실패")`.
|
||||||
|
|
||||||
|
### 7.3 Gemini 경로 (`gemini_renderer.py`)
|
||||||
|
|
||||||
|
- Vertex AI: google-genai SDK, GCP Project ID + gcp-key.json
|
||||||
|
- API Key: aistudio.google.com 발급 키
|
||||||
|
- 동일 인터페이스로 호출 → 인증만 다름
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 좌표계 / 단위 / 정밀도
|
||||||
|
|
||||||
|
### 8.1 지원 CRS (사용자 선택)
|
||||||
|
|
||||||
|
| EPSG | 이름 | 사용 영역 |
|
||||||
|
|---|---|---|
|
||||||
|
| 5187 | Korea 2000 / Central Belt 2010 | 한국 중부(서울·경기·충청) |
|
||||||
|
| 5186 | Korea 2000 / Central Belt | 한국 중부 (구) |
|
||||||
|
| 5185 | Korea 2000 / West Belt 2010 | 한국 서부 |
|
||||||
|
| 5181 | Korea 2000 / Unified CS | 통합 좌표계 |
|
||||||
|
| 3857 | Web Mercator | 글로벌 (위성 타일 호환) |
|
||||||
|
|
||||||
|
### 8.2 단위 강제 (메모리화된 결정)
|
||||||
|
|
||||||
|
> "extract_tin_shapes 는 unit_override='m' 고정 (자동감지가 KATEC 좌표를 mm 로 오판)"
|
||||||
|
|
||||||
|
DXF `$INSUNITS` 헤더가 부정확한 경우가 많아 **항상 m 로 강제**. KATEC 류
|
||||||
|
대형 좌표값(예: x=200000, y=550000) 을 mm 로 오판하면 단위가 1000배 어긋남.
|
||||||
|
|
||||||
|
### 8.3 Zero-Basing
|
||||||
|
|
||||||
|
큰 절대좌표(예: EPSG:5187 의 200,000m+)는 float32 정밀도에서 sub-meter 오차
|
||||||
|
발생 → **bbox 좌하단을 origin 으로 평행이동** → 모든 메시는 zero-based.
|
||||||
|
구조물 빌더는 절대좌표(`structure_v1.yaml` 의 origin) 와 zero-based 사이를
|
||||||
|
명시적으로 변환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 메모리화된 핵심 결정 사항 (요약)
|
||||||
|
|
||||||
|
> 이 결정들은 과거 디버깅에서 얻은 교훈으로, 코드 곳곳의 분기 판단 기준이 됨.
|
||||||
|
|
||||||
|
1. **Structure Placement Workflow**: 위치인식 → 굴착 → TIN수정 → 3D배치 4단계
|
||||||
|
2. **Geo-Referencing**: 미리보기 → 4점 매칭 → 확정 3단계
|
||||||
|
3. **TIN Shape Unit**: extract_tin_shapes 는 항상 `unit_override="m"` (자동감지 금지)
|
||||||
|
4. **Excavation**: 폴리곤 평탄 pad + smoothstep 전이 + Delaunay 재계산.
|
||||||
|
구조물은 `embed_offset` 으로 TIN 관통 방지
|
||||||
|
5. **Mesh Orientation**: CW picks + Y-flip + 상대회전 + PCA frame_angle 로
|
||||||
|
앞뒤 뒤집힘 해결
|
||||||
|
6. **TIN/DEM 벽 근본 접근**: 벽 이슈는 `create_tin_from_dxf` 부터 점검. 판정은
|
||||||
|
`slope_ratio (z_span / max_edge)` 만 사용. **절대 Z 컷은 구멍 낸다**
|
||||||
|
7. **TIN 3-Zone**: core(원본 TIN) / transition(smoothstep) / DEM 확장.
|
||||||
|
`tin_core_bbox` · `blend_width_m` 로 제어
|
||||||
|
8. **CHANGELOG 의무**: 모든 수정은 `CHANGELOG.md` 에 즉시 기록 (역순, 날짜/파일/사유/diff 요지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 최근 주요 수정 이력 (2026-04-24 ~)
|
||||||
|
|
||||||
|
### [render-fix] DEM 확장 경계 사각 선 제거 (후속)
|
||||||
|
- 두 개의 별도 PolyData → seam shading discontinuity 발생
|
||||||
|
- `merge(merge_points=True, tolerance=0.01)` + `compute_normals(feature_angle=180)`
|
||||||
|
로 1순위(구조 통합) + 2순위(법선 평활) 동시 달성
|
||||||
|
- 텍스처 모드에서 TCoords 소실 → `_uv_mapping_params` 저장 후 merge 후 재적용
|
||||||
|
|
||||||
|
### [rebrand] EG-VIEW → S-CANVAS 전면 리네이밍
|
||||||
|
- 클래스 `EGViewApp → SCanvasApp`
|
||||||
|
- 파일 `egview_maker.py → scanvas_maker.py`
|
||||||
|
- DB/로그 `egview_*.db/log → scanvas_*.db/log`
|
||||||
|
- 144개 문자열 occurrence (43 파일) 일괄 교체
|
||||||
|
|
||||||
|
### [ui-redesign] Light 기본 테마 + 사이드바 카드형
|
||||||
|
- `set_appearance_mode("light")`
|
||||||
|
- 모든 하드코딩 색상을 `(light, dark)` 튜플 쌍으로 → 자동 테마 스위칭
|
||||||
|
- SETTINGS / WORKFLOW / OPTIONS 섹션 헤더 + 1px 구분선
|
||||||
|
- SAMAN_CI 흰 배경 68.5% 알파 변환
|
||||||
|
|
||||||
|
### [feature] Vworld 키 프리필 + logo_V2 + 인트로 스플래시
|
||||||
|
- Vworld API 키 하드코딩 (사용자 제공)
|
||||||
|
- 로고 `Logo.png → logo_V2.png` (다크 bg 소프트 스트립)
|
||||||
|
- 신규 `splash.py` — cv2 기반 8초 MP4 스플래시 + 페이드 인/아웃
|
||||||
|
- `__main__` 에서 `show_intro_splash()` → `SCanvasApp()` 순차 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 차별화 포인트 (발표 슬라이드용 강조)
|
||||||
|
|
||||||
|
### 11.1 **사실 기반 vs 환각 기반**
|
||||||
|
경쟁 도구가 AI 의 상상으로 지형을 만들 때, S-CANVAS 는 **실측 DEM + 실제 위성영상**
|
||||||
|
을 베이스로 깔고 AI 는 **사실감 향상만** 담당. → 발주처에 실제와 다른 지형을
|
||||||
|
제출하는 사고 원천 차단.
|
||||||
|
|
||||||
|
### 11.2 **Seam-free 통합 메시**
|
||||||
|
TIN(설계 도면) + DEM(실제 지형) + 위성텍스처 + AI 렌더가 모두 **하나의 연속
|
||||||
|
표면**으로 합쳐짐. 사용자가 어디까지가 도면이고 어디부터가 실제 지형인지 구분
|
||||||
|
못 하도록.
|
||||||
|
|
||||||
|
### 11.3 **결정론적 재현성** (Harness)
|
||||||
|
DXF 해시 → seed → AI 렌더. 같은 입력에 항상 같은 출력. 발주처에 제출했던 그림과
|
||||||
|
1주일 뒤 회의에서 띄울 그림이 픽셀 단위로 동일.
|
||||||
|
|
||||||
|
### 11.4 **모듈러 구조물 빌더**
|
||||||
|
취수탑·제수변실·옹벽·수문 4종 즉시 지원. `structure_v1.yaml` 에 정의 추가하면
|
||||||
|
새 유형 확장 가능. DXF 단면도 → 자동 치수 파싱 → 3D 배치까지 클릭 1번.
|
||||||
|
|
||||||
|
### 11.5 **공개 데이터 + 무료 티어**
|
||||||
|
AWS Open Terrain Tiles · Google Satellite · ArcGIS World Imagery 모두 무료.
|
||||||
|
유일한 유료 항목은 AI 렌더(Gemini/Stability) — 그것도 사용자 본인 키 사용.
|
||||||
|
|
||||||
|
### 11.6 **자동 품질 게이트**
|
||||||
|
모든 렌더 결과를 OpenCV 로 즉시 검증. 흐릿하거나 단색 출력은 자동 PASS/FAIL
|
||||||
|
표시 → 사용자가 100장을 일일이 확인할 필요 없음.
|
||||||
|
|
||||||
|
### 11.7 **Saman Corp 자체 개발**
|
||||||
|
국내 토목·건설 도메인 지식이 코드 곳곳에 — KATEC 단위 처리, 한국 EPSG 코드
|
||||||
|
프리셋, Vworld 타일 지원, 한글 UI·로그 등.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 기술 스택 요약 (한눈에)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Frontend / GUI]
|
||||||
|
└─ CustomTkinter (Tk 기반 모던 위젯) + tkintermapview
|
||||||
|
|
||||||
|
[3D Engine]
|
||||||
|
└─ PyVista + VTK (메시 처리·렌더링)
|
||||||
|
|
||||||
|
[Geospatial]
|
||||||
|
├─ ezdxf (DXF 파싱)
|
||||||
|
├─ pyproj (좌표 변환)
|
||||||
|
├─ scipy.spatial.Delaunay (TIN)
|
||||||
|
└─ AWS Open Terrain Tiles (DEM)
|
||||||
|
|
||||||
|
[Image / Video]
|
||||||
|
├─ Pillow (PIL)
|
||||||
|
├─ OpenCV (cv2)
|
||||||
|
└─ tkintermapview (위성 타일)
|
||||||
|
|
||||||
|
[AI Rendering]
|
||||||
|
├─ Google google-genai (Vertex AI / API Key)
|
||||||
|
└─ Stability AI REST API
|
||||||
|
|
||||||
|
[Persistence]
|
||||||
|
├─ SQLAlchemy + SQLite (JobRecord)
|
||||||
|
├─ structlog (구조화 로깅)
|
||||||
|
└─ PyYAML (프롬프트/구조물 정의)
|
||||||
|
|
||||||
|
[Build / Distribution] (계획)
|
||||||
|
└─ PyInstaller (단일 .exe 또는 onedir 배포)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 배포 / 패키징 계획
|
||||||
|
|
||||||
|
**PyInstaller** 로 Windows .exe 빌드 예정. 주요 챌린지:
|
||||||
|
- PyVista + VTK 200MB+ 바이너리 → `--collect-all pyvista vtkmodules` 필요
|
||||||
|
- pyproj PROJ data → 명시적 hook
|
||||||
|
- google-genai → hidden imports
|
||||||
|
- 런타임 쓰기 경로(DB·로그·캐시) → `%LOCALAPPDATA%\S-CANVAS\` 분리 (onedir/onefile 양쪽)
|
||||||
|
- 자산 경로 (`Design/`, `prompt_templates/`, `structure_types/`) → `sys._MEIPASS` 핸들링
|
||||||
|
|
||||||
|
배포 형태 (예정): `dist/scanvas_maker/` 폴더 통째로 zip → 약 150-200MB 압축. 사용자는
|
||||||
|
.exe 더블클릭으로 실행, 본인 GCP 키 또는 Stability API 키만 환경변수/UI 입력.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 향후 로드맵
|
||||||
|
|
||||||
|
1. **다국어 (i18n)**: 한국어/영어 토글
|
||||||
|
2. **추가 구조물 유형**: 배수문·교량·터널·송수관 등
|
||||||
|
3. **Linux/macOS 지원**: 현재 Windows 전용
|
||||||
|
4. **클라우드 렌더 큐**: 다수 DXF 일괄 처리 후 결과 한꺼번에 ZIP
|
||||||
|
5. **VR/AR 출력**: glTF 익스포트 → Unity/Unreal 연동
|
||||||
|
6. **변경 추적**: 같은 DXF 의 V1/V2 자동 비교 → 변경 영역 하이라이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 핵심 메시지 (발표 마무리용)
|
||||||
|
|
||||||
|
> **S-CANVAS 는 토목·건설 도면을, "수일짜리 모델링 작업" 에서 "클릭 4번의 자동
|
||||||
|
> 파이프라인" 으로 압축한다. 사실 기반(DEM·위성) 위에서 AI 가 사실감만 더하므로
|
||||||
|
> 환각 위험 없이, 결정론적 재현성을 보장하면서, 발주처·주민설명회 발표용 조감도를
|
||||||
|
> 분 단위로 양산할 수 있다. — Saman Corp.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A. 진입점 / 실행 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scanvas_maker.py
|
||||||
|
```
|
||||||
|
|
||||||
|
진행 순서:
|
||||||
|
1. `splash.py` 가 logo_intro.mp4 8초 재생 (페이드 인/아웃)
|
||||||
|
2. `SCanvasApp(ctk.CTk)` 메인 창 기동
|
||||||
|
3. (사이드바) DXF 파일 선택 → CRS 확인 → Step 1~4 순차 실행
|
||||||
|
4. (옵션) 구조물 상세 3D 빌드 / 와이어프레임 / DEM 확장
|
||||||
|
5. AI 렌더 결과는 `rendered_birdseye.png` (와 SQLite 이력)
|
||||||
|
|
||||||
|
## 부록 B. 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\2026\00_EGVIEW2\
|
||||||
|
├── scanvas_maker.py # 메인 진입점
|
||||||
|
├── splash.py # 인트로 스플래시
|
||||||
|
├── dem_extender.py
|
||||||
|
├── geo_referencing.py
|
||||||
|
├── structure_placement.py
|
||||||
|
├── structure_templates.py
|
||||||
|
├── intake_tower_parser.py + _3d_builder.py
|
||||||
|
├── retaining_wall_parser.py + _3d_builder.py
|
||||||
|
├── gate_parser.py + _3d_builder.py
|
||||||
|
├── valve_chamber_parser.py + _3d_builder.py
|
||||||
|
├── detail_parser.py
|
||||||
|
├── filename_classifier.py
|
||||||
|
├── polygon_reconstructor.py
|
||||||
|
├── dxf_geometry.py
|
||||||
|
├── tile_downloader.py
|
||||||
|
├── view_detector.py
|
||||||
|
├── view_reconstructor.py
|
||||||
|
├── optional_detector.py
|
||||||
|
├── gemini_renderer.py
|
||||||
|
├── structure_vlm_feedback.py
|
||||||
|
│
|
||||||
|
├── harness/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── seed_manager.py
|
||||||
|
│ ├── quality_validator.py
|
||||||
|
│ ├── prompt_registry.py
|
||||||
|
│ └── logger.py
|
||||||
|
│
|
||||||
|
├── prompt_templates/
|
||||||
|
│ └── prompt_v1.yaml
|
||||||
|
│
|
||||||
|
├── structure_types/
|
||||||
|
│ └── structure_v1.yaml
|
||||||
|
│
|
||||||
|
├── Design/
|
||||||
|
│ ├── Logo.png
|
||||||
|
│ ├── logo_V2.png (현재 사용)
|
||||||
|
│ ├── SAMAN_CI.gif
|
||||||
|
│ ├── logo_intro.mp4 (8s, 24fps, 1280×720)
|
||||||
|
│ ├── homepage_sample.png
|
||||||
|
│ └── page_sample.png
|
||||||
|
│
|
||||||
|
├── cache/dem/ (런타임 DEM 캐시)
|
||||||
|
├── scanvas_jobs.db (SQLite 이력)
|
||||||
|
├── scanvas_harness.log (structlog)
|
||||||
|
├── scanvas_diagnostic.log (Step 1 진단)
|
||||||
|
├── CHANGELOG.md (수정 이력 역순)
|
||||||
|
└── Build_log.txt (사용자 원본 요청 로그)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 부록 C. 핵심 메트릭 (발표 슬라이드 숫자용)
|
||||||
|
|
||||||
|
- 메인 모듈: 6300+ LOC (`scanvas_maker.py`)
|
||||||
|
- 지원 구조물 유형: 4종 (취수탑·제수변실·옹벽·수문) + 확장 가능
|
||||||
|
- 지원 좌표계: 5종 EPSG (한국 4 + Web Mercator 1)
|
||||||
|
- 지원 위성 타일 서버: 9종 (Google·ArcGIS·Bing·OSM·OpenTopo·Vworld 3종 등)
|
||||||
|
- 지원 AI 렌더 엔진: 3종 (Gemini Vertex·Gemini API·Stability)
|
||||||
|
- DEM 자동 페치: AWS Terrarium 글로벌, ~30m 정확도
|
||||||
|
- 인트로 스플래시: 8.0초 (24fps × 192 frames @ 1280×720)
|
||||||
|
- 메시 통합 weld 톨러런스: 0.01m (1cm)
|
||||||
|
- TIN 3-Zone 블렌드: smoothstep `3t² − 2t³`
|
||||||
|
- 벽 컷 임계: slope_ratio > 4.0 (≈76°) AND z_span > 30m AND e_max > 5m
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*본 문서는 NotebookLM 의 단일 소스로 사용 가능하도록 자기완결형으로 작성되었습니다.
|
||||||
|
업로드 후 "Audio Overview", "마인드맵", "발표 슬라이드 초안" 자동 생성을 권장합니다.*
|
||||||
1581540
SAMPLE_CAD/사연댐 전체계획 평면도_V6.dxf
Normal file
1446740
SAMPLE_CAD/수문_1.dxf
Normal file
356110
SAMPLE_CAD/수문_2.dxf
Normal file
67
_build_icon.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Pre-generate scanvas_S.ico from Design/logo_V2.png for PyInstaller spec.
|
||||||
|
|
||||||
|
Called by build.bat before PyInstaller runs. Standalone so encoding issues in
|
||||||
|
.bat don't break it. Mirrors SCanvasApp._setup_window_icon logic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from scipy import ndimage as nd
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = Path(__file__).resolve().parent
|
||||||
|
src = root / "Design" / "logo_V2.png"
|
||||||
|
dst_dir = root / "cache" / "icons"
|
||||||
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ico = dst_dir / "scanvas_S.ico"
|
||||||
|
|
||||||
|
if not src.exists():
|
||||||
|
print(f"[icon] source not found: {src}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
pil = Image.open(src).convert("RGBA")
|
||||||
|
arr = np.asarray(pil).copy()
|
||||||
|
v = np.maximum.reduce([arr[..., 0], arr[..., 1], arr[..., 2]])
|
||||||
|
arr[..., 3] = np.where(v < 90, 0, arr[..., 3])
|
||||||
|
|
||||||
|
# Crop left ~32% to isolate the 'S' letter
|
||||||
|
crop_w = min(arr.shape[1], 850)
|
||||||
|
carr = arr[:, :crop_w, :]
|
||||||
|
mask = carr[..., 3] > 100
|
||||||
|
labeled, ncomp = nd.label(mask)
|
||||||
|
if ncomp == 0:
|
||||||
|
print("[icon] no foreground content found")
|
||||||
|
return 1
|
||||||
|
sizes = nd.sum(mask, labeled, range(1, ncomp + 1))
|
||||||
|
max_size = float(sizes.max())
|
||||||
|
keep = [i + 1 for i, s in enumerate(sizes) if s >= max_size * 0.1]
|
||||||
|
keep_mask = np.isin(labeled, keep)
|
||||||
|
cc = carr.copy()
|
||||||
|
cc[..., 3] = np.where(keep_mask, carr[..., 3], 0)
|
||||||
|
|
||||||
|
ys, xs = np.where(keep_mask)
|
||||||
|
cropped = Image.fromarray(
|
||||||
|
cc[int(ys.min()):int(ys.max()) + 1, int(xs.min()):int(xs.max()) + 1, :],
|
||||||
|
"RGBA",
|
||||||
|
)
|
||||||
|
cw, ch = cropped.size
|
||||||
|
side = max(cw, ch)
|
||||||
|
pad = max(int(side * 0.04), 4)
|
||||||
|
sp = side + 2 * pad
|
||||||
|
sq = Image.new("RGBA", (sp, sp), (0, 0, 0, 0))
|
||||||
|
sq.paste(cropped, ((sp - cw) // 2, (sp - ch) // 2), cropped)
|
||||||
|
sq.save(
|
||||||
|
ico, format="ICO",
|
||||||
|
sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||||
|
)
|
||||||
|
print(f"[icon] {ico} ({ico.stat().st_size} bytes)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
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()
|
||||||
61
build.bat
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
@echo off
|
||||||
|
REM ============================================================
|
||||||
|
REM S-CANVAS distribution build (PyInstaller onedir)
|
||||||
|
REM
|
||||||
|
REM Run: build.bat
|
||||||
|
REM
|
||||||
|
REM Output: dist\S-CANVAS\S-CANVAS.exe (zip the folder to ship)
|
||||||
|
REM ============================================================
|
||||||
|
|
||||||
|
setlocal enableextensions enabledelayedexpansion
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === [1/4] Check PyInstaller ===
|
||||||
|
python -m PyInstaller --version >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo PyInstaller not installed -- installing now
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install pyinstaller
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Failed to install PyInstaller. Try: pip install pyinstaller
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
python -m PyInstaller --version
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === [2/4] Generate window icon (scanvas_S.ico) ===
|
||||||
|
python _build_icon.py
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [WARN] Icon generation failed -- continuing with default PyInstaller icon
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === [3/4] Clean previous build artifacts ===
|
||||||
|
if exist build rmdir /s /q build
|
||||||
|
if exist "dist\S-CANVAS" rmdir /s /q "dist\S-CANVAS"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === [4/4] Run PyInstaller (10-15 min) ===
|
||||||
|
python -m PyInstaller --clean --noconfirm scanvas_maker.spec
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] PyInstaller build failed. See logs above.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === DONE ===
|
||||||
|
if exist "dist\S-CANVAS\S-CANVAS.exe" (
|
||||||
|
echo Entry point: dist\S-CANVAS\S-CANVAS.exe
|
||||||
|
echo Distribution: zip the dist\S-CANVAS\ folder
|
||||||
|
echo User data: %%LOCALAPPDATA%%\S-CANVAS\
|
||||||
|
echo db, logs, cache live here at runtime.
|
||||||
|
) else (
|
||||||
|
echo [WARN] dist\S-CANVAS\S-CANVAS.exe not found. Check build output.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
endlocal
|
||||||
867
dem_extender.py
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
"""실제 지형 DEM으로 DXF TIN 외곽을 확장하는 유틸리티.
|
||||||
|
|
||||||
|
동기:
|
||||||
|
사용자가 제공한 DXF 평면도의 등고선 범위를 벗어나면 3D 장면에서 지형이
|
||||||
|
절벽처럼 끊긴다. 조감도에서 이 구역을 AI 프롬프트로 메우면 사실 기반이
|
||||||
|
아니라 AI 상상이라 결과가 가변적이다. 이 모듈은 DXF 범위 바깥에 대해
|
||||||
|
실제 공개 DEM(AWS Open Terrain Tiles, terrarium PNG 포맷)을 받아 외곽 링
|
||||||
|
메시를 만들고, 기존 TIN과 Z-연속성을 맞춰 이어 붙인다.
|
||||||
|
|
||||||
|
기본 소스:
|
||||||
|
AWS Open Terrain Tiles (terrarium): API 키 없음, 글로벌, ~30m 수직 정확도.
|
||||||
|
URL: https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png
|
||||||
|
디코딩: elev_m = (R*256 + G + B/256) - 32768
|
||||||
|
|
||||||
|
옵션 소스:
|
||||||
|
- 로컬 GeoTIFF: `cache/dem/local.tif` 가 존재하고 rasterio가 설치되어
|
||||||
|
있으면 우선 사용. 한국이면 NGII 5m DEM을 여기 놓아두면 정확도 상승.
|
||||||
|
|
||||||
|
좌표계:
|
||||||
|
- TIN/메시 프레임: 투영 CRS(예: EPSG:5187) - origin (zero-basing)
|
||||||
|
- DEM fetch: WGS84 (EPSG:4326)
|
||||||
|
- 본 모듈은 투영 CRS와 origin만 받으면 내부에서 pyproj로 변환 처리.
|
||||||
|
|
||||||
|
Seam 처리:
|
||||||
|
1. 수직 datum 자동 보정: inner 경계 근처 TIN Z와 DEM Z의 평균 차이로
|
||||||
|
global offset을 잡아 DEM 전체 Z에서 차감.
|
||||||
|
2. Feathering: inner 경계로부터 feather_m 이내 도넛 점은 가장 가까운
|
||||||
|
TIN 정점의 Z와 보정된 DEM Z를 선형 블렌드.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyproj
|
||||||
|
import pyvista as pv
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
from scipy.spatial import Delaunay, cKDTree
|
||||||
|
|
||||||
|
|
||||||
|
# --- AWS Open Terrain Tiles (terrarium) ---------------------------------
|
||||||
|
_AWS_TERRARIUM_URL = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (S-CANVAS DEM extender)",
|
||||||
|
"Accept": "image/png,image/*;q=0.8",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DemExtendResult:
|
||||||
|
mesh: pv.PolyData
|
||||||
|
n_points: int
|
||||||
|
n_faces: int
|
||||||
|
buffer_m: float
|
||||||
|
source: str
|
||||||
|
vertical_offset_m: float = 0.0
|
||||||
|
zoom: int = 0
|
||||||
|
grid_step_m: float = 0.0
|
||||||
|
feather_m: float = 0.0
|
||||||
|
info: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
||||||
|
lat_rad = math.radians(lat)
|
||||||
|
n = 2 ** zoom
|
||||||
|
x = int((lon + 180.0) / 360.0 * n)
|
||||||
|
y = int((1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_to_latlon(x: int, y: int, zoom: int) -> tuple[float, float]:
|
||||||
|
n = 2 ** zoom
|
||||||
|
lon = x / n * 360.0 - 180.0
|
||||||
|
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
|
||||||
|
return math.degrees(lat_rad), lon
|
||||||
|
|
||||||
|
|
||||||
|
def _terrarium_decode(rgb: np.ndarray) -> np.ndarray:
|
||||||
|
"""(H,W,3) uint8 RGB → (H,W) float elevation in meters."""
|
||||||
|
r = rgb[..., 0].astype(np.float64)
|
||||||
|
g = rgb[..., 1].astype(np.float64)
|
||||||
|
b = rgb[..., 2].astype(np.float64)
|
||||||
|
return (r * 256.0 + g + b / 256.0) - 32768.0
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_cache_key(min_lat: float, min_lon: float, max_lat: float, max_lon: float, zoom: int) -> str:
|
||||||
|
raw = f"{min_lat:.6f}_{min_lon:.6f}_{max_lat:.6f}_{max_lon:.6f}_z{zoom}"
|
||||||
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_terrarium_grid(min_lat: float, min_lon: float, max_lat: float, max_lon: float,
|
||||||
|
zoom: int = 13,
|
||||||
|
cache_dir: str | Path = "cache/dem",
|
||||||
|
log_fn: Callable[[str], None] = print,
|
||||||
|
timeout: float = 15.0) -> tuple[np.ndarray, tuple[float, float, float, float]]:
|
||||||
|
"""AWS terrarium 타일을 BBOX 범위로 받아 합쳐 (elev_grid, bounds) 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
elev_grid: (H,W) float64, 위→아래로 lat 감소, 좌→우로 lon 증가.
|
||||||
|
bounds: (grid_lat_max, grid_lon_min, grid_lat_min, grid_lon_max) — 타일
|
||||||
|
경계에 정렬된 실제 격자 범위.
|
||||||
|
"""
|
||||||
|
cache_dir = Path(cache_dir)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_key = _bbox_cache_key(min_lat, min_lon, max_lat, max_lon, zoom)
|
||||||
|
cache_path = cache_dir / f"terrarium_{cache_key}.png"
|
||||||
|
bounds_path = cache_dir / f"terrarium_{cache_key}.bounds.txt"
|
||||||
|
|
||||||
|
x_min, y_min = _latlon_to_tile(max_lat, min_lon, zoom)
|
||||||
|
x_max, y_max = _latlon_to_tile(min_lat, max_lon, zoom)
|
||||||
|
cols = x_max - x_min + 1
|
||||||
|
rows = y_max - y_min + 1
|
||||||
|
|
||||||
|
grid_lat_max, grid_lon_min = _tile_to_latlon(x_min, y_min, zoom)
|
||||||
|
grid_lat_min, grid_lon_max = _tile_to_latlon(x_max + 1, y_max + 1, zoom)
|
||||||
|
bounds = (grid_lat_max, grid_lon_min, grid_lat_min, grid_lon_max)
|
||||||
|
|
||||||
|
if cache_path.exists() and bounds_path.exists():
|
||||||
|
try:
|
||||||
|
img = Image.open(cache_path).convert("RGB")
|
||||||
|
arr = np.asarray(img, dtype=np.uint8)
|
||||||
|
elev = _terrarium_decode(arr)
|
||||||
|
log_fn(f" [DEM] 캐시 사용: {cache_path.name} ({arr.shape[1]}x{arr.shape[0]}px, {cols}x{rows} tiles z{zoom})")
|
||||||
|
return elev, bounds
|
||||||
|
except Exception as e:
|
||||||
|
log_fn(f" [DEM] 캐시 로드 실패 ({e}), 재다운로드합니다.")
|
||||||
|
|
||||||
|
tile_size = 256
|
||||||
|
merged = Image.new("RGB", (cols * tile_size, rows * tile_size), (128, 0, 0))
|
||||||
|
ok = 0
|
||||||
|
fail = 0
|
||||||
|
for ty in range(y_min, y_max + 1):
|
||||||
|
for tx in range(x_min, x_max + 1):
|
||||||
|
url = _AWS_TERRARIUM_URL.format(z=zoom, x=tx, y=ty)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=_HEADERS, timeout=timeout)
|
||||||
|
if resp.status_code == 200 and len(resp.content) > 200:
|
||||||
|
tile_img = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||||
|
merged.paste(tile_img, ((tx - x_min) * tile_size, (ty - y_min) * tile_size))
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
log_fn(f" [DEM] 타일: 성공 {ok}장, 실패 {fail}장 (z{zoom}, {cols}x{rows})")
|
||||||
|
if ok == 0:
|
||||||
|
raise RuntimeError("AWS terrarium 타일을 한 장도 받지 못했습니다. 네트워크를 확인하세요.")
|
||||||
|
|
||||||
|
merged.save(cache_path)
|
||||||
|
bounds_path.write_text(
|
||||||
|
f"{grid_lat_max} {grid_lon_min} {grid_lat_min} {grid_lon_max} z{zoom}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
arr = np.asarray(merged, dtype=np.uint8)
|
||||||
|
elev = _terrarium_decode(arr)
|
||||||
|
return elev, bounds
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_grid_bilinear(elev: np.ndarray,
|
||||||
|
bounds: tuple[float, float, float, float],
|
||||||
|
lats: np.ndarray, lons: np.ndarray) -> np.ndarray:
|
||||||
|
"""(H,W) 격자에서 (lats, lons) 점들의 Z를 bilinear 샘플링."""
|
||||||
|
grid_lat_max, grid_lon_min, grid_lat_min, grid_lon_max = bounds
|
||||||
|
h, w = elev.shape
|
||||||
|
fx = (lons - grid_lon_min) / (grid_lon_max - grid_lon_min) * (w - 1)
|
||||||
|
fy = (grid_lat_max - lats) / (grid_lat_max - grid_lat_min) * (h - 1)
|
||||||
|
fx = np.clip(fx, 0.0, w - 1 - 1e-9)
|
||||||
|
fy = np.clip(fy, 0.0, h - 1 - 1e-9)
|
||||||
|
x0 = np.floor(fx).astype(np.int64); x1 = x0 + 1
|
||||||
|
y0 = np.floor(fy).astype(np.int64); y1 = y0 + 1
|
||||||
|
x1 = np.clip(x1, 0, w - 1)
|
||||||
|
y1 = np.clip(y1, 0, h - 1)
|
||||||
|
tx = fx - x0
|
||||||
|
ty = fy - y0
|
||||||
|
v00 = elev[y0, x0]; v01 = elev[y0, x1]
|
||||||
|
v10 = elev[y1, x0]; v11 = elev[y1, x1]
|
||||||
|
v0 = v00 * (1 - tx) + v01 * tx
|
||||||
|
v1 = v10 * (1 - tx) + v11 * tx
|
||||||
|
return v0 * (1 - ty) + v1 * ty
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
tif = cache_dir / "local.tif"
|
||||||
|
if not tif.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import rasterio # type: ignore
|
||||||
|
from rasterio.warp import transform_bounds # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with rasterio.open(tif) as ds:
|
||||||
|
arr = ds.read(1).astype(np.float64)
|
||||||
|
# nodata 처리
|
||||||
|
nodata = ds.nodata
|
||||||
|
if nodata is not None:
|
||||||
|
arr = np.where(arr == nodata, np.nan, arr)
|
||||||
|
src_bounds = ds.bounds
|
||||||
|
src_crs = ds.crs
|
||||||
|
lon_min, lat_min, lon_max, lat_max = transform_bounds(
|
||||||
|
src_crs, "EPSG:4326",
|
||||||
|
src_bounds.left, src_bounds.bottom, src_bounds.right, src_bounds.top,
|
||||||
|
densify_pts=21,
|
||||||
|
)
|
||||||
|
# rasterio는 top-to-bottom: 첫 행이 lat_max
|
||||||
|
bounds = (lat_max, lon_min, lat_min, lon_max)
|
||||||
|
return arr, bounds, "local_geotiff"
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_ring_points(inner_xyxy: tuple[float, float, float, float],
|
||||||
|
outer_xyxy: tuple[float, float, float, float],
|
||||||
|
grid_step: float) -> np.ndarray:
|
||||||
|
"""도넛(ring) 영역을 규칙 격자로 채운 XY 좌표 반환. inner bbox 내부는 제외.
|
||||||
|
|
||||||
|
Legacy(사각 bbox 경계): hull을 못 얻는 경우의 폴백.
|
||||||
|
"""
|
||||||
|
ix0, iy0, ix1, iy1 = inner_xyxy
|
||||||
|
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)
|
||||||
|
gx, gy = np.meshgrid(xs, ys)
|
||||||
|
pts = np.column_stack([gx.ravel(), gy.ravel()])
|
||||||
|
inside = (pts[:, 0] > ix0) & (pts[:, 0] < ix1) & (pts[:, 1] > iy0) & (pts[:, 1] < iy1)
|
||||||
|
ring_pts = pts[~inside]
|
||||||
|
# inner 경계 자체에 정확히 놓이는 점들도 추가 (seam 정합용)
|
||||||
|
ex = np.arange(ix0, ix1 + grid_step * 0.5, grid_step)
|
||||||
|
edge_top = np.column_stack([ex, np.full_like(ex, iy1)])
|
||||||
|
edge_bot = np.column_stack([ex, np.full_like(ex, iy0)])
|
||||||
|
ey = np.arange(iy0, iy1 + grid_step * 0.5, grid_step)
|
||||||
|
edge_left = np.column_stack([np.full_like(ey, ix0), ey])
|
||||||
|
edge_right = np.column_stack([np.full_like(ey, ix1), ey])
|
||||||
|
return np.vstack([ring_pts, edge_top, edge_bot, edge_left, edge_right])
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_tin_hull_projected(tin_xyz_zerobased: np.ndarray,
|
||||||
|
origin: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""TIN 점들의 XY 컨벡스 헐을 투영 CRS 좌표로 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
hull_xy_abs: (N,2) 반시계 순 헐 정점 XY (projected CRS, 절대좌표)
|
||||||
|
hull_z_abs: (N,) 해당 정점의 TIN Z (절대 높이)
|
||||||
|
"""
|
||||||
|
from scipy.spatial import ConvexHull
|
||||||
|
xy_abs = tin_xyz_zerobased[:, :2] + origin[:2]
|
||||||
|
hull = ConvexHull(xy_abs)
|
||||||
|
hull_xy_abs = xy_abs[hull.vertices]
|
||||||
|
hull_z_abs = tin_xyz_zerobased[hull.vertices, 2] + origin[2]
|
||||||
|
return hull_xy_abs, hull_z_abs
|
||||||
|
|
||||||
|
|
||||||
|
def _densify_polygon(poly_xy: np.ndarray, max_seg_len: float) -> np.ndarray:
|
||||||
|
"""다각형 엣지를 max_seg_len 이하로 선형 보간해 세분화."""
|
||||||
|
n = len(poly_xy)
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
p0 = poly_xy[i]
|
||||||
|
p1 = poly_xy[(i + 1) % n]
|
||||||
|
seg = p1 - p0
|
||||||
|
L = float(np.hypot(seg[0], seg[1]))
|
||||||
|
k = max(1, int(np.ceil(L / max(max_seg_len, 1e-6))))
|
||||||
|
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: np.ndarray | None = None,
|
||||||
|
) -> tuple[np.ndarray, int]:
|
||||||
|
"""hull 바깥쪽 격자점 + hull 엣지 세분화 점들을 결합해 링 XY 반환.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
precomputed_boundary: (N,2) TIN mesh에 이미 존재하는 bbox 변 정점 XY.
|
||||||
|
제공되면 densify를 건너뛰고 이 점들을 그대로 inner 경계로 사용 →
|
||||||
|
TIN 정점과 DEM 링 정점이 **공유 XY**가 되어 T-vertex/fin 원천 제거.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pts: (M, 2) 전체 링 점
|
||||||
|
n_hull_boundary: 끝 n개가 hull 경계 점. Z 오버라이드/feather용.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
gx, gy = np.meshgrid(xs, ys)
|
||||||
|
pts = np.column_stack([gx.ravel(), gy.ravel()])
|
||||||
|
# inner_hull = TIN bbox 4 모서리. bbox 내부(margin 포함)의 격자점 제거.
|
||||||
|
# 여유 margin = grid_step*0.5 — bbox 선에 너무 가까운 격자점도 제거해 링 삼각형이
|
||||||
|
# bbox 내부로 밀고 들어가는 현상(error1.png 톱니 fin) 차단.
|
||||||
|
ix0 = float(np.min(inner_hull_xy[:, 0])); ix1 = float(np.max(inner_hull_xy[:, 0]))
|
||||||
|
iy0 = float(np.min(inner_hull_xy[:, 1])); iy1 = float(np.max(inner_hull_xy[:, 1]))
|
||||||
|
guard = grid_step * 0.5
|
||||||
|
near_inner = (
|
||||||
|
(pts[:, 0] > ix0 - guard) & (pts[:, 0] < ix1 + guard)
|
||||||
|
& (pts[:, 1] > iy0 - guard) & (pts[:, 1] < iy1 + guard)
|
||||||
|
)
|
||||||
|
outside_grid = pts[~near_inner]
|
||||||
|
|
||||||
|
if precomputed_boundary is not None and len(precomputed_boundary) >= 3:
|
||||||
|
# **경계 densify 추가** — TIN bbox 공유정점 사이 간격이 외곽 grid_step 보다
|
||||||
|
# 크면 링 Delaunay 가 bbox 를 가로질러 큰 삼각형을 만들어 TIN 영역을 덮음.
|
||||||
|
# 따라서 공유정점을 유지하되, 인접 공유정점 사이 gap > grid_step 구간은
|
||||||
|
# 외곽 격자와 같은 밀도로 세분화해 삽입.
|
||||||
|
raw = np.asarray(precomputed_boundary, dtype=np.float64)
|
||||||
|
b_tol = max(ix1 - ix0, iy1 - iy0) * 1e-4 + 1e-3
|
||||||
|
on_bot = np.abs(raw[:, 1] - iy0) < b_tol
|
||||||
|
on_top = np.abs(raw[:, 1] - iy1) < b_tol
|
||||||
|
on_lft = np.abs(raw[:, 0] - ix0) < b_tol
|
||||||
|
on_rgt = np.abs(raw[:, 0] - ix1) < b_tol
|
||||||
|
parts: list[np.ndarray] = []
|
||||||
|
for mask, sort_col, fixed_col, fixed_val in [
|
||||||
|
(on_bot, 0, 1, iy0), (on_top, 0, 1, iy1),
|
||||||
|
(on_lft, 1, 0, ix0), (on_rgt, 1, 0, ix1),
|
||||||
|
]:
|
||||||
|
if mask.sum() == 0:
|
||||||
|
continue
|
||||||
|
side = raw[mask]
|
||||||
|
# 변 sort + 중복 제거
|
||||||
|
side = side[np.argsort(side[:, sort_col])]
|
||||||
|
_, uq = np.unique(np.round(side[:, sort_col], 3), return_index=True)
|
||||||
|
side = side[np.sort(uq)]
|
||||||
|
parts.append(side)
|
||||||
|
if len(side) < 2:
|
||||||
|
continue
|
||||||
|
main = side[:, sort_col]
|
||||||
|
gaps = np.diff(main)
|
||||||
|
for k, g in enumerate(gaps):
|
||||||
|
if g > grid_step:
|
||||||
|
n_add = int(np.ceil(g / grid_step)) - 1
|
||||||
|
if n_add < 1:
|
||||||
|
continue
|
||||||
|
mids = np.linspace(main[k], main[k + 1], n_add + 2)[1:-1]
|
||||||
|
add = np.zeros((len(mids), 2))
|
||||||
|
add[:, sort_col] = mids
|
||||||
|
add[:, fixed_col] = fixed_val
|
||||||
|
parts.append(add)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 최종 중복 제거 (outside_grid 점 중 boundary 와 매우 가까운 것 제거 → sliver 방지)
|
||||||
|
if len(boundary_pts) > 0 and len(outside_grid) > 0:
|
||||||
|
from scipy.spatial import cKDTree as _cKDT
|
||||||
|
bt = _cKDT(boundary_pts)
|
||||||
|
d, _ = bt.query(outside_grid, k=1)
|
||||||
|
outside_grid = outside_grid[d > grid_step * 0.25]
|
||||||
|
|
||||||
|
n_hull_boundary = len(boundary_pts)
|
||||||
|
all_pts = np.vstack([outside_grid, boundary_pts])
|
||||||
|
return all_pts, n_hull_boundary
|
||||||
|
|
||||||
|
|
||||||
|
def build_extended_terrain_ring(projected_bounds: tuple[float, float, float, float],
|
||||||
|
origin: np.ndarray,
|
||||||
|
src_crs: str,
|
||||||
|
buffer_m: float = 1000.0,
|
||||||
|
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: 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으로 확장한 메시 생성.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
projected_bounds: (xmin, ymin, xmax, ymax) 투영 CRS 원본 좌표.
|
||||||
|
origin: (ox, oy, oz) — TIN 생성 시 zero-basing에 사용한 offset.
|
||||||
|
src_crs: 투영 CRS 문자열 (예: "EPSG:5187").
|
||||||
|
buffer_m: 외곽으로 확장할 거리(미터).
|
||||||
|
tin_xyz_zerobased: (N,3) zero-based TIN 정점. 제공되면 수직 datum 보정
|
||||||
|
+ feathering + (use_hull_boundary=True면) 컨벡스 헐 경계 스냅에 사용.
|
||||||
|
None이면 DEM Z 원본을 그대로 사용하고 bbox 경계 사용.
|
||||||
|
grid_step_m: 외곽 링 격자 간격. None이면 buffer/25 기준 자동 (최소 10m).
|
||||||
|
feather_m: inner 경계에서 이 거리 이내는 TIN Z로 블렌드.
|
||||||
|
zoom: AWS terrarium zoom level (13 ≈ 19m/px at mid-lat).
|
||||||
|
cache_dir: 캐시 디렉토리.
|
||||||
|
source: "auto" | "aws_terrain" | "local_geotiff".
|
||||||
|
use_hull_boundary: True면 링 inner 경계를 TIN 컨벡스 헐로 스냅. 헐 정점은
|
||||||
|
TIN Z를 그대로 써 공유 정점으로 삼아 gap/T-vertex 제거. False면 사각 bbox
|
||||||
|
경계(legacy).
|
||||||
|
datum_offset_override: None이 아니면 이 값을 DEM Z에서 차감(vertical datum 보정).
|
||||||
|
TIN/내부채움과 **같은 offset**을 써야 bbox seam에 Z 단차가 없음. 호출자가
|
||||||
|
create_tin_from_dxf의 `_dem_datum_offset`을 그대로 넘기는 것을 권장.
|
||||||
|
elev_grid_override, grid_bounds_override: None이 아니면 DEM 타일을 **재사용**
|
||||||
|
(TIN 생성 시 이미 받은 격자 재활용 → datum 일관성 + 속도).
|
||||||
|
log_fn: 로그 callback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DemExtendResult: 메시 + 메타데이터.
|
||||||
|
"""
|
||||||
|
cache_dir = Path(cache_dir)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
xmin, ymin, xmax, ymax = projected_bounds
|
||||||
|
inner = (xmin, ymin, xmax, ymax)
|
||||||
|
outer = (xmin - buffer_m, ymin - buffer_m, xmax + buffer_m, ymax + buffer_m)
|
||||||
|
|
||||||
|
if grid_step_m is None:
|
||||||
|
grid_step_m = max(10.0, buffer_m / 25.0)
|
||||||
|
|
||||||
|
# --- 좌표계 진단 로그 -------------------------------------------------
|
||||||
|
# TIN/DXF는 src_crs(예: EPSG:5187), DEM 타일은 WGS84로 받아와 Z만 샘플링.
|
||||||
|
# 결과 mesh의 XY는 src_crs 평면 그대로이므로 TIN과 동일 CRS. 혹시 변환
|
||||||
|
# 오차가 의심되면 왕복 변환 거리로 확인 가능.
|
||||||
|
log_fn(f" [DEM] 좌표계: DXF={src_crs} → WGS84로 DEM 샘플링 → 결과 mesh는 {src_crs} 평면")
|
||||||
|
try:
|
||||||
|
_mid_x = 0.5 * (xmin + xmax); _mid_y = 0.5 * (ymin + ymax)
|
||||||
|
_to_wgs_t = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||||||
|
_to_proj_t = pyproj.Transformer.from_crs("EPSG:4326", src_crs, always_xy=True)
|
||||||
|
_lon, _lat = _to_wgs_t.transform(_mid_x, _mid_y)
|
||||||
|
_rx, _ry = _to_proj_t.transform(_lon, _lat)
|
||||||
|
_rt_err = float(((_rx - _mid_x) ** 2 + (_ry - _mid_y) ** 2) ** 0.5)
|
||||||
|
log_fn(f" [DEM] TIN bbox: X=[{xmin:.1f}, {xmax:.1f}] Y=[{ymin:.1f}, {ymax:.1f}] "
|
||||||
|
f"(중심 WGS84 Lon={_lon:.5f} Lat={_lat:.5f})")
|
||||||
|
log_fn(f" [DEM] 좌표 왕복 변환 오차: {_rt_err:.4f}m (중심점 기준, 0에 가까워야 정상)")
|
||||||
|
except Exception as _ce:
|
||||||
|
log_fn(f" [DEM] 좌표계 진단 실패: {_ce}")
|
||||||
|
|
||||||
|
# 1) 링 점 생성 — **TIN bbox를 inner polygon으로 사용** (convex hull 대신).
|
||||||
|
# 이전 hull 모드는 TIN pts의 bbox ≠ convex hull 이라 두 메시가 hull 외곽과
|
||||||
|
# bbox 사이 얇은 영역을 중복 덮거나 T-vertex/fin을 만들었다. bbox를 inner로
|
||||||
|
# 쓰면 DEM 링 inner 경계가 TIN bbox 선과 정확히 맞닿아 중복/fin 제거.
|
||||||
|
hull_xy_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:
|
||||||
|
try:
|
||||||
|
tin_abs = tin_xyz_zerobased + origin # (N,3) 절대좌표
|
||||||
|
tbx0 = float(tin_abs[:, 0].min()); tbx1 = float(tin_abs[:, 0].max())
|
||||||
|
tby0 = float(tin_abs[:, 1].min()); tby1 = float(tin_abs[:, 1].max())
|
||||||
|
hull_xy_abs = np.array([
|
||||||
|
[tbx0, tby0], [tbx1, tby0], [tbx1, tby1], [tbx0, tby1],
|
||||||
|
], dtype=np.float64)
|
||||||
|
# **TIN의 실제 bbox 변 정점**만 선별해 DEM 링 inner 경계로 사용 →
|
||||||
|
# 두 메시가 동일 XY의 공유 정점을 가지므로 T-vertex/fin 원천 제거.
|
||||||
|
bbox_tol_tin = max(tbx1 - tbx0, tby1 - tby0) * 1e-4 + 1e-3
|
||||||
|
on_bbox_mask = (
|
||||||
|
(np.abs(tin_abs[:, 0] - tbx0) < bbox_tol_tin)
|
||||||
|
| (np.abs(tin_abs[:, 0] - tbx1) < bbox_tol_tin)
|
||||||
|
| (np.abs(tin_abs[:, 1] - tby0) < bbox_tol_tin)
|
||||||
|
| (np.abs(tin_abs[:, 1] - tby1) < bbox_tol_tin)
|
||||||
|
)
|
||||||
|
tin_bbox_vertices_xy = tin_abs[on_bbox_mask, :2]
|
||||||
|
if len(tin_bbox_vertices_xy) < 4:
|
||||||
|
# bbox 변에 정점이 너무 적으면 폴백(densify 기본 로직)
|
||||||
|
tin_bbox_vertices_xy = None
|
||||||
|
|
||||||
|
ring_xy, n_hull_boundary = _generate_ring_points_hull(
|
||||||
|
outer, hull_xy_abs, grid_step_m,
|
||||||
|
precomputed_boundary=tin_bbox_vertices_xy,
|
||||||
|
)
|
||||||
|
n_shared = len(tin_bbox_vertices_xy) if tin_bbox_vertices_xy is not None else 0
|
||||||
|
boundary_mode = (f"TIN bbox 공유정점 {n_shared}개" if n_shared > 0
|
||||||
|
else "TIN bbox 4모서리 densify 폴백")
|
||||||
|
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; 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, "
|
||||||
|
f"buffer={buffer_m:.0f}m, 경계={boundary_mode})")
|
||||||
|
|
||||||
|
# 2) WGS84로 변환
|
||||||
|
to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||||||
|
lons, lats = to_wgs.transform(ring_xy[:, 0], ring_xy[:, 1])
|
||||||
|
lons = np.asarray(lons); lats = np.asarray(lats)
|
||||||
|
|
||||||
|
# 3) DEM 소스 결정 및 fetch
|
||||||
|
resolved_source = source
|
||||||
|
elev_grid = None
|
||||||
|
grid_bounds = None
|
||||||
|
|
||||||
|
# 3-0) 호출자 override — TIN densify에서 이미 받은 격자 재사용 (datum 일관성)
|
||||||
|
if elev_grid_override is not None and grid_bounds_override is not None:
|
||||||
|
elev_grid = elev_grid_override
|
||||||
|
grid_bounds = grid_bounds_override
|
||||||
|
resolved_source = "reused_from_caller"
|
||||||
|
log_fn(f" [DEM] 호출자 override 격자 재사용 ({elev_grid.shape[1]}x{elev_grid.shape[0]}) "
|
||||||
|
f"— TIN 생성 때 받은 것과 **동일 datum 보장**")
|
||||||
|
|
||||||
|
if elev_grid is None and source in ("auto", "local_geotiff"):
|
||||||
|
local = _try_local_geotiff(cache_dir)
|
||||||
|
if local is not None:
|
||||||
|
elev_grid, grid_bounds, _ = local
|
||||||
|
resolved_source = "local_geotiff"
|
||||||
|
log_fn(f" [DEM] 로컬 GeoTIFF 사용: cache/dem/local.tif ({elev_grid.shape[1]}x{elev_grid.shape[0]})")
|
||||||
|
|
||||||
|
if elev_grid is None:
|
||||||
|
ox0, oy0, ox1, oy1 = outer
|
||||||
|
margin = max(grid_step_m * 2, 50.0)
|
||||||
|
corners_x = np.array([ox0 - margin, ox1 + margin, ox0 - margin, ox1 + margin])
|
||||||
|
corners_y = np.array([oy0 - margin, oy0 - margin, oy1 + margin, oy1 + margin])
|
||||||
|
cx_lon, cx_lat = to_wgs.transform(corners_x, corners_y)
|
||||||
|
min_lat, max_lat = float(np.min(cx_lat)), float(np.max(cx_lat))
|
||||||
|
min_lon, max_lon = float(np.min(cx_lon)), float(np.max(cx_lon))
|
||||||
|
elev_grid, grid_bounds = fetch_terrarium_grid(
|
||||||
|
min_lat, min_lon, max_lat, max_lon,
|
||||||
|
zoom=zoom, cache_dir=cache_dir, log_fn=log_fn,
|
||||||
|
)
|
||||||
|
resolved_source = "aws_terrain"
|
||||||
|
|
||||||
|
# 4) 링 점 고도 샘플링
|
||||||
|
z_dem_raw = _sample_grid_bilinear(elev_grid, grid_bounds, lats, lons)
|
||||||
|
if np.any(np.isnan(z_dem_raw)):
|
||||||
|
med = float(np.nanmedian(z_dem_raw))
|
||||||
|
z_dem_raw = np.where(np.isnan(z_dem_raw), med, z_dem_raw)
|
||||||
|
|
||||||
|
# 4a) 전역 outlier 클립 + **NaN 즉시 채움**.
|
||||||
|
# terrarium 디코딩 실패·타일 엣지에서 극단값/NaN 이 링 삼각형을 수십 m
|
||||||
|
# 솟게 만들었던 현상 방지. IQR 기반 + 절대범위(−500m~9000m) 가드.
|
||||||
|
finite = np.isfinite(z_dem_raw)
|
||||||
|
if finite.any():
|
||||||
|
vals = z_dem_raw[finite]
|
||||||
|
q1, q3 = np.percentile(vals, [5, 95])
|
||||||
|
iqr = max(q3 - q1, 1.0)
|
||||||
|
lo = max(-500.0, q1 - iqr * 3.0)
|
||||||
|
hi = min(9000.0, q3 + iqr * 3.0)
|
||||||
|
med_all = float(np.median(vals))
|
||||||
|
z_dem_raw = np.where(finite, z_dem_raw, med_all)
|
||||||
|
z_dem_raw = np.clip(z_dem_raw, lo, hi)
|
||||||
|
else:
|
||||||
|
z_dem_raw = np.zeros_like(z_dem_raw)
|
||||||
|
log_fn(" [DEM] 경고: 샘플 전체가 NaN — 0으로 채움 (DEM 타일 fetch 점검 필요)")
|
||||||
|
|
||||||
|
# 4b) 국소 스파이크 필터 — 각 점의 Z가 반경 R 내 이웃 median 대비 3×MAD 이상
|
||||||
|
# 벗어나면 median으로 치환. 경계 값이 튀는 원인(인접 타일 디코딩 차이)을 직접 제거.
|
||||||
|
try:
|
||||||
|
radius = max(grid_step_m * 3.5, 60.0)
|
||||||
|
tree_local = cKDTree(ring_xy)
|
||||||
|
neigh_idxs = tree_local.query_ball_point(ring_xy, r=radius)
|
||||||
|
cleaned = z_dem_raw.copy()
|
||||||
|
spike_count = 0
|
||||||
|
for i, nbrs in enumerate(neigh_idxs):
|
||||||
|
if len(nbrs) < 5:
|
||||||
|
continue
|
||||||
|
neigh_z = z_dem_raw[nbrs]
|
||||||
|
med_ = float(np.median(neigh_z))
|
||||||
|
mad_ = float(np.median(np.abs(neigh_z - med_))) + 1e-6
|
||||||
|
if abs(z_dem_raw[i] - med_) > 4.0 * mad_:
|
||||||
|
cleaned[i] = med_
|
||||||
|
spike_count += 1
|
||||||
|
if spike_count:
|
||||||
|
log_fn(f" [DEM] 스파이크 {spike_count}개 제거 (radius={radius:.0f}m, 4×MAD)")
|
||||||
|
z_dem_raw = cleaned
|
||||||
|
except Exception as _se:
|
||||||
|
log_fn(f" [DEM] 스파이크 필터 경고: {_se}")
|
||||||
|
|
||||||
|
# 5) 수직 datum 보정 + feathering
|
||||||
|
# **override 우선** — TIN 생성 시 계산한 offset을 그대로 쓰면 bbox seam에 Z 단차 0.
|
||||||
|
vertical_offset = 0.0
|
||||||
|
z_final = z_dem_raw.copy()
|
||||||
|
if datum_offset_override is not None:
|
||||||
|
vertical_offset = float(datum_offset_override)
|
||||||
|
z_final = z_dem_raw - vertical_offset
|
||||||
|
log_fn(f" [DEM] 수직 datum **override** offset={vertical_offset:+.2f}m "
|
||||||
|
f"(TIN 생성 시 계산한 값 재사용 → bbox seam Z 단차 0)")
|
||||||
|
if tin_xyz_zerobased is not None and len(tin_xyz_zerobased) > 0:
|
||||||
|
tin_abs = tin_xyz_zerobased + origin
|
||||||
|
tree = cKDTree(tin_abs[:, :2])
|
||||||
|
ring_abs = np.column_stack([ring_xy[:, 0], ring_xy[:, 1]])
|
||||||
|
dists, idxs = tree.query(ring_abs, k=1)
|
||||||
|
near_mask = dists < max(feather_m * 1.5, 50.0)
|
||||||
|
if np.any(near_mask) and datum_offset_override is None:
|
||||||
|
tin_z_near = tin_abs[idxs[near_mask], 2]
|
||||||
|
dem_z_near = z_dem_raw[near_mask]
|
||||||
|
diffs = dem_z_near - tin_z_near
|
||||||
|
vertical_offset = float(np.median(diffs))
|
||||||
|
log_fn(f" [DEM] 수직 datum 자동 보정: offset={vertical_offset:+.2f}m "
|
||||||
|
f"(비교 점 {int(near_mask.sum())}개)")
|
||||||
|
z_final = z_dem_raw - vertical_offset
|
||||||
|
if feather_m > 0 and np.any(near_mask):
|
||||||
|
# smoothstep 블렌드 (선형보다 부드러운 C1 전이 — 경계 Z 단차 제거).
|
||||||
|
# t=0(경계)에서 TIN Z, t=1(feather_m 바깥)에서 DEM Z. 사이 값은
|
||||||
|
# 3t²−2t³ 곡선으로 양 끝에서 미분 0 → 절벽 같은 Z 점프 제거.
|
||||||
|
raw_t = np.clip(dists / feather_m, 0.0, 1.0)
|
||||||
|
t = raw_t * raw_t * (3.0 - 2.0 * raw_t) # smoothstep
|
||||||
|
tin_z_all = tin_abs[idxs, 2]
|
||||||
|
blended = (1.0 - t) * tin_z_all + t * z_final
|
||||||
|
z_final = np.where(dists < feather_m, blended, z_final)
|
||||||
|
|
||||||
|
# 5b) **Outer smooth blend** — outer 경계 근처를 이웃 평균으로 부드럽게 평활.
|
||||||
|
# 이전 "하위 10% 분위수" ramp-down 은 점마다 강제 하강량이 달라 톱니 fin 을
|
||||||
|
# 만들었음(error1.png). 이번에는:
|
||||||
|
# - 이웃 **평균** Z 를 타겟으로(분위수 NO)
|
||||||
|
# - smoothstep 가중치 (경계 1, ramp 밖 0)
|
||||||
|
# - 이웃 반경 = grid_step*6 로 넓게 → fin 유발하는 국소 편차 제거
|
||||||
|
# - 최대 하강/상승 폭 제한 (|Δ| ≤ grid_step*0.8) → 튀는 값 차단
|
||||||
|
try:
|
||||||
|
ox0, oy0, ox1, oy1 = outer
|
||||||
|
dist_to_outer = np.minimum.reduce([
|
||||||
|
ring_xy[:, 0] - ox0,
|
||||||
|
ox1 - ring_xy[:, 0],
|
||||||
|
ring_xy[:, 1] - oy0,
|
||||||
|
oy1 - ring_xy[:, 1],
|
||||||
|
])
|
||||||
|
dist_to_outer = np.maximum(dist_to_outer, 0.0)
|
||||||
|
ramp_width = max(float(grid_step_m) * 4.0, float(buffer_m) * 0.20)
|
||||||
|
in_ramp = dist_to_outer < ramp_width
|
||||||
|
n_ramp = int(in_ramp.sum())
|
||||||
|
if n_ramp > 0:
|
||||||
|
tree_outer = cKDTree(ring_xy)
|
||||||
|
r_neigh = max(float(grid_step_m) * 6.0, 120.0)
|
||||||
|
idxs_ramp = np.where(in_ramp)[0]
|
||||||
|
nbrs_ramp = tree_outer.query_ball_point(ring_xy[idxs_ramp], r=r_neigh)
|
||||||
|
z_target = z_final[idxs_ramp].copy()
|
||||||
|
for k, nb in enumerate(nbrs_ramp):
|
||||||
|
if len(nb) < 5:
|
||||||
|
continue
|
||||||
|
z_target[k] = float(np.mean(z_final[nb]))
|
||||||
|
# smoothstep weight: outer(t=0) → w=1, ramp 밖(t=1) → w=0
|
||||||
|
t = np.clip(dist_to_outer[idxs_ramp] / ramp_width, 0.0, 1.0)
|
||||||
|
u = 1.0 - t
|
||||||
|
w = u * u * (3.0 - 2.0 * u)
|
||||||
|
z_before = z_final[idxs_ramp].copy()
|
||||||
|
blended = (1.0 - w) * z_before + w * z_target
|
||||||
|
# 튀는 값 가드 — 이웃 평균과의 편차를 grid_step*0.8 이내로 제한
|
||||||
|
cap = float(grid_step_m) * 0.8
|
||||||
|
blended = np.clip(blended, z_before - cap, z_before + cap)
|
||||||
|
z_final[idxs_ramp] = blended
|
||||||
|
log_fn(
|
||||||
|
f" [DEM] outer smooth blend {n_ramp}개 점 "
|
||||||
|
f"(ramp폭={ramp_width:.0f}m, r={r_neigh:.0f}m, |Δ|≤{cap:.1f}m) "
|
||||||
|
f"— 이웃 평균 블렌드, fin/톱니 제거"
|
||||||
|
)
|
||||||
|
except Exception as _rd:
|
||||||
|
log_fn(f" [DEM] outer smooth blend 경고: {_rd}")
|
||||||
|
|
||||||
|
# 5c) bbox 경계 densified 점 Z 설정.
|
||||||
|
# (1) **TIN 공유 정점** (= 실제 TIN vertex 그대로) 은 TIN Z 그대로.
|
||||||
|
# (2) **경계 보간 densify** 신규 점(= TIN vertex 없는 bbox 변 위 점)은 양옆
|
||||||
|
# TIN vertex 사이 선형 보간 Z (거리 가중 k=2) 로 설정 → TIN 경계선이
|
||||||
|
# 그대로 연장되어 seam 에서 Z 점프 0.
|
||||||
|
if n_hull_boundary > 0 and hull_xy_abs is not None and tin_xyz_zerobased is not None:
|
||||||
|
n_total = len(ring_xy)
|
||||||
|
boundary_start = n_total - n_hull_boundary
|
||||||
|
boundary_xy = ring_xy[boundary_start:]
|
||||||
|
tin_abs_full = tin_xyz_zerobased + origin
|
||||||
|
tin_tree_full = cKDTree(tin_abs_full[:, :2])
|
||||||
|
d_near, i_near = tin_tree_full.query(boundary_xy, k=2)
|
||||||
|
# k=1 이면 거의 일치하는 TIN vertex → 그대로 사용
|
||||||
|
# k=2 가중평균으로 densify 중간 점 Z 를 선형 보간
|
||||||
|
exact = d_near[:, 0] < 1e-3
|
||||||
|
z_b = np.empty(len(boundary_xy), dtype=np.float64)
|
||||||
|
z_b[exact] = tin_abs_full[i_near[exact, 0], 2]
|
||||||
|
if (~exact).any():
|
||||||
|
w0 = 1.0 / np.maximum(d_near[~exact, 0], 1e-6) ** 2
|
||||||
|
w1 = 1.0 / np.maximum(d_near[~exact, 1], 1e-6) ** 2
|
||||||
|
z0 = tin_abs_full[i_near[~exact, 0], 2]
|
||||||
|
z1 = tin_abs_full[i_near[~exact, 1], 2]
|
||||||
|
z_b[~exact] = (w0 * z0 + w1 * z1) / (w0 + w1)
|
||||||
|
z_final[boundary_start:] = z_b
|
||||||
|
|
||||||
|
# 5d) **최종 NaN/무한 가드** — 어떤 경로로 들어온 NaN도 여기서 확정적으로 제거.
|
||||||
|
# 인접 이웃의 median 으로 채움. 이웃 없으면 전체 median.
|
||||||
|
bad = ~np.isfinite(z_final)
|
||||||
|
if bad.any():
|
||||||
|
good_idx = np.where(~bad)[0]
|
||||||
|
if len(good_idx) > 0:
|
||||||
|
tree_nan = cKDTree(ring_xy[good_idx])
|
||||||
|
_, j = tree_nan.query(ring_xy[bad], k=1)
|
||||||
|
z_final[bad] = z_final[good_idx[j]]
|
||||||
|
else:
|
||||||
|
z_final[bad] = 0.0
|
||||||
|
log_fn(f" [DEM] NaN/Inf {int(bad.sum())}개 이웃 값으로 채움")
|
||||||
|
|
||||||
|
# 5e) **링 Z Laplacian 1pass** — 톱니(fin) 마지막 평활. TIN 공유 경계 정점은
|
||||||
|
# 고정(Laplacian 에서 제외) → TIN–링 seam Z 는 변경되지 않음.
|
||||||
|
try:
|
||||||
|
tree_ring = cKDTree(ring_xy)
|
||||||
|
r_sm = max(float(grid_step_m) * 2.2, 40.0)
|
||||||
|
fixed_mask = np.zeros(len(ring_xy), dtype=bool)
|
||||||
|
if n_hull_boundary > 0:
|
||||||
|
fixed_mask[len(ring_xy) - n_hull_boundary:] = True
|
||||||
|
movable = np.where(~fixed_mask)[0]
|
||||||
|
nbrs = tree_ring.query_ball_point(ring_xy[movable], r=r_sm)
|
||||||
|
z_new = z_final.copy()
|
||||||
|
for k, nb in enumerate(nbrs):
|
||||||
|
if len(nb) < 4:
|
||||||
|
continue
|
||||||
|
vid = movable[k]
|
||||||
|
nb_arr = np.asarray(nb, dtype=np.int64)
|
||||||
|
nb_arr = nb_arr[nb_arr != vid]
|
||||||
|
if len(nb_arr) == 0:
|
||||||
|
continue
|
||||||
|
z_new[vid] = 0.5 * z_final[vid] + 0.5 * float(np.mean(z_final[nb_arr]))
|
||||||
|
z_final = z_new
|
||||||
|
except Exception as _ls:
|
||||||
|
log_fn(f" [DEM] 링 Laplacian 평활 경고: {_ls}")
|
||||||
|
|
||||||
|
# 6) Zero-basing
|
||||||
|
ring_xyz = np.column_stack([
|
||||||
|
ring_xy[:, 0] - origin[0],
|
||||||
|
ring_xy[:, 1] - origin[1],
|
||||||
|
z_final - origin[2],
|
||||||
|
])
|
||||||
|
|
||||||
|
# 7) Delaunay (XY 기준) → 도넛 triangulation
|
||||||
|
try:
|
||||||
|
tri = Delaunay(ring_xyz[:, :2])
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"링 메시 Delaunay 실패: {e}") from e
|
||||||
|
|
||||||
|
# inner(TIN bbox) 내부 삼각형 제거 — **3중 가드**로 침범 원천 차단.
|
||||||
|
# (a) centroid 가 inner bbox 내부
|
||||||
|
# (b) **세 정점 중 하나라도 inner bbox strict 내부** (≥ strict_tol 안쪽)
|
||||||
|
# (c) **엣지 중점** 셋 중 하나라도 inner bbox strict 내부
|
||||||
|
# 이전에는 (a)+(b) 만으로 bbox 선을 따라 "세 정점 전부 경계 위"인 납작한
|
||||||
|
# 삼각형이 남아 엣지 중점이 bbox 내부로 진입하며 TIN 위를 덮었음(error.png
|
||||||
|
# 침범 증상). (c) 추가로 완전 차단.
|
||||||
|
cx0 = inner[0] - origin[0]; cy0 = inner[1] - origin[1]
|
||||||
|
cx1 = inner[2] - origin[0]; cy1 = inner[3] - origin[1]
|
||||||
|
bbox_w = max(cx1 - cx0, cy1 - cy0)
|
||||||
|
strict_tol = bbox_w * 1e-4 + 1e-3 # 수치 오차 가드 (≈mm 단위)
|
||||||
|
|
||||||
|
v0 = ring_xyz[tri.simplices[:, 0], :2]
|
||||||
|
v1 = ring_xyz[tri.simplices[:, 1], :2]
|
||||||
|
v2 = ring_xyz[tri.simplices[:, 2], :2]
|
||||||
|
centroids = (v0 + v1 + v2) / 3.0
|
||||||
|
mid01 = (v0 + v1) * 0.5
|
||||||
|
mid12 = (v1 + v2) * 0.5
|
||||||
|
mid20 = (v2 + v0) * 0.5
|
||||||
|
|
||||||
|
def _strict_in_bbox(p: np.ndarray) -> np.ndarray:
|
||||||
|
return (
|
||||||
|
(p[:, 0] > cx0 + strict_tol) & (p[:, 0] < cx1 - strict_tol)
|
||||||
|
& (p[:, 1] > cy0 + strict_tol) & (p[:, 1] < cy1 - strict_tol)
|
||||||
|
)
|
||||||
|
|
||||||
|
# (a) centroid
|
||||||
|
inside_inner_centroid = _strict_in_bbox(centroids - np.array([[0, 0]])) # strict only
|
||||||
|
# centroid 는 strict 내부까지 너무 보수적이면 seam 삼각형도 지울 수 있으므로
|
||||||
|
# 너그럽게: bbox 완전 내부만.
|
||||||
|
inside_inner_centroid = (
|
||||||
|
(centroids[:, 0] > cx0 + strict_tol) & (centroids[:, 0] < cx1 - strict_tol)
|
||||||
|
& (centroids[:, 1] > cy0 + strict_tol) & (centroids[:, 1] < cy1 - strict_tol)
|
||||||
|
)
|
||||||
|
|
||||||
|
# (b) 세 정점 중 하나라도 strict 내부
|
||||||
|
vx = ring_xyz[:, 0]; vy = ring_xyz[:, 1]
|
||||||
|
strict_inner_vert = (
|
||||||
|
(vx > cx0 + strict_tol) & (vx < cx1 - strict_tol)
|
||||||
|
& (vy > cy0 + strict_tol) & (vy < cy1 - strict_tol)
|
||||||
|
)
|
||||||
|
has_strict_vertex = (
|
||||||
|
strict_inner_vert[tri.simplices[:, 0]]
|
||||||
|
| strict_inner_vert[tri.simplices[:, 1]]
|
||||||
|
| strict_inner_vert[tri.simplices[:, 2]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# (c) 엣지 중점 중 하나라도 strict 내부
|
||||||
|
edge_mid_inside = (
|
||||||
|
_strict_in_bbox(mid01) | _strict_in_bbox(mid12) | _strict_in_bbox(mid20)
|
||||||
|
)
|
||||||
|
|
||||||
|
inside_inner = inside_inner_centroid | has_strict_vertex | edge_mid_inside
|
||||||
|
n_cent = int(inside_inner_centroid.sum())
|
||||||
|
n_strict = int(has_strict_vertex.sum())
|
||||||
|
n_emid = int(edge_mid_inside.sum())
|
||||||
|
tris_keep = tri.simplices[~inside_inner]
|
||||||
|
log_fn(f" [DEM] inner 제거 삼각형: centroid {n_cent}개 + strict-vertex {n_strict}개 "
|
||||||
|
f"+ edge-midpoint {n_emid}개 (tol={strict_tol:.3f}m) — TIN 침범 3중 차단")
|
||||||
|
|
||||||
|
# 링 삼각형 벽 컷 — **2단계 보호 + 추가 완화**.
|
||||||
|
# 이전: slope_ratio>2.5 & Z스팬>10m 도 실제 72° 산사면/급한 골짜기를 자르면서
|
||||||
|
# 접합점에 삼각 구멍(= error.png의 두 번째 이슈)을 냈다.
|
||||||
|
# 이번:
|
||||||
|
# (1) **TIN 공유 경계 정점을 가진 삼각형은 컷 제외** — bbox seam 삼각형은
|
||||||
|
# DEM 급경사 한 번에 날아가면 보이는 **접합점 구멍**이 된다. 보호.
|
||||||
|
# (2) 기준 상향: slope_ratio > 4.0 (≈76°) AND Z스팬 > 30m — 자연 급사면보다
|
||||||
|
# 가파르고, 30m 이상 Z 점프가 있어야만 진짜 "수직 벽" 후보로 간주.
|
||||||
|
# (3) 추가 가드: 에지 길이 e_max가 너무 짧지 않아야 함(<5m는 제외: 극미세
|
||||||
|
# sliver는 별도 수치 오차, 벽 아님).
|
||||||
|
if len(tris_keep) > 0:
|
||||||
|
zs = ring_xyz[tris_keep][:, :, 2]
|
||||||
|
z_span_tri = zs.max(axis=1) - zs.min(axis=1)
|
||||||
|
pxy = ring_xyz[tris_keep][:, :, :2]
|
||||||
|
e01 = np.linalg.norm(pxy[:, 0] - pxy[:, 1], axis=1)
|
||||||
|
e12 = np.linalg.norm(pxy[:, 1] - pxy[:, 2], axis=1)
|
||||||
|
e20 = np.linalg.norm(pxy[:, 2] - pxy[:, 0], axis=1)
|
||||||
|
e_max_tri = np.maximum(np.maximum(e01, e12), e20)
|
||||||
|
e_max_safe = np.maximum(e_max_tri, 1e-6)
|
||||||
|
slope_ratio_tri = z_span_tri / e_max_safe
|
||||||
|
|
||||||
|
# (1) seam 보호 마스크 — TIN bbox 변 공유 정점(= boundary_pts, 마지막
|
||||||
|
# n_hull_boundary 개)을 가진 삼각형은 "접합부"라 컷 제외.
|
||||||
|
protect_vertex = np.zeros(len(ring_xyz), dtype=bool)
|
||||||
|
if n_hull_boundary > 0:
|
||||||
|
protect_vertex[len(ring_xyz) - n_hull_boundary:] = True
|
||||||
|
pv0 = protect_vertex[tris_keep[:, 0]]
|
||||||
|
pv1 = protect_vertex[tris_keep[:, 1]]
|
||||||
|
pv2 = protect_vertex[tris_keep[:, 2]]
|
||||||
|
seam_tri = pv0 | pv1 | pv2
|
||||||
|
|
||||||
|
# (2+3) 완화된 기준
|
||||||
|
wall_mask = (slope_ratio_tri > 4.0) & (z_span_tri > 30.0) & (e_max_tri > 5.0) & (~seam_tri)
|
||||||
|
if wall_mask.any():
|
||||||
|
tris_keep = tris_keep[~wall_mask]
|
||||||
|
log_fn(f" [DEM] 링 벽 삼각형 {int(wall_mask.sum())}개 제거 "
|
||||||
|
f"(slope_ratio>4.0(≈76°) & Z스팬>30m, seam {int(seam_tri.sum())}개 보호) "
|
||||||
|
f"— 자연 급사면 보존, 접합점 구멍 방지")
|
||||||
|
else:
|
||||||
|
log_fn(f" [DEM] 링 벽 컷: 대상 없음 (seam {int(seam_tri.sum())}개 보호) "
|
||||||
|
f"— 자연 경사면 유지")
|
||||||
|
|
||||||
|
if len(tris_keep) == 0:
|
||||||
|
raise RuntimeError("링 삼각형이 하나도 남지 않았습니다 (buffer_m/grid_step_m 확인).")
|
||||||
|
|
||||||
|
faces = np.column_stack([np.full(len(tris_keep), 3), tris_keep])
|
||||||
|
mesh = pv.PolyData(ring_xyz, faces)
|
||||||
|
mesh["Elevation"] = ring_xyz[:, 2]
|
||||||
|
|
||||||
|
info = (f"source={resolved_source}, buffer={buffer_m:.0f}m, step={grid_step_m:.1f}m, "
|
||||||
|
f"feather={feather_m:.0f}m, offset={vertical_offset:+.2f}m, "
|
||||||
|
f"boundary={boundary_mode}, pts={len(ring_xyz)}, tris={len(tris_keep)}")
|
||||||
|
log_fn(f" [DEM] 링 메시 완료: {info}")
|
||||||
|
|
||||||
|
return DemExtendResult(
|
||||||
|
mesh=mesh,
|
||||||
|
n_points=len(ring_xyz),
|
||||||
|
n_faces=len(tris_keep),
|
||||||
|
buffer_m=buffer_m,
|
||||||
|
source=resolved_source,
|
||||||
|
vertical_offset_m=vertical_offset,
|
||||||
|
zoom=zoom,
|
||||||
|
grid_step_m=grid_step_m,
|
||||||
|
feather_m=feather_m,
|
||||||
|
info=info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 스모크 테스트: 사연댐 대략 좌표(WGS84) 주변
|
||||||
|
# 투영 좌표로 변환해서 테스트
|
||||||
|
to_proj = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:5187", always_xy=True)
|
||||||
|
cx, cy = to_proj.transform(129.10, 35.54) # 대략 사연댐 부근
|
||||||
|
half = 1000.0
|
||||||
|
bounds = (cx - half, cy - half, cx + half, cy + half)
|
||||||
|
origin = np.array([cx - half, cy - half, 100.0])
|
||||||
|
result = build_extended_terrain_ring(
|
||||||
|
projected_bounds=bounds,
|
||||||
|
origin=origin,
|
||||||
|
src_crs="EPSG:5187",
|
||||||
|
buffer_m=500.0,
|
||||||
|
grid_step_m=50.0,
|
||||||
|
feather_m=0.0,
|
||||||
|
log_fn=print,
|
||||||
|
)
|
||||||
|
print(result.info)
|
||||||
551
detail_parser.py
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
"""S-CANVAS 구조물 상세도면 DXF 치수 파서.
|
||||||
|
|
||||||
|
상세도면(단면도/상세도)에서 TEXT, MTEXT, DIMENSION, ATTRIB 엔티티를 분석하여
|
||||||
|
구조물의 높이, 폭, 두께, 계획고, 관경, 사면경사 등 설계 치수를 자동 추출한다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
parser = DetailParser()
|
||||||
|
result = parser.parse(dxf_path)
|
||||||
|
# result.dimensions → [ParsedDimension(...), ...]
|
||||||
|
# result.summary() → {"height": 5.0, "width": 3.0, ...}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 클래스
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedDimension:
|
||||||
|
"""파싱된 개별 치수 항목."""
|
||||||
|
param: str # height, width, thickness, elevation, diameter, slope, length, ...
|
||||||
|
value: float # 수치 값 (미터 단위로 정규화)
|
||||||
|
raw_text: str # 원본 텍스트
|
||||||
|
source: str # "text" | "mtext" | "dimension" | "attrib"
|
||||||
|
layer: str # DXF 레이어명
|
||||||
|
position: tuple # (x, y) 좌표
|
||||||
|
confidence: float # 0.0 ~ 1.0 신뢰도
|
||||||
|
unit: str = "m" # 단위 (m, mm)
|
||||||
|
secondary: float | None = None # 보조값 (slope의 경우 비율 등)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseResult:
|
||||||
|
"""전체 파싱 결과."""
|
||||||
|
dxf_path: str
|
||||||
|
dimensions: list[ParsedDimension] = field(default_factory=list)
|
||||||
|
layer_names: list[str] = field(default_factory=list)
|
||||||
|
entity_summary: dict = field(default_factory=dict) # {entity_type: count}
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
"""파라미터별 최고 신뢰도 값을 딕셔너리로 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"height": 5.0, "width": 3.0, "elevation": 85.0, ...}
|
||||||
|
"""
|
||||||
|
best: dict[str, ParsedDimension] = {}
|
||||||
|
for d in self.dimensions:
|
||||||
|
if d.param not in best or d.confidence > best[d.param].confidence:
|
||||||
|
best[d.param] = d
|
||||||
|
return {k: v.value for k, v in best.items()}
|
||||||
|
|
||||||
|
def by_param(self, param: str) -> list[ParsedDimension]:
|
||||||
|
"""특정 파라미터의 모든 파싱 결과를 신뢰도 내림차순으로 반환."""
|
||||||
|
return sorted(
|
||||||
|
[d for d in self.dimensions if d.param == param],
|
||||||
|
key=lambda d: -d.confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
def all_params(self) -> list[str]:
|
||||||
|
"""발견된 모든 파라미터 종류."""
|
||||||
|
return sorted(set(d.param for d in self.dimensions))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 패턴 정의
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# AutoCAD 특수문자: %%C = Φ (파이), %%D = ° (도), %%P = ± (플마)
|
||||||
|
_AUTOCAD_PHI = r"%%[cC]"
|
||||||
|
|
||||||
|
# 토목 도면 치수 패턴 (한글/영문 혼용)
|
||||||
|
_PATTERNS: list[tuple[str, str, re.Pattern, float]] = [
|
||||||
|
# (param, description, compiled_pattern, base_confidence)
|
||||||
|
|
||||||
|
# --- 계획고 (elevation) ---
|
||||||
|
("elevation", "EL.xxx.xx",
|
||||||
|
re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
|
||||||
|
|
||||||
|
("elevation", "표고 xxx.x",
|
||||||
|
re.compile(r"(?:표고|계획고|설계고)\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE), 0.90),
|
||||||
|
|
||||||
|
# --- 높이 (height) ---
|
||||||
|
("height", "H=xxx",
|
||||||
|
re.compile(r"[Hh]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
|
||||||
|
("height", "높이=xxx",
|
||||||
|
re.compile(r"(?:높이|전고|벽고|壁高)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
|
||||||
|
("height", "h xxx.x m",
|
||||||
|
re.compile(r"(?:^|\s)[Hh]\s+(\d+\.?\d*)\s*[mM](?:\s|$)"), 0.75),
|
||||||
|
|
||||||
|
# --- 폭 (width) ---
|
||||||
|
("width", "W=xxx 또는 B=xxx",
|
||||||
|
re.compile(r"[WwBb]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
|
||||||
|
("width", "폭=xxx",
|
||||||
|
re.compile(r"(?:폭|너비|幅|底幅|천단폭)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
|
||||||
|
# --- 두께 (thickness) ---
|
||||||
|
("thickness", "T=xxx 또는 t=xxx",
|
||||||
|
re.compile(r"[Tt]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.85),
|
||||||
|
|
||||||
|
("thickness", "두께=xxx",
|
||||||
|
re.compile(r"(?:두께|벽두께|슬래브두께)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
|
||||||
|
# --- 길이 (length) ---
|
||||||
|
("length", "L=xxx",
|
||||||
|
re.compile(r"[Ll]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.85),
|
||||||
|
|
||||||
|
("length", "길이=xxx 또는 연장=xxx",
|
||||||
|
re.compile(r"(?:길이|연장|延長|총연장)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
|
||||||
|
# --- 관경/직경 (diameter) ---
|
||||||
|
("diameter", "%%Cxxx (AutoCAD Φ기호)",
|
||||||
|
re.compile(r"%%[cC]\s*(\d+\.?\d*)"), 0.95),
|
||||||
|
|
||||||
|
("diameter", "D=xxx 또는 Φxxx",
|
||||||
|
re.compile(r"(?:[Dd]|[ΦφΦ])\s*[=:]?\s*(\d+\.?\d*)\s*(?:mm|MM)?"), 0.85),
|
||||||
|
|
||||||
|
("diameter", "관경=xxx",
|
||||||
|
re.compile(r"(?:관경|내경|외경|직경)\s*[=:]?\s*(\d+\.?\d*)\s*(?:mm|MM|[mM])?"), 0.90),
|
||||||
|
|
||||||
|
# --- 사면 경사 (slope) ---
|
||||||
|
("slope", "1:N.N",
|
||||||
|
re.compile(r"(\d+)\s*:\s*(\d+\.?\d*)"), 0.90),
|
||||||
|
|
||||||
|
# --- 박스/U형 단면 (composite: width × height) ---
|
||||||
|
("_box", "BOX W×H",
|
||||||
|
re.compile(r"BOX\s*(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
|
||||||
|
|
||||||
|
("_uchannel", "U W×H",
|
||||||
|
re.compile(r"U\s*(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
|
||||||
|
|
||||||
|
# --- 반경 (radius) ---
|
||||||
|
("radius", "R=xxx",
|
||||||
|
re.compile(r"[Rr]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.80),
|
||||||
|
|
||||||
|
# --- 간격 (spacing) ---
|
||||||
|
("spacing", "@xxx",
|
||||||
|
re.compile(r"@\s*(\d+\.?\d*)"), 0.80),
|
||||||
|
|
||||||
|
# --- 근입깊이 (embedment depth) ---
|
||||||
|
("embedment", "근입=xxx",
|
||||||
|
re.compile(r"(?:근입|근입깊이|매입깊이)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메인 파서
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DetailParser:
|
||||||
|
"""구조물 상세도면 DXF에서 설계 치수를 추출하는 파서."""
|
||||||
|
|
||||||
|
def __init__(self, unit_threshold_mm: float = 50.0):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
unit_threshold_mm: 이 값보다 큰 수치는 mm 단위로 간주하여 m로 변환.
|
||||||
|
관경(%%C300 등)은 별도 처리.
|
||||||
|
"""
|
||||||
|
self.unit_threshold = unit_threshold_mm
|
||||||
|
|
||||||
|
def parse(self, dxf_path: str | Path) -> ParseResult:
|
||||||
|
"""DXF 파일에서 치수를 파싱.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dxf_path: DXF 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParseResult 객체
|
||||||
|
"""
|
||||||
|
dxf_path = Path(dxf_path)
|
||||||
|
doc = ezdxf.readfile(str(dxf_path))
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
result = ParseResult(dxf_path=str(dxf_path))
|
||||||
|
result.layer_names = [layer.dxf.name for layer in doc.layers]
|
||||||
|
|
||||||
|
# 엔티티 요약 수집
|
||||||
|
for entity in msp:
|
||||||
|
et = entity.dxftype()
|
||||||
|
result.entity_summary[et] = result.entity_summary.get(et, 0) + 1
|
||||||
|
|
||||||
|
# 1) TEXT / MTEXT 파싱
|
||||||
|
self._parse_text_entities(msp, result)
|
||||||
|
|
||||||
|
# 2) DIMENSION 엔티티 파싱
|
||||||
|
self._parse_dimension_entities(msp, result)
|
||||||
|
|
||||||
|
# 3) INSERT 블록 내 ATTRIB 파싱
|
||||||
|
self._parse_attrib_entities(msp, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ----- TEXT / MTEXT -----
|
||||||
|
|
||||||
|
def _parse_text_entities(self, msp, result: ParseResult):
|
||||||
|
"""TEXT, MTEXT 엔티티에서 패턴 매칭으로 치수 추출."""
|
||||||
|
for entity in msp:
|
||||||
|
etype = entity.dxftype()
|
||||||
|
if etype not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
txt = entity.dxf.text.strip() if etype == "TEXT" else (entity.text or "").strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
|
||||||
|
layer = entity.dxf.layer
|
||||||
|
try:
|
||||||
|
pos = entity.dxf.insert
|
||||||
|
position = (pos.x, pos.y)
|
||||||
|
except Exception:
|
||||||
|
position = (0.0, 0.0)
|
||||||
|
|
||||||
|
source = etype.lower()
|
||||||
|
self._match_patterns(txt, source, layer, position, result)
|
||||||
|
|
||||||
|
def _match_patterns(self, txt: str, source: str, layer: str,
|
||||||
|
position: tuple, result: ParseResult):
|
||||||
|
"""텍스트에 대해 모든 패턴을 매칭하고 결과를 추가."""
|
||||||
|
for param, _desc, pattern, base_conf in _PATTERNS:
|
||||||
|
m = pattern.search(txt)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
groups = m.groups()
|
||||||
|
|
||||||
|
# --- 복합 치수 (BOX, U형) → width + height 분리 ---
|
||||||
|
if param == "_box":
|
||||||
|
w, h = float(groups[0]), float(groups[1])
|
||||||
|
w, w_unit = self._normalize_unit(w, param="width")
|
||||||
|
h, h_unit = self._normalize_unit(h, param="height")
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param="width", value=w, raw_text=txt, source=source,
|
||||||
|
layer=layer, position=position, confidence=base_conf,
|
||||||
|
unit=w_unit,
|
||||||
|
))
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param="height", value=h, raw_text=txt, source=source,
|
||||||
|
layer=layer, position=position, confidence=base_conf,
|
||||||
|
unit=h_unit,
|
||||||
|
))
|
||||||
|
return # 복합 패턴 매칭 시 개별 패턴 중복 방지
|
||||||
|
|
||||||
|
if param == "_uchannel":
|
||||||
|
w, h = float(groups[0]), float(groups[1])
|
||||||
|
w, w_unit = self._normalize_unit(w, param="width")
|
||||||
|
h, h_unit = self._normalize_unit(h, param="height")
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param="width", value=w, raw_text=txt, source=source,
|
||||||
|
layer=layer, position=position, confidence=base_conf,
|
||||||
|
unit=w_unit,
|
||||||
|
))
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param="height", value=h, raw_text=txt, source=source,
|
||||||
|
layer=layer, position=position, confidence=base_conf,
|
||||||
|
unit=h_unit,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- slope (1:N) → 특수 처리 ---
|
||||||
|
if param == "slope":
|
||||||
|
v1, v2 = float(groups[0]), float(groups[1])
|
||||||
|
# 사면 경사비가 아닌 좌표 구분자(218,200)와 혼동 방지
|
||||||
|
# 일반적인 경사비: 1:0.3 ~ 1:5.0
|
||||||
|
if v1 <= 2 and 0.1 <= v2 <= 10.0:
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param="slope", value=v2, raw_text=txt, source=source,
|
||||||
|
layer=layer, position=position, confidence=base_conf,
|
||||||
|
unit="ratio", secondary=v1,
|
||||||
|
))
|
||||||
|
return # slope는 항상 여기서 종료 (다른 패턴과 중복 방지)
|
||||||
|
|
||||||
|
# --- 일반 단일값 ---
|
||||||
|
value = float(groups[0])
|
||||||
|
|
||||||
|
# 관경은 항상 mm 단위
|
||||||
|
if param == "diameter":
|
||||||
|
if value > self.unit_threshold:
|
||||||
|
value /= 1000.0
|
||||||
|
unit = "mm→m"
|
||||||
|
else:
|
||||||
|
unit = "m"
|
||||||
|
else:
|
||||||
|
value, unit = self._normalize_unit(value, param)
|
||||||
|
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param=param, value=value, raw_text=txt, source=source,
|
||||||
|
layer=layer, position=position, confidence=base_conf,
|
||||||
|
unit=unit,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ----- DIMENSION 엔티티 -----
|
||||||
|
|
||||||
|
def _parse_dimension_entities(self, msp, result: ParseResult):
|
||||||
|
"""DIMENSION 엔티티에서 치수 추출.
|
||||||
|
|
||||||
|
DIMENSION 엔티티는 defpoint 좌표와 actual_measurement를 직접 제공하므로
|
||||||
|
가장 신뢰도가 높다.
|
||||||
|
"""
|
||||||
|
for entity in msp:
|
||||||
|
if entity.dxftype() != "DIMENSION":
|
||||||
|
continue
|
||||||
|
|
||||||
|
layer = entity.dxf.layer
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 실제 측정값 (ezdxf가 계산)
|
||||||
|
measurement = entity.dxf.get("actual_measurement", None)
|
||||||
|
if measurement is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
measurement = float(measurement)
|
||||||
|
if measurement <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 치수 방향 판별 (수직 → 높이, 수평 → 폭)
|
||||||
|
param = self._classify_dimension_direction(entity)
|
||||||
|
|
||||||
|
# 텍스트 오버라이드 확인
|
||||||
|
text_override = entity.dxf.get("text", "")
|
||||||
|
raw_text = text_override if text_override else f"[DIM]{measurement:.3f}"
|
||||||
|
|
||||||
|
# 위치: defpoint 중간점
|
||||||
|
try:
|
||||||
|
dp1 = entity.dxf.defpoint
|
||||||
|
dp2 = entity.dxf.defpoint2 if entity.dxf.hasattr("defpoint2") else dp1
|
||||||
|
position = ((dp1.x + dp2.x) / 2, (dp1.y + dp2.y) / 2)
|
||||||
|
except Exception:
|
||||||
|
position = (0.0, 0.0)
|
||||||
|
|
||||||
|
value, unit = self._normalize_unit(measurement, param)
|
||||||
|
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param=param, value=value, raw_text=raw_text,
|
||||||
|
source="dimension", layer=layer, position=position,
|
||||||
|
confidence=0.95, unit=unit,
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _classify_dimension_direction(self, dim_entity) -> str:
|
||||||
|
"""DIMENSION 엔티티의 방향을 분석하여 파라미터 종류를 추론.
|
||||||
|
|
||||||
|
수직(Y 차이 > X 차이) → height
|
||||||
|
수평(X 차이 > Y 차이) → width
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dp1 = dim_entity.dxf.defpoint
|
||||||
|
dp2 = dim_entity.dxf.defpoint2 if dim_entity.dxf.hasattr("defpoint2") else dp1
|
||||||
|
|
||||||
|
dx = abs(dp2.x - dp1.x)
|
||||||
|
dy = abs(dp2.y - dp1.y)
|
||||||
|
|
||||||
|
if dy > dx * 1.5:
|
||||||
|
return "height"
|
||||||
|
elif dx > dy * 1.5:
|
||||||
|
return "width"
|
||||||
|
else:
|
||||||
|
return "length" # 대각선이면 길이로 분류
|
||||||
|
except Exception:
|
||||||
|
return "length"
|
||||||
|
|
||||||
|
# ----- ATTRIB (블록 속성) -----
|
||||||
|
|
||||||
|
def _parse_attrib_entities(self, msp, result: ParseResult):
|
||||||
|
"""INSERT 블록의 ATTRIB에서 치수 추출."""
|
||||||
|
# 속성 태그명 → 파라미터 매핑
|
||||||
|
tag_map = {
|
||||||
|
"HEIGHT": "height", "H": "height", "높이": "height",
|
||||||
|
"WIDTH": "width", "W": "width", "폭": "width", "B": "width",
|
||||||
|
"THICKNESS": "thickness", "T": "thickness", "두께": "thickness",
|
||||||
|
"ELEVATION": "elevation", "EL": "elevation", "표고": "elevation",
|
||||||
|
"DIAMETER": "diameter", "DIA": "diameter", "D": "diameter", "관경": "diameter",
|
||||||
|
"LENGTH": "length", "L": "length", "길이": "length", "연장": "length",
|
||||||
|
}
|
||||||
|
|
||||||
|
for entity in msp:
|
||||||
|
if entity.dxftype() != "INSERT":
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
attribs = list(entity.attribs) if hasattr(entity, "attribs") else []
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
layer = entity.dxf.layer
|
||||||
|
try:
|
||||||
|
pos = entity.dxf.insert
|
||||||
|
position = (pos.x, pos.y)
|
||||||
|
except Exception:
|
||||||
|
position = (0.0, 0.0)
|
||||||
|
|
||||||
|
for attrib in attribs:
|
||||||
|
try:
|
||||||
|
tag = attrib.dxf.tag.strip().upper()
|
||||||
|
text = attrib.dxf.text.strip()
|
||||||
|
|
||||||
|
param = tag_map.get(tag)
|
||||||
|
if not param:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 숫자 추출
|
||||||
|
m = re.search(r"(\d+\.?\d*)", text)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = float(m.group(1))
|
||||||
|
value, unit = self._normalize_unit(value, param)
|
||||||
|
|
||||||
|
result.dimensions.append(ParsedDimension(
|
||||||
|
param=param, value=value, raw_text=f"{tag}={text}",
|
||||||
|
source="attrib", layer=layer, position=position,
|
||||||
|
confidence=0.85, unit=unit,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ----- 유틸리티 -----
|
||||||
|
|
||||||
|
def _normalize_unit(self, value: float, param: str) -> tuple[float, str]:
|
||||||
|
"""값의 단위를 판단하여 미터로 정규화.
|
||||||
|
|
||||||
|
토목 도면 관례:
|
||||||
|
- 관경: 보통 mm (300, 600, 1000 등)
|
||||||
|
- 높이/폭/두께: 보통 m (0.5 ~ 30 정도)
|
||||||
|
- 계획고(EL): 항상 m (해발 기준)
|
||||||
|
"""
|
||||||
|
if param == "elevation":
|
||||||
|
# 계획고는 항상 m 단위 (음수 가능, 큰 값 가능)
|
||||||
|
return value, "m"
|
||||||
|
|
||||||
|
if param == "diameter":
|
||||||
|
if value > self.unit_threshold:
|
||||||
|
return value / 1000.0, "mm→m"
|
||||||
|
return value, "m"
|
||||||
|
|
||||||
|
# spacing(@간격)은 보통 mm
|
||||||
|
if param == "spacing":
|
||||||
|
if value > self.unit_threshold:
|
||||||
|
return value / 1000.0, "mm→m"
|
||||||
|
return value, "m"
|
||||||
|
|
||||||
|
# 일반 치수: 50 이상이면 mm로 간주
|
||||||
|
if value > self.unit_threshold:
|
||||||
|
return value / 1000.0, "mm→m"
|
||||||
|
|
||||||
|
return value, "m"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 공간 연결: 텍스트 치수를 인근 지오메트리에 매칭
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def associate_dimensions_to_structures(
|
||||||
|
parse_result: ParseResult,
|
||||||
|
structure_positions: dict[str, tuple[float, float]],
|
||||||
|
max_distance: float = 50.0,
|
||||||
|
) -> dict[str, list[ParsedDimension]]:
|
||||||
|
"""파싱된 치수를 인근 구조물에 공간적으로 연결.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parse_result: DetailParser.parse() 결과
|
||||||
|
structure_positions: {"구조물명": (x, y), ...} 구조물 중심 좌표
|
||||||
|
max_distance: 최대 연결 거리 (m)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"구조물명": [ParsedDimension, ...], ...}
|
||||||
|
"""
|
||||||
|
if not parse_result.dimensions or not structure_positions:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 구조물 좌표 배열
|
||||||
|
names = list(structure_positions.keys())
|
||||||
|
positions = np.array([structure_positions[n] for n in names])
|
||||||
|
|
||||||
|
associated: dict[str, list[ParsedDimension]] = {n: [] for n in names}
|
||||||
|
|
||||||
|
for dim in parse_result.dimensions:
|
||||||
|
dim_pos = np.array(dim.position)
|
||||||
|
dists = np.linalg.norm(positions - dim_pos, axis=1)
|
||||||
|
min_idx = int(np.argmin(dists))
|
||||||
|
|
||||||
|
if dists[min_idx] <= max_distance:
|
||||||
|
associated[names[min_idx]].append(dim)
|
||||||
|
|
||||||
|
return associated
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 치수 → structure params 매핑
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def dimensions_to_structure_params(
|
||||||
|
dimensions: list[ParsedDimension],
|
||||||
|
) -> dict:
|
||||||
|
"""파싱된 치수 목록을 structure_v1.yaml 호환 파라미터 딕셔너리로 변환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"height": 5.0, "width": 3.0, "z_offset": -2.0, ...}
|
||||||
|
"""
|
||||||
|
# 파라미터별 최고 신뢰도 값 선택
|
||||||
|
best: dict[str, ParsedDimension] = {}
|
||||||
|
for d in dimensions:
|
||||||
|
if d.param not in best or d.confidence > best[d.param].confidence:
|
||||||
|
best[d.param] = d
|
||||||
|
|
||||||
|
params: dict = {}
|
||||||
|
|
||||||
|
if "height" in best:
|
||||||
|
params["height"] = best["height"].value
|
||||||
|
|
||||||
|
if "width" in best:
|
||||||
|
params["width"] = best["width"].value
|
||||||
|
|
||||||
|
if "thickness" in best:
|
||||||
|
params["thickness"] = best["thickness"].value
|
||||||
|
|
||||||
|
if "diameter" in best:
|
||||||
|
params["diameter"] = best["diameter"].value
|
||||||
|
|
||||||
|
if "length" in best:
|
||||||
|
params["length"] = best["length"].value
|
||||||
|
|
||||||
|
if "elevation" in best:
|
||||||
|
params["elevation"] = best["elevation"].value
|
||||||
|
|
||||||
|
if "slope" in best:
|
||||||
|
params["slope_ratio"] = best["slope"].value
|
||||||
|
|
||||||
|
if "embedment" in best:
|
||||||
|
params["embedment"] = best["embedment"].value
|
||||||
|
|
||||||
|
if "radius" in best:
|
||||||
|
params["radius"] = best["radius"].value
|
||||||
|
|
||||||
|
return params
|
||||||
574
dxf_geometry.py
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
"""DXF 지오메트리 추출 공통 유틸리티.
|
||||||
|
|
||||||
|
모든 구조물 템플릿이 공유하는 DXF 처리 로직:
|
||||||
|
1. 단위 자동 감지 (mm vs m)
|
||||||
|
2. 주석/치수/해치 레이어 자동 필터링
|
||||||
|
3. LINE/LWPOLYLINE/POLYLINE/ARC/SPLINE/CIRCLE 통합 추출
|
||||||
|
4. 뷰 영역 자동 분할 (평면/정면/측면)
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
from dxf_geometry import extract_structural_geometry
|
||||||
|
|
||||||
|
result = extract_structural_geometry(dxf_path)
|
||||||
|
for layer_name, shapes in result.by_layer.items():
|
||||||
|
for shape in shapes:
|
||||||
|
# shape.points: [(x, y), ...] (단위 정규화 완료)
|
||||||
|
# shape.closed: bool
|
||||||
|
# shape.kind: "polyline" | "line" | "arc" | ...
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 구조
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Shape:
|
||||||
|
"""추출된 단일 지오메트리 요소 (단위: m)."""
|
||||||
|
kind: str # "polyline" | "line" | "arc" | "circle"
|
||||||
|
layer: str
|
||||||
|
points: list # [(x, y), ...] — m 단위
|
||||||
|
closed: bool = False
|
||||||
|
extra: dict = field(default_factory=dict) # 부가정보 (center, radius, angles 등)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bbox(self) -> tuple[float, float, float, float]:
|
||||||
|
"""(xmin, ymin, xmax, ymax)"""
|
||||||
|
if not self.points:
|
||||||
|
return (0, 0, 0, 0)
|
||||||
|
arr = np.array(self.points)
|
||||||
|
return (float(arr[:, 0].min()), float(arr[:, 1].min()),
|
||||||
|
float(arr[:, 0].max()), float(arr[:, 1].max()))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def centroid(self) -> tuple[float, float]:
|
||||||
|
if not self.points:
|
||||||
|
return (0, 0)
|
||||||
|
arr = np.array(self.points)
|
||||||
|
return (float(arr[:, 0].mean()), float(arr[:, 1].mean()))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def area(self) -> float:
|
||||||
|
"""closed polygon 면적 (shoelace). open이면 0."""
|
||||||
|
if not self.closed or len(self.points) < 3:
|
||||||
|
return 0.0
|
||||||
|
n = len(self.points)
|
||||||
|
s = 0.0
|
||||||
|
for i in range(n):
|
||||||
|
x1, y1 = self.points[i]
|
||||||
|
x2, y2 = self.points[(i + 1) % n]
|
||||||
|
s += x1 * y2 - x2 * y1
|
||||||
|
return abs(s) / 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self) -> float:
|
||||||
|
"""폴리라인 누적 길이."""
|
||||||
|
if len(self.points) < 2:
|
||||||
|
return 0.0
|
||||||
|
arr = np.array(self.points)
|
||||||
|
diffs = np.diff(arr, axis=0)
|
||||||
|
return float(np.sum(np.linalg.norm(diffs, axis=1)))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeometryResult:
|
||||||
|
"""DXF에서 추출된 전체 지오메트리."""
|
||||||
|
dxf_path: str
|
||||||
|
unit_scale: float = 1.0 # 원본 → m 변환 계수 (mm이면 0.001)
|
||||||
|
detected_unit: str = "m" # "mm" | "m"
|
||||||
|
shapes: list[Shape] = field(default_factory=list)
|
||||||
|
|
||||||
|
# 분류별 접근 편의 속성
|
||||||
|
by_layer: dict[str, list[Shape]] = field(default_factory=dict)
|
||||||
|
closed_shapes: list[Shape] = field(default_factory=list)
|
||||||
|
open_shapes: list[Shape] = field(default_factory=list)
|
||||||
|
|
||||||
|
# 메타
|
||||||
|
total_bounds: tuple[float, float, float, float] = (0, 0, 0, 0)
|
||||||
|
raw_text_count: int = 0
|
||||||
|
dimension_count: int = 0
|
||||||
|
excluded_layers: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
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) -> Shape | None:
|
||||||
|
"""가장 긴 폴리라인 반환."""
|
||||||
|
polys = [s for s in self.shapes if s.kind == "polyline" and len(s.points) > 2]
|
||||||
|
if not polys:
|
||||||
|
return None
|
||||||
|
return max(polys, key=lambda s: s.length)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 레이어 필터링
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 구조물 지오메트리로 간주하지 않는 레이어 이름 패턴
|
||||||
|
# (대소문자 무시, 부분 매칭)
|
||||||
|
# 주의: MZ-HIDL(숨김선)은 도면에 따라 주 구조물이 있을 수 있어 제외 대상에서 뺌
|
||||||
|
_EXCLUDE_LAYER_PATTERNS = [
|
||||||
|
# 치수선
|
||||||
|
r"^DIM$", r"-DIM$", r"^CS-DIM", r"^MZ-DIM", r"치수선",
|
||||||
|
# 해치/패턴
|
||||||
|
r"^HATCH$", r"-HATCH$", r"^CS-PATT", r"^CZ-PATT", r"^MZ-HATCH",
|
||||||
|
r"^CS-HATCH", r"^PATT-", r"해치",
|
||||||
|
# 텍스트 (레이어명이 명확히 TEXT 전용인 경우만)
|
||||||
|
r"^CS-TEXT$", r"^CZ-TEXT$", r"^CX-BORD", r"^TEXT$", r"^문자$",
|
||||||
|
r"^CZ-TEX[0-9]", r"^텍스트$",
|
||||||
|
# 지시선/리더
|
||||||
|
r"^CS-LEAT$", r"^CS-LEAL$", r"^CS-LEA$", r"^지시선$", r"^LEADER$",
|
||||||
|
# 표/프레임
|
||||||
|
r"^CS-TABL",
|
||||||
|
# 중심선 (완전히 중심선 전용 레이어만)
|
||||||
|
r"^중심선\(", r"중심선$",
|
||||||
|
# 기타 주석
|
||||||
|
r"^Defpoints$", r"^심볼$", r"^SYMB$", r"^AA-",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded_layer(layer_name: str) -> bool:
|
||||||
|
"""레이어명이 주석/치수/해치 등 비구조 레이어인지 확인."""
|
||||||
|
if not layer_name:
|
||||||
|
return False
|
||||||
|
return any(re.search(pat, layer_name, re.IGNORECASE) for pat in _EXCLUDE_LAYER_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 단위 자동 감지
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def detect_unit_scale(doc) -> tuple[float, str]:
|
||||||
|
"""DXF에서 단위를 자동 감지하여 m로 변환할 scale 반환.
|
||||||
|
|
||||||
|
한국 토목도면은 $INSUNITS가 잘못 설정되는 경우가 많으므로
|
||||||
|
DIMENSION 값을 최우선 판단 기준으로 사용.
|
||||||
|
|
||||||
|
판단 순서:
|
||||||
|
1. DIMENSION 값 분포 (최우선) — 치수 값이 설계 의도를 반영
|
||||||
|
2. $INSUNITS header variable (보조 확인)
|
||||||
|
3. 전체 bbox 크기 (폴백)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(scale, unit_name): 원본 × scale = m. unit_name은 "mm"/"m"/"cm"
|
||||||
|
"""
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
# 1) DIMENSION 값 분포 분석 (가장 신뢰할 만한 신호)
|
||||||
|
dim_values = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxftype() == "DIMENSION":
|
||||||
|
try:
|
||||||
|
m = e.dxf.get("actual_measurement", None)
|
||||||
|
if m is not None and m > 0:
|
||||||
|
dim_values.append(float(m))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(dim_values) >= 3:
|
||||||
|
median_val = float(np.median(dim_values))
|
||||||
|
# 토목 구조물 치수 관례:
|
||||||
|
# - m 단위: 중앙값이 보통 0.5~100 범위
|
||||||
|
# - mm 단위: 중앙값이 보통 100~100,000 범위
|
||||||
|
# - cm 단위: 중앙값이 50~10,000 범위 (드뭄)
|
||||||
|
if median_val >= 100:
|
||||||
|
return 0.001, "mm"
|
||||||
|
elif median_val >= 1 and median_val < 100:
|
||||||
|
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))
|
||||||
|
if insunits in _INSUNITS_TO_SCALE:
|
||||||
|
return _INSUNITS_TO_SCALE[insunits]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3) Bbox 크기로 추정
|
||||||
|
try:
|
||||||
|
# 엔티티 bbox 직접 계산 (header extents는 부정확할 수 있음)
|
||||||
|
all_x = []
|
||||||
|
all_y = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxftype() in ("LWPOLYLINE", "LINE", "CIRCLE", "ARC"):
|
||||||
|
try:
|
||||||
|
if e.dxftype() == "LWPOLYLINE":
|
||||||
|
for p in e.get_points():
|
||||||
|
all_x.append(p[0])
|
||||||
|
all_y.append(p[1])
|
||||||
|
elif e.dxftype() == "LINE":
|
||||||
|
all_x.extend([e.dxf.start.x, e.dxf.end.x])
|
||||||
|
all_y.extend([e.dxf.start.y, e.dxf.end.y])
|
||||||
|
elif e.dxftype() in ("CIRCLE", "ARC"):
|
||||||
|
all_x.append(e.dxf.center.x)
|
||||||
|
all_y.append(e.dxf.center.y)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 성능: 처음 1000개만 샘플
|
||||||
|
if len(all_x) > 1000:
|
||||||
|
break
|
||||||
|
|
||||||
|
if all_x and all_y:
|
||||||
|
diag = math.sqrt((max(all_x) - min(all_x)) ** 2 + (max(all_y) - min(all_y)) ** 2)
|
||||||
|
# 토목구조물 전체 크기: 수 m ~ 수백 m
|
||||||
|
# mm이면 대각선 수천 ~ 수십만
|
||||||
|
if diag > 2000:
|
||||||
|
return 0.001, "mm"
|
||||||
|
elif diag < 500:
|
||||||
|
return 1.0, "m"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 기본값: 토목도면은 대부분 mm
|
||||||
|
return 0.001, "mm"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메인 추출 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract_structural_geometry(
|
||||||
|
dxf_path: str | Path,
|
||||||
|
exclude_layers: bool = True,
|
||||||
|
include_open: bool = True,
|
||||||
|
min_points: int = 2,
|
||||||
|
unit_override: str | None = None,
|
||||||
|
explode_blocks: bool = False,
|
||||||
|
max_block_depth: int = 4,
|
||||||
|
) -> GeometryResult:
|
||||||
|
"""DXF에서 구조물 지오메트리를 추출.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dxf_path: DXF 파일 경로
|
||||||
|
exclude_layers: True면 주석/치수/해치 레이어 제외
|
||||||
|
include_open: False면 closed 지오메트리만
|
||||||
|
min_points: 최소 점 개수
|
||||||
|
unit_override: "mm" | "m" 중 하나면 자동 감지 무시
|
||||||
|
explode_blocks: True면 INSERT(블록 참조)를 virtual_entities로 재귀 확장.
|
||||||
|
평면도에 구조물이 블록으로 배치된 경우 필수.
|
||||||
|
max_block_depth: 중첩 블록 재귀 최대 깊이
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GeometryResult
|
||||||
|
"""
|
||||||
|
doc = ezdxf.readfile(str(dxf_path))
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
# 단위 감지
|
||||||
|
if unit_override == "mm":
|
||||||
|
unit_scale, unit_name = 0.001, "mm"
|
||||||
|
elif unit_override == "m":
|
||||||
|
unit_scale, unit_name = 1.0, "m"
|
||||||
|
else:
|
||||||
|
unit_scale, unit_name = detect_unit_scale(doc)
|
||||||
|
|
||||||
|
result = GeometryResult(
|
||||||
|
dxf_path=str(dxf_path),
|
||||||
|
unit_scale=unit_scale,
|
||||||
|
detected_unit=unit_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
excluded_set = set()
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
layer = inherited_layer if inherited_layer and raw_layer in ("", "0") else raw_layer
|
||||||
|
|
||||||
|
# 레이어 필터
|
||||||
|
if exclude_layers and is_excluded_layer(layer):
|
||||||
|
excluded_set.add(layer)
|
||||||
|
return
|
||||||
|
|
||||||
|
# INSERT 재귀 확장
|
||||||
|
if etype == "INSERT":
|
||||||
|
if not explode_blocks or depth >= max_block_depth:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for sub in entity.virtual_entities():
|
||||||
|
_process_entity(sub, inherited_layer=layer, depth=depth + 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# 엔티티별 추출
|
||||||
|
try:
|
||||||
|
shape = _extract_entity(entity, etype, unit_scale)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if shape is None:
|
||||||
|
return
|
||||||
|
if len(shape.points) < min_points:
|
||||||
|
return
|
||||||
|
if not include_open and not shape.closed:
|
||||||
|
return
|
||||||
|
|
||||||
|
shape.layer = layer
|
||||||
|
result.shapes.append(shape)
|
||||||
|
|
||||||
|
# 레이어별 집계
|
||||||
|
result.by_layer.setdefault(layer, []).append(shape)
|
||||||
|
|
||||||
|
if shape.closed:
|
||||||
|
result.closed_shapes.append(shape)
|
||||||
|
else:
|
||||||
|
result.open_shapes.append(shape)
|
||||||
|
|
||||||
|
for entity in msp:
|
||||||
|
_process_entity(entity)
|
||||||
|
|
||||||
|
# 메타 카운트
|
||||||
|
for e in msp:
|
||||||
|
et = e.dxftype()
|
||||||
|
if et in ("TEXT", "MTEXT"):
|
||||||
|
result.raw_text_count += 1
|
||||||
|
elif et == "DIMENSION":
|
||||||
|
result.dimension_count += 1
|
||||||
|
|
||||||
|
result.excluded_layers = sorted(excluded_set)
|
||||||
|
|
||||||
|
# 전체 bbox
|
||||||
|
if result.shapes:
|
||||||
|
all_pts = []
|
||||||
|
for s in result.shapes:
|
||||||
|
all_pts.extend(s.points)
|
||||||
|
arr = np.array(all_pts)
|
||||||
|
result.total_bounds = (
|
||||||
|
float(arr[:, 0].min()), float(arr[:, 1].min()),
|
||||||
|
float(arr[:, 0].max()), float(arr[:, 1].max()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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()]
|
||||||
|
if len(pts) < 2:
|
||||||
|
return None
|
||||||
|
return Shape(kind="polyline", layer="", points=pts, closed=entity.closed)
|
||||||
|
|
||||||
|
elif etype == "POLYLINE":
|
||||||
|
pts = [(v.dxf.location.x * scale, v.dxf.location.y * scale) for v in entity.vertices]
|
||||||
|
if len(pts) < 2:
|
||||||
|
return None
|
||||||
|
return Shape(kind="polyline", layer="", points=pts, closed=entity.is_closed)
|
||||||
|
|
||||||
|
elif etype == "LINE":
|
||||||
|
s = entity.dxf.start
|
||||||
|
e = entity.dxf.end
|
||||||
|
return Shape(
|
||||||
|
kind="line", layer="",
|
||||||
|
points=[(s.x * scale, s.y * scale), (e.x * scale, e.y * scale)],
|
||||||
|
closed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif etype == "ARC":
|
||||||
|
c = entity.dxf.center
|
||||||
|
r = entity.dxf.radius * scale
|
||||||
|
sa = math.radians(entity.dxf.start_angle)
|
||||||
|
ea = math.radians(entity.dxf.end_angle)
|
||||||
|
if ea < sa:
|
||||||
|
ea += 2 * math.pi
|
||||||
|
n = max(8, int((ea - sa) / math.radians(5)))
|
||||||
|
pts = []
|
||||||
|
for i in range(n + 1):
|
||||||
|
t = sa + (ea - sa) * i / n
|
||||||
|
pts.append((c.x * scale + r * math.cos(t), c.y * scale + r * math.sin(t)))
|
||||||
|
return Shape(
|
||||||
|
kind="arc", layer="", points=pts, closed=False,
|
||||||
|
extra={"center": (c.x * scale, c.y * scale), "radius": r,
|
||||||
|
"start_angle": sa, "end_angle": ea},
|
||||||
|
)
|
||||||
|
|
||||||
|
elif etype == "CIRCLE":
|
||||||
|
c = entity.dxf.center
|
||||||
|
r = entity.dxf.radius * scale
|
||||||
|
n = 32
|
||||||
|
pts = []
|
||||||
|
for i in range(n + 1):
|
||||||
|
t = 2 * math.pi * i / n
|
||||||
|
pts.append((c.x * scale + r * math.cos(t), c.y * scale + r * math.sin(t)))
|
||||||
|
return Shape(
|
||||||
|
kind="circle", layer="", points=pts, closed=True,
|
||||||
|
extra={"center": (c.x * scale, c.y * scale), "radius": r},
|
||||||
|
)
|
||||||
|
|
||||||
|
elif etype == "SPLINE":
|
||||||
|
try:
|
||||||
|
# 제어점으로 근사
|
||||||
|
pts = [(pt[0] * scale, pt[1] * scale) for pt in entity.control_points]
|
||||||
|
if len(pts) < 2:
|
||||||
|
return None
|
||||||
|
return Shape(
|
||||||
|
kind="polyline", layer="", points=pts, closed=entity.closed,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif etype == "ELLIPSE":
|
||||||
|
# 타원 → 폴리라인 샘플링
|
||||||
|
try:
|
||||||
|
c = entity.dxf.center
|
||||||
|
# flattening으로 점 목록 얻기
|
||||||
|
pts = []
|
||||||
|
for pt in entity.flattening(distance=0.1):
|
||||||
|
pts.append((pt.x * scale, pt.y * scale))
|
||||||
|
if len(pts) < 2:
|
||||||
|
return None
|
||||||
|
return Shape(kind="polyline", layer="", points=pts, closed=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 뷰 영역 분할 (평면/정면/측면)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def split_views_by_y(result: GeometryResult, n_views: int = 2) -> list[GeometryResult]:
|
||||||
|
"""Y 좌표 분포로 도면을 n개 뷰로 분할.
|
||||||
|
|
||||||
|
토목도면은 보통 평면(위)/정면(아래) 또는 측면/단면을 수직 배치.
|
||||||
|
"""
|
||||||
|
if not result.shapes or n_views < 2:
|
||||||
|
return [result]
|
||||||
|
|
||||||
|
# Y 좌표 집계
|
||||||
|
y_vals = []
|
||||||
|
for s in result.shapes:
|
||||||
|
arr = np.array(s.points)
|
||||||
|
y_vals.append(arr[:, 1].mean())
|
||||||
|
y_vals = np.array(y_vals)
|
||||||
|
|
||||||
|
# 분위수로 분할
|
||||||
|
thresholds = np.quantile(y_vals, [i / n_views for i in range(1, n_views)])
|
||||||
|
|
||||||
|
views = [[] for _ in range(n_views)]
|
||||||
|
for i, s in enumerate(result.shapes):
|
||||||
|
bucket = n_views - 1
|
||||||
|
for ti, t in enumerate(thresholds):
|
||||||
|
if y_vals[i] <= t:
|
||||||
|
bucket = ti
|
||||||
|
break
|
||||||
|
views[bucket].append(s)
|
||||||
|
|
||||||
|
# 각 뷰를 GeometryResult로 래핑
|
||||||
|
out = []
|
||||||
|
for shapes in views:
|
||||||
|
v = GeometryResult(
|
||||||
|
dxf_path=result.dxf_path,
|
||||||
|
unit_scale=result.unit_scale,
|
||||||
|
detected_unit=result.detected_unit,
|
||||||
|
)
|
||||||
|
v.shapes = shapes
|
||||||
|
for s in shapes:
|
||||||
|
v.by_layer.setdefault(s.layer, []).append(s)
|
||||||
|
if s.closed:
|
||||||
|
v.closed_shapes.append(s)
|
||||||
|
else:
|
||||||
|
v.open_shapes.append(s)
|
||||||
|
if shapes:
|
||||||
|
all_pts = []
|
||||||
|
for s in shapes:
|
||||||
|
all_pts.extend(s.points)
|
||||||
|
arr = np.array(all_pts)
|
||||||
|
v.total_bounds = (
|
||||||
|
float(arr[:, 0].min()), float(arr[:, 1].min()),
|
||||||
|
float(arr[:, 0].max()), float(arr[:, 1].max()),
|
||||||
|
)
|
||||||
|
out.append(v)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 편의 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract_all(dxf_paths: list[str], **kwargs) -> GeometryResult:
|
||||||
|
"""여러 DXF 파일을 모두 파싱하여 단일 GeometryResult로 반환."""
|
||||||
|
combined = GeometryResult(dxf_path=";".join(dxf_paths))
|
||||||
|
|
||||||
|
first_scale = None
|
||||||
|
for p in dxf_paths:
|
||||||
|
try:
|
||||||
|
r = extract_structural_geometry(p, **kwargs)
|
||||||
|
if first_scale is None:
|
||||||
|
first_scale = r.unit_scale
|
||||||
|
combined.unit_scale = r.unit_scale
|
||||||
|
combined.detected_unit = r.detected_unit
|
||||||
|
combined.shapes.extend(r.shapes)
|
||||||
|
for layer, shapes in r.by_layer.items():
|
||||||
|
combined.by_layer.setdefault(layer, []).extend(shapes)
|
||||||
|
combined.closed_shapes.extend(r.closed_shapes)
|
||||||
|
combined.open_shapes.extend(r.open_shapes)
|
||||||
|
combined.raw_text_count += r.raw_text_count
|
||||||
|
combined.dimension_count += r.dimension_count
|
||||||
|
combined.excluded_layers.extend(r.excluded_layers)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 추출 실패 ({p}): {e}")
|
||||||
|
|
||||||
|
if combined.shapes:
|
||||||
|
all_pts = []
|
||||||
|
for s in combined.shapes:
|
||||||
|
all_pts.extend(s.points)
|
||||||
|
arr = np.array(all_pts)
|
||||||
|
combined.total_bounds = (
|
||||||
|
float(arr[:, 0].min()), float(arr[:, 1].min()),
|
||||||
|
float(arr[:, 0].max()), float(arr[:, 1].max()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 샘플 테스트
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
paths = sys.argv[1:]
|
||||||
|
if not paths:
|
||||||
|
base = Path("Gate_Sample")
|
||||||
|
paths = [
|
||||||
|
str(base / "12995740-M40-001 여수로 수문 설치도(1/2).dxf"),
|
||||||
|
str(base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in paths:
|
||||||
|
print(f"\n=== {Path(p).name} ===")
|
||||||
|
r = extract_structural_geometry(p)
|
||||||
|
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(" 레이어별:")
|
||||||
|
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
|
||||||
|
print(f" bbox: ({b[0]:.2f}, {b[1]:.2f}) ~ ({b[2]:.2f}, {b[3]:.2f}) m")
|
||||||
|
print(f" 제외된 레이어: {r.excluded_layers[:5]}")
|
||||||
|
if r.largest_closed():
|
||||||
|
lc = r.largest_closed()
|
||||||
|
print(f" 최대 closed: {lc.layer} ({lc.area:.2f} m², {len(lc.points)}pts)")
|
||||||
234
filename_classifier.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""DXF 파일명에서 구조물 종류를 자동 추정.
|
||||||
|
|
||||||
|
토목 설계 도면 파일명에는 보통 구조물 종류가 포함됨:
|
||||||
|
"여수로 수문 설치도.dxf" → spillway_gate
|
||||||
|
"좌안옹벽 일반도.dxf" → retaining_wall
|
||||||
|
"신설 취수탑 설비 설치도.dxf" → building
|
||||||
|
"OO 교량 상세도.dxf" → bridge
|
||||||
|
"터널 갱구 설치도.dxf" → tunnel_portal
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
from filename_classifier import classify_by_filename
|
||||||
|
tid = classify_by_filename("좌안옹벽 일반도.dxf")
|
||||||
|
# → "retaining_wall"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 구조물 유형별 키워드 패턴
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 우선순위가 높은 것을 먼저 (더 구체적인 패턴 → 일반적인 패턴)
|
||||||
|
|
||||||
|
FILENAME_PATTERNS = {
|
||||||
|
"spillway_gate": [
|
||||||
|
# 여수로 수문 관련
|
||||||
|
r"여수로.*수문",
|
||||||
|
r"수문.*여수로",
|
||||||
|
r"여수로.*게이트",
|
||||||
|
r"래디얼.*게이트",
|
||||||
|
r"테인터.*게이트",
|
||||||
|
r"tainter.*gate",
|
||||||
|
r"radial.*gate",
|
||||||
|
r"spillway.*gate",
|
||||||
|
r"spillway",
|
||||||
|
# 수문 단독
|
||||||
|
r"(?<![\uAC00-\uD7A3])수문(?![\uAC00-\uD7A3])", # 한글 경계
|
||||||
|
r"gate\b",
|
||||||
|
],
|
||||||
|
|
||||||
|
"retaining_wall": [
|
||||||
|
r"옹벽",
|
||||||
|
r"방벽",
|
||||||
|
r"(?:좌안|우안).*옹벽",
|
||||||
|
r"retaining.*wall",
|
||||||
|
r"\bwall\b(?!.*gate)", # wall but not "gate wall" etc.
|
||||||
|
],
|
||||||
|
|
||||||
|
"bridge": [
|
||||||
|
r"교량",
|
||||||
|
r"공도교",
|
||||||
|
r"연륙교",
|
||||||
|
r"인도교",
|
||||||
|
r"bridge",
|
||||||
|
r"(?<![가-힣a-z])교(?:\s|\.|_|$)", # 단독 "교"
|
||||||
|
r"viaduct",
|
||||||
|
r"overpass",
|
||||||
|
],
|
||||||
|
|
||||||
|
"tunnel_portal": [
|
||||||
|
r"터널",
|
||||||
|
r"갱구",
|
||||||
|
r"tunnel",
|
||||||
|
r"portal",
|
||||||
|
r"굴착.*입구",
|
||||||
|
],
|
||||||
|
|
||||||
|
"intake_tower": [
|
||||||
|
r"취수탑",
|
||||||
|
r"intake.*tower",
|
||||||
|
r"intake.*structure",
|
||||||
|
],
|
||||||
|
|
||||||
|
"valve_chamber": [
|
||||||
|
r"제수변실",
|
||||||
|
r"밸브실",
|
||||||
|
r"변실",
|
||||||
|
r"valve.*(?:room|chamber)",
|
||||||
|
r"도수관로",
|
||||||
|
r"도수.*관",
|
||||||
|
],
|
||||||
|
|
||||||
|
"building": [
|
||||||
|
# 일반 건축물
|
||||||
|
r"관리사무",
|
||||||
|
r"사무소",
|
||||||
|
r"관리동",
|
||||||
|
r"수문조작실",
|
||||||
|
r"변전소",
|
||||||
|
r"기계실",
|
||||||
|
r"전기실",
|
||||||
|
r"건축물",
|
||||||
|
r"건물",
|
||||||
|
r"building",
|
||||||
|
r"office",
|
||||||
|
r"powerhouse",
|
||||||
|
r"발전소",
|
||||||
|
r"펌프장",
|
||||||
|
r"pump(?:ing)?.*station",
|
||||||
|
r"조정지",
|
||||||
|
],
|
||||||
|
|
||||||
|
# Generic은 마지막 폴백이므로 패턴 없음
|
||||||
|
}
|
||||||
|
|
||||||
|
# 구조물 종류 힌트가 될 수 있는 추가 키워드 (신뢰도 낮음)
|
||||||
|
SECONDARY_KEYWORDS = {
|
||||||
|
"spillway_gate": ["스필웨이", "월류", "유수전환", "가물막이", "cofferdam"],
|
||||||
|
"retaining_wall": ["절토", "성토사면", "법면"],
|
||||||
|
"bridge": ["교대", "교각", "상판", "pier", "abutment", "deck"],
|
||||||
|
"tunnel_portal": ["터파기", "갱내"],
|
||||||
|
"building": ["사택", "청사", "센터"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 분류 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def classify_by_filename(filename: str) -> str | None:
|
||||||
|
"""파일명에서 구조물 유형을 추정.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 파일 경로 또는 파일명
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
template_id 문자열 ("spillway_gate", "building" 등) 또는
|
||||||
|
확실한 매칭이 없으면 None
|
||||||
|
"""
|
||||||
|
# 파일명만 추출 (경로/확장자 제거)
|
||||||
|
name = Path(filename).stem.lower()
|
||||||
|
|
||||||
|
# 구두점/숫자 코드 노이즈 제거 (예: "12995740-M40-001")
|
||||||
|
# 한글/영문 단어만 남김
|
||||||
|
cleaned = re.sub(r"[\d\-_/\\]+", " ", name)
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
# 주 키워드 우선 매칭
|
||||||
|
for template_id, patterns in FILENAME_PATTERNS.items():
|
||||||
|
for pat in patterns:
|
||||||
|
if re.search(pat, cleaned, re.IGNORECASE):
|
||||||
|
return template_id
|
||||||
|
|
||||||
|
# 보조 키워드로 재시도
|
||||||
|
best_id = None
|
||||||
|
best_count = 0
|
||||||
|
for template_id, keywords in SECONDARY_KEYWORDS.items():
|
||||||
|
count = sum(1 for kw in keywords if kw.lower() in cleaned)
|
||||||
|
if count > best_count:
|
||||||
|
best_count = count
|
||||||
|
best_id = template_id
|
||||||
|
|
||||||
|
if best_count >= 1:
|
||||||
|
return best_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_by_filenames(filenames: list[str]) -> str | None:
|
||||||
|
"""여러 파일의 이름을 종합해서 가장 가능성 높은 유형 추정."""
|
||||||
|
votes = {}
|
||||||
|
for f in filenames:
|
||||||
|
tid = classify_by_filename(f)
|
||||||
|
if tid:
|
||||||
|
votes[tid] = votes.get(tid, 0) + 1
|
||||||
|
|
||||||
|
if not votes:
|
||||||
|
return None
|
||||||
|
# 최다 득표
|
||||||
|
return max(votes.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_with_confidence(filename: str) -> tuple[str | None, float]:
|
||||||
|
"""추정 결과 + 신뢰도 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(template_id, confidence): confidence ∈ [0.0, 1.0]
|
||||||
|
"""
|
||||||
|
name = Path(filename).stem.lower()
|
||||||
|
cleaned = re.sub(r"[\d\-_/\\]+", " ", name)
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
# 주 키워드 매칭 개수 및 매칭된 패턴 확인
|
||||||
|
for template_id, patterns in FILENAME_PATTERNS.items():
|
||||||
|
matched_patterns = [pat for pat in patterns
|
||||||
|
if re.search(pat, cleaned, re.IGNORECASE)]
|
||||||
|
|
||||||
|
if matched_patterns:
|
||||||
|
# 매칭 개수에 따라 신뢰도 계산
|
||||||
|
# 1개 매칭 → 0.75, 2개 → 0.85, 3개+ → 0.95
|
||||||
|
conf = min(0.95, 0.65 + 0.1 * len(matched_patterns))
|
||||||
|
return template_id, conf
|
||||||
|
|
||||||
|
# 보조 키워드 시도
|
||||||
|
for template_id, keywords in SECONDARY_KEYWORDS.items():
|
||||||
|
matched = [kw for kw in keywords if kw.lower() in cleaned]
|
||||||
|
if matched:
|
||||||
|
conf = min(0.6, 0.3 + 0.1 * len(matched))
|
||||||
|
return template_id, conf
|
||||||
|
|
||||||
|
return None, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 테스트
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_cases = [
|
||||||
|
"12995740-M40-001 여수로 수문 설치도(1/2).dxf",
|
||||||
|
"12995740-M40-002 여수로 수문 설치도(2/2).dxf",
|
||||||
|
"12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||||||
|
"12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf",
|
||||||
|
"12996710-M43-002 신설 제수변실 설비 배치도.dxf",
|
||||||
|
"1. 좌안옹벽 일반도 작성(2026.0109).dxf",
|
||||||
|
"사연댐 전체계획 평면도.dxf",
|
||||||
|
"A-Line 교량 상세도.dxf",
|
||||||
|
"B-Road 터널 갱구 일반도.dxf",
|
||||||
|
"관리사무소 평면도.dxf",
|
||||||
|
"P-Station 펌프장 설치도.dxf",
|
||||||
|
"random_file.dxf",
|
||||||
|
]
|
||||||
|
|
||||||
|
print("파일명 기반 구조물 유형 추정:")
|
||||||
|
print("=" * 70)
|
||||||
|
for f in test_cases:
|
||||||
|
tid, conf = suggest_with_confidence(f)
|
||||||
|
if tid:
|
||||||
|
print(f" [{tid:16}] ({conf:.0%}) {f}")
|
||||||
|
else:
|
||||||
|
print(f" [{'─' * 16}] (미매칭) {f}")
|
||||||
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)
|
||||||
730
gate_3d_builder.py
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
"""여수로 수문 구조물 3D 파라메트릭 빌더.
|
||||||
|
|
||||||
|
GateParams 객체를 받아 PyVista 메쉬들을 생성한다:
|
||||||
|
- 여수로 본체 (ogee 프로파일을 span 방향으로 extrude)
|
||||||
|
- 교각 (pier) n+1개
|
||||||
|
- 래디얼 게이트 (Tainter gate) n개
|
||||||
|
- 공도교 (service bridge)
|
||||||
|
- 여수로 개폐장치 (gate hoist) — pier 상면에 embedded
|
||||||
|
|
||||||
|
좌표계:
|
||||||
|
- X: dam axis (span, 수문이 나란히 배치되는 방향)
|
||||||
|
- Y: 유출방향 (upstream → downstream)
|
||||||
|
- Z: 표고 (해발 m)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
from gate_parser import GateParams
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 재질 색상
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
|
"concrete": "#B8B5A8", # 콘크리트
|
||||||
|
"pier": "#A8A59B", # 교각 (약간 밝게)
|
||||||
|
"gate_panel": "#3D4A5C", # 수문 강재 (암회색)
|
||||||
|
"gate_frame": "#5A4A3A", # 수문 프레임
|
||||||
|
"bridge_deck": "#8B8B8B", # 공도교 상판
|
||||||
|
"bridge_rail": "#4A4A4A", # 난간
|
||||||
|
"gate_hoist": "#D4A373", # 여수로 개폐장치 본체
|
||||||
|
"gate_hoist_roof": "#3A3A3A", # 개폐장치 지붕
|
||||||
|
"water": "#3A7AA8", # 수면
|
||||||
|
"apron": "#9A968C", # 여수로 에이프런
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 빌더
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class GateBuilder:
|
||||||
|
"""파라미터 기반 여수로 3D 모델 빌더."""
|
||||||
|
|
||||||
|
def __init__(self, params: GateParams):
|
||||||
|
self.params = params
|
||||||
|
self.meshes: list[tuple[pv.PolyData, str, float]] = [] # (mesh, color, opacity)
|
||||||
|
|
||||||
|
def build_all(self) -> list[tuple[pv.PolyData, str, float]]:
|
||||||
|
"""모든 구성요소를 빌드하여 리스트 반환.
|
||||||
|
|
||||||
|
부속 구조물은 GateParams의 has_* 플래그가 True일 때만 빌드 (Phase A).
|
||||||
|
주 구조물(본체/교각)은 도면 기하(Phase B')를 우선, 없으면 parametric 폴백.
|
||||||
|
"""
|
||||||
|
self.meshes = []
|
||||||
|
self._build_spillway_body()
|
||||||
|
self._build_piers()
|
||||||
|
self._build_radial_gates()
|
||||||
|
if getattr(self.params, "has_service_bridge", False):
|
||||||
|
self._build_service_bridge()
|
||||||
|
if getattr(self.params, "has_hoist_housings", True):
|
||||||
|
self._build_gate_hoists()
|
||||||
|
if getattr(self.params, "has_water_surface", True):
|
||||||
|
self._build_water_surface()
|
||||||
|
if getattr(self.params, "has_downstream_apron", True):
|
||||||
|
self._build_downstream_apron()
|
||||||
|
return self.meshes
|
||||||
|
|
||||||
|
# --- 여수로 본체 ---
|
||||||
|
|
||||||
|
def _build_spillway_body(self):
|
||||||
|
"""Ogee 프로파일을 span 방향으로 extrude하여 본체 생성."""
|
||||||
|
p = self.params
|
||||||
|
profile = p.ogee_profile
|
||||||
|
|
||||||
|
if len(profile) < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 프로파일 → 폐합 다각형으로 확장 (바닥 포함)
|
||||||
|
xs = [pt[0] for pt in profile]
|
||||||
|
zs = [pt[1] for pt in profile]
|
||||||
|
|
||||||
|
x_max = max(xs)
|
||||||
|
z_min = min(zs) - 1.0 # 바닥 1m 확장
|
||||||
|
|
||||||
|
# 닫힌 단면 (상류시작 바닥 → 프로파일 → 하류끝 바닥 → 돌아옴)
|
||||||
|
closed_pts_2d = [(xs[0], z_min), *list(zip(xs, zs, strict=False)), (x_max, z_min)]
|
||||||
|
|
||||||
|
# Y 방향(span)으로 extrude
|
||||||
|
span = p.total_span
|
||||||
|
span_pts = self._extrude_2d_profile(closed_pts_2d, span)
|
||||||
|
mesh = self._triangulate_prism(span_pts, len(closed_pts_2d))
|
||||||
|
|
||||||
|
if mesh is not None:
|
||||||
|
self.meshes.append((mesh, COLORS["concrete"], 1.0))
|
||||||
|
|
||||||
|
def _extrude_2d_profile(self, profile_2d: list, span: float) -> np.ndarray:
|
||||||
|
"""(y, z) 프로파일 점들을 X 방향으로 2개 평면(start/end) 생성.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_2d: [(y, z), ...] 단면 프로파일
|
||||||
|
span: X 방향 길이
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
np.ndarray shape (2*n, 3): X=0면 n점 + X=span면 n점
|
||||||
|
"""
|
||||||
|
n = len(profile_2d)
|
||||||
|
pts = np.zeros((2 * n, 3))
|
||||||
|
for i, (y, z) in enumerate(profile_2d):
|
||||||
|
pts[i] = [0.0, y, z]
|
||||||
|
pts[i + n] = [span, y, z]
|
||||||
|
return pts
|
||||||
|
|
||||||
|
def _triangulate_prism(self, pts: np.ndarray, n: int) -> pv.PolyData | None:
|
||||||
|
"""프리즘 메쉬 생성 (두 개의 n-gon 끝면 + 측면 스트립)."""
|
||||||
|
if len(pts) != 2 * n:
|
||||||
|
return None
|
||||||
|
|
||||||
|
faces = []
|
||||||
|
|
||||||
|
# 측면 (n개의 사각형 → 삼각형 2개씩)
|
||||||
|
for i in range(n):
|
||||||
|
i_next = (i + 1) % n
|
||||||
|
# 앞면 i, 앞면 i_next, 뒷면 i_next
|
||||||
|
faces.append([3, i, i_next, i_next + n])
|
||||||
|
# 앞면 i, 뒷면 i_next, 뒷면 i
|
||||||
|
faces.append([3, i, i_next + n, i + n])
|
||||||
|
|
||||||
|
# 양끝면 (fan triangulation)
|
||||||
|
# 앞면 (X=0)
|
||||||
|
faces.extend([3, 0, i, i + 1] for i in range(1, n - 1))
|
||||||
|
# 뒷면 (X=span)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# --- 교각 ---
|
||||||
|
|
||||||
|
def _compute_pier_x_centers(self) -> list:
|
||||||
|
"""Parametric pier X centers (n_gates + 1 개).
|
||||||
|
|
||||||
|
외곽 wing pier는 gate 양쪽 반폭 + pier 반폭 바깥, 내부 pier는
|
||||||
|
인접 gate 중심의 중점. Phase B' 폴리곤이 사용되는 경우엔 pier 폴리곤
|
||||||
|
자체의 bbox 중심을 쓰도록 별도 계산한다.
|
||||||
|
"""
|
||||||
|
p = self.params
|
||||||
|
pier_polys = getattr(p, "pier_plan_polygons", None) or []
|
||||||
|
expected_n_piers = p.n_gates + 1
|
||||||
|
if pier_polys and len(pier_polys) == expected_n_piers \
|
||||||
|
and self._validate_pier_polys(pier_polys, p.pier_width, p.pier_length):
|
||||||
|
centers = []
|
||||||
|
for poly in pier_polys:
|
||||||
|
xs = [pt[0] for pt in poly]
|
||||||
|
centers.append((min(xs) + max(xs)) / 2.0)
|
||||||
|
centers.sort()
|
||||||
|
return centers
|
||||||
|
|
||||||
|
pier_w = p.pier_width
|
||||||
|
gate_xs = p.gate_centers_x
|
||||||
|
if not gate_xs:
|
||||||
|
return []
|
||||||
|
centers = [gate_xs[0] - p.gate_width / 2 - pier_w / 2]
|
||||||
|
for i in range(len(gate_xs) - 1):
|
||||||
|
centers.append((gate_xs[i] + gate_xs[i + 1]) / 2)
|
||||||
|
centers.append(gate_xs[-1] + p.gate_width / 2 + pier_w / 2)
|
||||||
|
return centers
|
||||||
|
|
||||||
|
def _build_piers(self):
|
||||||
|
"""교각 빌드. Phase B' 폴리곤이 **완전 추출 + sanity check 통과** 시만
|
||||||
|
실제 기하 사용. 하나라도 비정상 폭/길이면 parametric 폴백 전체 사용."""
|
||||||
|
p = self.params
|
||||||
|
pier_top_el = p.el_bridge_top
|
||||||
|
pier_bot_el = p.el_gate_sill - 2.0
|
||||||
|
|
||||||
|
# --- Phase B': sanity check 후 폴리곤 경로 ---
|
||||||
|
pier_polys = getattr(p, "pier_plan_polygons", None) or []
|
||||||
|
expected_n_piers = p.n_gates + 1
|
||||||
|
if pier_polys and len(pier_polys) == expected_n_piers \
|
||||||
|
and self._validate_pier_polys(pier_polys, p.pier_width, p.pier_length):
|
||||||
|
for poly in pier_polys:
|
||||||
|
try:
|
||||||
|
mesh = self._extrude_polygon_xy(
|
||||||
|
poly, pier_bot_el, pier_top_el
|
||||||
|
)
|
||||||
|
if mesh is not None:
|
||||||
|
self.meshes.append((mesh, COLORS["pier"], 1.0))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Parametric 폴백 ---
|
||||||
|
pier_w = p.pier_width
|
||||||
|
pier_l = p.pier_length
|
||||||
|
pier_x_centers = self._compute_pier_x_centers()
|
||||||
|
if not pier_x_centers:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pier body는 nose_len만큼 안쪽에서 시작해 slab Y 범위(0 ~ pier_length)
|
||||||
|
# 바깥으로 돌출되지 않게 한다. nose는 [0, nose_len] 구간에 위치.
|
||||||
|
nose_len = pier_w * 1.2
|
||||||
|
body_y0 = nose_len
|
||||||
|
body_y1 = pier_l
|
||||||
|
for cx in pier_x_centers:
|
||||||
|
mesh = self._make_box(
|
||||||
|
x0=cx - pier_w / 2, x1=cx + pier_w / 2,
|
||||||
|
y0=body_y0, y1=body_y1,
|
||||||
|
z0=pier_bot_el, z1=pier_top_el,
|
||||||
|
)
|
||||||
|
nose = self._make_pier_nose(cx, pier_w, body_y0, pier_bot_el, pier_top_el)
|
||||||
|
if nose is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
mesh = mesh.merge(nose)
|
||||||
|
|
||||||
|
self.meshes.append((mesh, COLORS["pier"], 1.0))
|
||||||
|
|
||||||
|
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 좌표, 폐합 가정.
|
||||||
|
시계방향/반시계방향 둘 다 허용 (면 normal은 무관).
|
||||||
|
"""
|
||||||
|
if len(poly_xy) < 3:
|
||||||
|
return None
|
||||||
|
# 중복된 마지막 점(=첫점) 제거
|
||||||
|
pts2d = list(poly_xy)
|
||||||
|
if (abs(pts2d[0][0] - pts2d[-1][0]) < 1e-6
|
||||||
|
and abs(pts2d[0][1] - pts2d[-1][1]) < 1e-6):
|
||||||
|
pts2d = pts2d[:-1]
|
||||||
|
n = len(pts2d)
|
||||||
|
if n < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 상·하 고리 정점
|
||||||
|
pts = np.zeros((2 * n, 3))
|
||||||
|
for i, (x, y) in enumerate(pts2d):
|
||||||
|
pts[i] = [x, y, z_bot]
|
||||||
|
pts[i + n] = [x, y, z_top]
|
||||||
|
|
||||||
|
faces: list[int] = []
|
||||||
|
# 측면 사각형 (n개) → 각각 2 삼각형
|
||||||
|
for i in range(n):
|
||||||
|
j = (i + 1) % n
|
||||||
|
# (i, j, j+n)
|
||||||
|
faces.extend([3, i, j, j + n])
|
||||||
|
# (i, j+n, i+n)
|
||||||
|
faces.extend([3, i, j + n, i + n])
|
||||||
|
# 위/아래 fan triangulation (간단; 오목 폴리곤이면 결과가 다소 비정상이나 pier는 보통 거의 볼록)
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
faces.extend([3, 0, i, i + 1]) # 바닥 (0..n-1)
|
||||||
|
faces.extend([3, n, n + i + 1, n + i]) # 상부 (n..2n-1)
|
||||||
|
|
||||||
|
return pv.PolyData(pts, np.array(faces))
|
||||||
|
|
||||||
|
# ----- Phase B' sanity check -----
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_pier_polys(pier_polys: list, pier_width: float, pier_length: float,
|
||||||
|
tol_ratio: float = 0.5) -> bool:
|
||||||
|
"""Phase B' pier 폴리곤이 합리적 크기 범위 안인지 검증.
|
||||||
|
|
||||||
|
조건:
|
||||||
|
- 각 pier의 X-폭이 pier_width × (1 ± tol_ratio) 범위
|
||||||
|
- 각 pier의 Y-길이가 pier_length × (0.4 ~ 1.5) 범위
|
||||||
|
하나라도 실패하면 False 반환 → 빌더가 parametric으로 폴백.
|
||||||
|
tol_ratio=0.5이면 pier_width의 50%~150% 허용 (기본).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
for poly in pier_polys:
|
||||||
|
xs = [p[0] for p in poly]
|
||||||
|
ys = [p[1] for p in poly]
|
||||||
|
if len(xs) < 3:
|
||||||
|
return False
|
||||||
|
w = max(xs) - min(xs)
|
||||||
|
l = max(ys) - min(ys)
|
||||||
|
if not (w_lo <= w <= w_hi):
|
||||||
|
return False
|
||||||
|
if not (l_lo <= l <= l_hi):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_bridge_bbox(bbox: tuple, total_span: float, pier_length: float) -> bool:
|
||||||
|
"""Bridge bbox가 합리적 범위인지 검증. (x0, y0, x1, y1) local m."""
|
||||||
|
if bbox is None or len(bbox) != 4:
|
||||||
|
return False
|
||||||
|
x0, y0, x1, y1 = bbox
|
||||||
|
w = x1 - x0
|
||||||
|
h = y1 - y0
|
||||||
|
# 최소 1m, x-폭은 total_span의 30%~150%, y-길이는 pier_length의 10%~100%
|
||||||
|
if w < 1.0 or h < 0.5:
|
||||||
|
return False
|
||||||
|
if not (total_span * 0.2 <= w <= total_span * 1.5):
|
||||||
|
return False
|
||||||
|
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) -> pv.PolyData | None:
|
||||||
|
"""교각 상류측 삼각형 물가르기 노즈."""
|
||||||
|
half_w = width / 2
|
||||||
|
nose_len = width * 1.2 # 노즈 돌출 길이
|
||||||
|
y_tip = y_front - nose_len
|
||||||
|
|
||||||
|
# 8개 점: 바닥 3(좌,우,앞) + 상부 3
|
||||||
|
pts = np.array([
|
||||||
|
[cx - half_w, y_front, z_bot], # 0: 좌측 뒤 바닥
|
||||||
|
[cx + half_w, y_front, z_bot], # 1: 우측 뒤 바닥
|
||||||
|
[cx, y_tip, z_bot], # 2: 앞 끝 바닥
|
||||||
|
[cx - half_w, y_front, z_top], # 3: 좌측 뒤 상부
|
||||||
|
[cx + half_w, y_front, z_top], # 4: 우측 뒤 상부
|
||||||
|
[cx, y_tip, z_top], # 5: 앞 끝 상부
|
||||||
|
])
|
||||||
|
faces = np.array([
|
||||||
|
3, 0, 1, 2, # 바닥 (inward)
|
||||||
|
3, 5, 4, 3, # 상부 (outward)
|
||||||
|
3, 0, 2, 5, 3, 0, 5, 3, # 좌 측면 (2 tri)
|
||||||
|
3, 1, 4, 5, 3, 1, 5, 2, # 우 측면 (2 tri)
|
||||||
|
4, 0, 3, 4, 1, # 뒷면 사각형 (pier body와 맞닿음; quad)
|
||||||
|
])
|
||||||
|
return pv.PolyData(pts, faces)
|
||||||
|
|
||||||
|
# --- 래디얼 게이트 ---
|
||||||
|
|
||||||
|
def _compute_gate_geometry(self):
|
||||||
|
"""수문(Tainter) 기하를 기하학적 일관성 있게 계산하는 헬퍼.
|
||||||
|
|
||||||
|
좌표계: 빌더 +Y = downstream, body Y 범위는 [0, pier_length].
|
||||||
|
게이트 skin은 crest 근처에 배치되고, trunnion은 skin의 상류 쪽
|
||||||
|
(`trunnion_y = gate_y - horizontal`)으로 뻗는다. 이전 이 빌더에서
|
||||||
|
`gate_y = 1.0` 하드코딩이 쓰이면서 gate_height 7m · radius 8.75m 조합에서
|
||||||
|
`trunnion_y ≈ -7.75m`가 되어 개폐장치·암이 여수로 본체(Y=0~25m) 밖
|
||||||
|
-9m까지 튀어나오는 현상이 발생했다.
|
||||||
|
|
||||||
|
수정 로직:
|
||||||
|
1) ogee_profile에서 `el_weir_crest`와 가장 가까운 점의 x(=유출방향
|
||||||
|
거리)를 후보 `crest_y`로 추출. 상류 끝점(x=0)은 제외.
|
||||||
|
2) 실패 시 `pier_length * 0.45`를 폴백.
|
||||||
|
3) `gate_y`를 [horizontal + hoist_half, pier_length - margin] 범위로
|
||||||
|
clamp하여 trunnion과 개폐장치가 body 안으로 들어오게 함.
|
||||||
|
"""
|
||||||
|
p = self.params
|
||||||
|
sill_el = p.el_gate_sill
|
||||||
|
top_el = p.el_gate_top
|
||||||
|
gate_h = top_el - sill_el
|
||||||
|
|
||||||
|
# Radius: 기본값 gate_h * 1.25 (설계 관행)
|
||||||
|
radius = gate_h * 1.25
|
||||||
|
|
||||||
|
mid_el = (sill_el + top_el) / 2
|
||||||
|
trunnion_el_user = p.el_trunnion_pin
|
||||||
|
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: float | None = None
|
||||||
|
if p.ogee_profile:
|
||||||
|
best_diff = float("inf")
|
||||||
|
for (x, z) in p.ogee_profile:
|
||||||
|
if x <= 0.1:
|
||||||
|
continue
|
||||||
|
diff = abs(z - p.el_weir_crest)
|
||||||
|
if diff < best_diff:
|
||||||
|
best_diff = diff
|
||||||
|
crest_y_candidate = float(x)
|
||||||
|
if crest_y_candidate is None or crest_y_candidate < 0.5:
|
||||||
|
crest_y_candidate = body_len * 0.45
|
||||||
|
|
||||||
|
# 개폐장치 절반 길이(=1.10) + 지붕 margin(0.2) 이상 여유 확보
|
||||||
|
hoist_half = 2.0
|
||||||
|
gate_y_min = horizontal + hoist_half
|
||||||
|
gate_y_max = max(gate_y_min + 0.1, body_len - 0.5)
|
||||||
|
gate_y = max(gate_y_min, min(crest_y_candidate, gate_y_max))
|
||||||
|
trunnion_y = gate_y - horizontal
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gate_y": gate_y,
|
||||||
|
"trunnion_y": trunnion_y,
|
||||||
|
"trunnion_el": trunnion_el,
|
||||||
|
"radius": radius,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_radial_gates(self):
|
||||||
|
"""각 수문 위치에 래디얼(Tainter) 게이트 생성."""
|
||||||
|
p = self.params
|
||||||
|
gate_w = p.gate_width
|
||||||
|
sill_el = p.el_gate_sill
|
||||||
|
top_el = p.el_gate_top
|
||||||
|
|
||||||
|
geom = self._compute_gate_geometry()
|
||||||
|
gate_y = geom["gate_y"]
|
||||||
|
trunnion_y = geom["trunnion_y"]
|
||||||
|
trunnion_el = geom["trunnion_el"]
|
||||||
|
radius = geom["radius"]
|
||||||
|
|
||||||
|
for gx in p.gate_centers_x:
|
||||||
|
# 게이트 스킨플레이트 (곡면)
|
||||||
|
skin = self._make_radial_skin(
|
||||||
|
cx=gx, width=gate_w,
|
||||||
|
sill_el=sill_el, top_el=top_el,
|
||||||
|
gate_y=gate_y, trunnion_y=trunnion_y, trunnion_el=trunnion_el,
|
||||||
|
radius=radius,
|
||||||
|
)
|
||||||
|
if skin is not None:
|
||||||
|
self.meshes.append((skin, COLORS["gate_panel"], 1.0))
|
||||||
|
|
||||||
|
# 게이트 암 (trunnion → skin 연결부)
|
||||||
|
arms = self._make_gate_arms(
|
||||||
|
cx=gx, width=gate_w,
|
||||||
|
sill_el=sill_el, top_el=top_el,
|
||||||
|
gate_y=gate_y, trunnion_y=trunnion_y, trunnion_el=trunnion_el,
|
||||||
|
radius=radius, # 수정: 암이 곡면에 정확히 닿도록 radius 파라미터 전달
|
||||||
|
)
|
||||||
|
if arms is not None:
|
||||||
|
self.meshes.append((arms, COLORS["gate_frame"], 1.0))
|
||||||
|
|
||||||
|
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) -> pv.PolyData | None:
|
||||||
|
"""래디얼 게이트의 스킨플레이트 (원통면 일부).
|
||||||
|
|
||||||
|
sill·top 각도는 각 점의 dz만 정확히 알고 있으므로
|
||||||
|
dx = sqrt(radius² - dz²)로 원호 상 위치를 되찾는다.
|
||||||
|
(단순히 `gate_y - trunnion_y`를 쓰면 trunnion_el가 mid_el에서
|
||||||
|
벗어날 때 sill/top이 비대칭이 되어 스킨이 원호를 벗어남.)
|
||||||
|
"""
|
||||||
|
dz_sill = sill_el - trunnion_el
|
||||||
|
dz_top = top_el - trunnion_el
|
||||||
|
dx_sill = math.sqrt(max(0.01, radius * radius - dz_sill * dz_sill))
|
||||||
|
dx_top = math.sqrt(max(0.01, radius * radius - dz_top * dz_top))
|
||||||
|
ang_sill = math.atan2(dz_sill, dx_sill)
|
||||||
|
ang_top = math.atan2(dz_top, dx_top)
|
||||||
|
|
||||||
|
if ang_top - ang_sill > math.pi:
|
||||||
|
ang_top -= 2 * math.pi
|
||||||
|
elif ang_top - ang_sill < -math.pi:
|
||||||
|
ang_top += 2 * math.pi
|
||||||
|
|
||||||
|
n_circ = 16
|
||||||
|
half_w = width / 2 - 0.1
|
||||||
|
|
||||||
|
pts = []
|
||||||
|
for i in range(n_circ + 1):
|
||||||
|
t = i / n_circ
|
||||||
|
ang = ang_sill * (1 - t) + ang_top * t
|
||||||
|
dy = math.cos(ang) * radius
|
||||||
|
dz = math.sin(ang) * radius
|
||||||
|
y = trunnion_y + dy
|
||||||
|
z = trunnion_el + dz
|
||||||
|
pts.extend([cx + s, y, z] for s in (-half_w, half_w))
|
||||||
|
|
||||||
|
pts = np.array(pts)
|
||||||
|
|
||||||
|
# 삼각형 메쉬 (좌우 쌍으로 strip, Normal 방향 렌더링 오류 수정)
|
||||||
|
faces = []
|
||||||
|
for i in range(n_circ):
|
||||||
|
i0 = 2 * i
|
||||||
|
i1 = 2 * i + 1
|
||||||
|
i2 = 2 * (i + 1)
|
||||||
|
i3 = 2 * (i + 1) + 1
|
||||||
|
# 수정: Winding Order를 역순으로 바꿔 면의 바깥쪽(볼록한 면) 노멀이 정상적으로 향하도록 조치
|
||||||
|
faces.append([3, i0, i3, i1])
|
||||||
|
faces.append([3, i0, i2, i3])
|
||||||
|
|
||||||
|
faces_flat = np.concatenate(faces)
|
||||||
|
return pv.PolyData(pts, faces_flat)
|
||||||
|
|
||||||
|
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) -> pv.PolyData | None:
|
||||||
|
"""게이트 암: trunnion → skin 양 끝으로 뻗는 빔."""
|
||||||
|
half_w = width / 2 - 0.15
|
||||||
|
arm_thick = 0.3
|
||||||
|
mid_el = (sill_el + top_el) / 2
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
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 = np.array([side_cx, s_pt_y, mid_el])
|
||||||
|
|
||||||
|
dir_v = s_pt - t_pt
|
||||||
|
length = np.linalg.norm(dir_v)
|
||||||
|
if length < 0.1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = pv.Line(t_pt.tolist(), s_pt.tolist())
|
||||||
|
try:
|
||||||
|
tube = line.tube(radius=arm_thick, n_sides=8)
|
||||||
|
parts.append(tube)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
merged = parts[0]
|
||||||
|
for m in parts[1:]:
|
||||||
|
try:
|
||||||
|
merged = merged.merge(m)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return merged
|
||||||
|
|
||||||
|
# --- 공도교 ---
|
||||||
|
|
||||||
|
def _build_service_bridge(self):
|
||||||
|
"""수문 상부 공도교 (service bridge).
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1) 사용자가 UI에서 bridge_x_start/end/y_start/end를 **명시 입력**했으면 그 값
|
||||||
|
2) Phase B' 파서가 추출한 bridge_plan_bbox (sanity 통과 시)
|
||||||
|
3) parametric default
|
||||||
|
"""
|
||||||
|
p = self.params
|
||||||
|
deck_thickness = getattr(p, "bridge_deck_thickness_m", 1.2)
|
||||||
|
deck_top = p.el_bridge_top + deck_thickness
|
||||||
|
deck_bot = p.el_bridge_top
|
||||||
|
|
||||||
|
# 1) 사용자 명시 값 (UI param, 0이 아닌 편집값)
|
||||||
|
ux0 = getattr(p, "bridge_x_start", None)
|
||||||
|
ux1 = getattr(p, "bridge_x_end", None)
|
||||||
|
uy0 = getattr(p, "bridge_y_start", None)
|
||||||
|
uy1 = getattr(p, "bridge_y_end", None)
|
||||||
|
user_override = (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)
|
||||||
|
|
||||||
|
# 사용자 override 후보에도 sanity 적용 (파서가 자동 채운 이상값이 여기로 흘러올 수 있음)
|
||||||
|
user_bbox = None
|
||||||
|
if user_override:
|
||||||
|
cand = (float(ux0), float(uy0), float(ux1), float(uy1))
|
||||||
|
if self._validate_bridge_bbox(cand, p.total_span, p.pier_length):
|
||||||
|
user_bbox = cand
|
||||||
|
|
||||||
|
if user_bbox is not None:
|
||||||
|
x0, y0, x1, y1 = user_bbox
|
||||||
|
source = "user"
|
||||||
|
else:
|
||||||
|
# 2) Phase B' bbox (sanity 재확인)
|
||||||
|
bbox = getattr(p, "bridge_plan_bbox", None)
|
||||||
|
if bbox is not None and self._validate_bridge_bbox(
|
||||||
|
bbox, p.total_span, p.pier_length):
|
||||||
|
x0, y0, x1, y1 = bbox
|
||||||
|
source = "extracted"
|
||||||
|
else:
|
||||||
|
# 3) parametric 폴백
|
||||||
|
x0 = -p.pier_width * 0.5
|
||||||
|
x1 = p.total_span + p.pier_width * 0.5
|
||||||
|
y0 = p.pier_length * 0.3
|
||||||
|
y1 = p.pier_length * 0.55
|
||||||
|
source = "parametric"
|
||||||
|
|
||||||
|
# 건설 기록을 raw_text_annotations에 남김
|
||||||
|
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
|
||||||
|
))
|
||||||
|
|
||||||
|
deck = self._make_box(x0, x1, y0, y1, deck_bot, deck_top)
|
||||||
|
self.meshes.append((deck, COLORS["bridge_deck"], 1.0))
|
||||||
|
|
||||||
|
# 양쪽 난간
|
||||||
|
rail_height = 1.1
|
||||||
|
rail_thick = 0.2
|
||||||
|
for y_rail in [y0, y1 - rail_thick]:
|
||||||
|
rail = self._make_box(
|
||||||
|
x0, x1, y_rail, y_rail + rail_thick,
|
||||||
|
deck_top, deck_top + rail_height,
|
||||||
|
)
|
||||||
|
self.meshes.append((rail, COLORS["bridge_rail"], 1.0))
|
||||||
|
|
||||||
|
# --- 여수로 개폐장치 (gate hoist) ---
|
||||||
|
|
||||||
|
def _build_gate_hoists(self):
|
||||||
|
"""각 pier 상면에 올라앉는 여수로 개폐장치(gate hoist).
|
||||||
|
|
||||||
|
**수문_1.dxf 평면도 실측**:
|
||||||
|
- pier 상면의 CS-CONC-Spillway closed 4각형(X폭 ≈ 4481mm, Y길이 ≈ 2181mm)
|
||||||
|
이 pier X 중심과 정확히 일치 → 개폐장치 기초 footprint.
|
||||||
|
- 평면 Y 범위는 pier body 상류 끝(Y=49783~51964mm, body Y=49125 기준
|
||||||
|
local 658~2839mm ≈ 1.5m 중심).
|
||||||
|
**수문_2.dxf 측면도 실측**:
|
||||||
|
- MZ-BASE Y=24938~27015mm (표고 offset +30.962 → EL.55.9~57.977)
|
||||||
|
- 높이 ≈ 2.1m, 바닥 EL.55.9 = pier 상면(el_bridge_top=56.0) − 0.1m
|
||||||
|
|
||||||
|
→ X 중심은 **pier X 중심들**(`_compute_pier_x_centers`), gate 중심이 아님.
|
||||||
|
→ Z는 pier 상면에 일부 embed(0.1m)되어 허공 부양 없음.
|
||||||
|
"""
|
||||||
|
p = self.params
|
||||||
|
pier_x_centers = self._compute_pier_x_centers()
|
||||||
|
if not pier_x_centers:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 평면도 실측 기반 치수 (m). 폭은 pier 폭과 동일(실측 도면: pier 상면 4각형과
|
||||||
|
# 기초 footprint가 정확히 일치) — 좁은 pier에도 양옆으로 튀어나오지 않게.
|
||||||
|
house_w = max(p.pier_width, 2.5)
|
||||||
|
house_l = 2.2 # Y 길이
|
||||||
|
house_h = 2.1 # 높이 (측면도 MZ-BASE 관찰)
|
||||||
|
embed_depth = 0.1 # pier 상면 안으로 파고드는 깊이
|
||||||
|
roof_overhang = 0.2
|
||||||
|
roof_thick = 0.2
|
||||||
|
|
||||||
|
pier_top_el = p.el_bridge_top
|
||||||
|
base_z = pier_top_el - embed_depth
|
||||||
|
top_z = base_z + house_h
|
||||||
|
|
||||||
|
# Y 중심: pier body 상류 끝(nose_len 직후) 근방 — 평면도에서 local Y≈1.5m
|
||||||
|
pier_w = p.pier_width
|
||||||
|
nose_len = pier_w * 1.2 # pier body와 동일 계산식
|
||||||
|
body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
|
||||||
|
y_center_target = nose_len + 0.5 # pier body 시작선 0.5m 하류
|
||||||
|
margin = house_l / 2 + roof_overhang + 0.1
|
||||||
|
y_center = min(max(y_center_target, margin), body_len - margin)
|
||||||
|
y0 = y_center - house_l / 2
|
||||||
|
y1 = y_center + house_l / 2
|
||||||
|
|
||||||
|
for cx in pier_x_centers:
|
||||||
|
# 기초 + 본체 (pier 상면 안쪽으로 살짝 embedded)
|
||||||
|
box = self._make_box(
|
||||||
|
cx - house_w / 2, cx + house_w / 2,
|
||||||
|
y0, y1, base_z, top_z,
|
||||||
|
)
|
||||||
|
self.meshes.append((box, COLORS["gate_hoist"], 1.0))
|
||||||
|
# 지붕 (평지붕 + 약간 돌출)
|
||||||
|
roof = self._make_box(
|
||||||
|
cx - house_w / 2 - roof_overhang, cx + house_w / 2 + roof_overhang,
|
||||||
|
y0 - roof_overhang, y1 + roof_overhang,
|
||||||
|
top_z, top_z + roof_thick,
|
||||||
|
)
|
||||||
|
self.meshes.append((roof, COLORS["gate_hoist_roof"], 1.0))
|
||||||
|
|
||||||
|
# --- 수면 ---
|
||||||
|
|
||||||
|
def _build_water_surface(self):
|
||||||
|
"""상류 수면 (N.H.W.L 기준 평판)."""
|
||||||
|
p = self.params
|
||||||
|
water_el = p.el_nhwl
|
||||||
|
|
||||||
|
# 상류측 수면 (상류쪽으로 40m)
|
||||||
|
x0 = -10
|
||||||
|
x1 = p.total_span + 10
|
||||||
|
y0 = -40 # 상류 40m
|
||||||
|
y1 = 0.5 # 여수로 앞
|
||||||
|
|
||||||
|
water = self._make_flat_rect(x0, x1, y0, y1, water_el)
|
||||||
|
self.meshes.append((water, COLORS["water"], 0.85))
|
||||||
|
|
||||||
|
# --- 하류 에이프런 ---
|
||||||
|
|
||||||
|
def _build_downstream_apron(self):
|
||||||
|
"""하류측 에이프런/하천 바닥."""
|
||||||
|
p = self.params
|
||||||
|
apron_el = p.el_downstream
|
||||||
|
|
||||||
|
x0 = -5
|
||||||
|
x1 = p.total_span + 5
|
||||||
|
y0 = p.pier_length # 여수로 끝
|
||||||
|
y1 = y0 + 30 # 하류 30m
|
||||||
|
|
||||||
|
apron = self._make_flat_rect(x0, x1, y0, y1, apron_el)
|
||||||
|
self.meshes.append((apron, COLORS["apron"], 1.0))
|
||||||
|
|
||||||
|
# --- 유틸리티: 기본 형상 ---
|
||||||
|
|
||||||
|
def _make_box(self, x0, x1, y0, y1, z0, z1) -> pv.PolyData:
|
||||||
|
"""축정렬 박스 메쉬 생성."""
|
||||||
|
pts = np.array([
|
||||||
|
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], # bottom
|
||||||
|
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], # top
|
||||||
|
])
|
||||||
|
faces = np.hstack([
|
||||||
|
[4, 0, 3, 2, 1], # bottom (inward normal)
|
||||||
|
[4, 4, 5, 6, 7], # top
|
||||||
|
[4, 0, 1, 5, 4], # front
|
||||||
|
[4, 2, 3, 7, 6], # back
|
||||||
|
[4, 1, 2, 6, 5], # right
|
||||||
|
[4, 0, 4, 7, 3], # left
|
||||||
|
])
|
||||||
|
return pv.PolyData(pts, faces)
|
||||||
|
|
||||||
|
def _make_flat_rect(self, x0, x1, y0, y1, z) -> pv.PolyData:
|
||||||
|
"""수평 사각형 평면."""
|
||||||
|
pts = np.array([
|
||||||
|
[x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z],
|
||||||
|
])
|
||||||
|
faces = np.array([4, 0, 1, 2, 3])
|
||||||
|
return pv.PolyData(pts, faces)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 편의 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_gate_meshes(params: GateParams) -> list[tuple[pv.PolyData, str, float]]:
|
||||||
|
"""편의 함수: 파라미터 → 메쉬 리스트."""
|
||||||
|
return GateBuilder(params).build_all()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from gate_parser import parse_gate_dxf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
base = Path("Gate_Sample")
|
||||||
|
f1 = base / "12995740-M40-001 여수로 수문 설치도(1/2).dxf"
|
||||||
|
f2 = base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf"
|
||||||
|
|
||||||
|
params = parse_gate_dxf(str(f1), str(f2))
|
||||||
|
print(params.summary())
|
||||||
|
|
||||||
|
builder = GateBuilder(params)
|
||||||
|
meshes = builder.build_all()
|
||||||
|
print(f"\nBuilt {len(meshes)} mesh components")
|
||||||
|
for i, (m, c, o) in enumerate(meshes):
|
||||||
|
print(f" [{i}] {m.n_points} pts, {m.n_cells} cells, color={c}, opacity={o}")
|
||||||
1168
gate_3d_builder_bpy.py
Normal file
1215
gate_parser.py
Normal file
288
gemini_renderer.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Gemini(Nano Banana 등) 기반 조감도 AI 렌더링 워커.
|
||||||
|
|
||||||
|
scanvas_maker.SCanvasApp의 백그라운드 스레드에서 호출되며, app 인스턴스를 통해
|
||||||
|
상태(capture_image / job_logger / log / after / dxf_path / 등)에 접근한다.
|
||||||
|
scanvas_maker로부터 ~264줄을 분리해 AI 호출 로직을 모듈 단위로 격리.
|
||||||
|
|
||||||
|
공개 API:
|
||||||
|
run_gemini_render(app, credential, prompt, use_vertex=False, location="us-central1")
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import time as _time
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def run_gemini_render(app, credential: str, prompt: str,
|
||||||
|
use_vertex: bool = False,
|
||||||
|
location: str = "us-central1") -> None:
|
||||||
|
"""Gemini 자동 호출 + Harness 통합. app = SCanvasApp 인스턴스.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: scanvas_maker.SCanvasApp (상태/로그/UI scheduling 접근)
|
||||||
|
credential: use_vertex=True이면 GCP Project ID, 아니면 API Key
|
||||||
|
prompt: 렌더 프롬프트
|
||||||
|
use_vertex: True면 Vertex AI (google-genai SDK), False면 API Key 경로
|
||||||
|
location: Vertex AI region ("global"=gemini-3.x, "us-central1"=2.x)
|
||||||
|
"""
|
||||||
|
t_start = _time.time()
|
||||||
|
job_id = None
|
||||||
|
db = None
|
||||||
|
|
||||||
|
dxf_hash = app._get_dxf_hash()
|
||||||
|
prompt_hash = app._get_prompt_hash(prompt)
|
||||||
|
prompt_ver = "v1"
|
||||||
|
seed = 0
|
||||||
|
|
||||||
|
if app.job_logger and _HARNESS_OK:
|
||||||
|
try:
|
||||||
|
db = get_db_session()
|
||||||
|
job = app.job_logger.create_job(db, app.dxf_path or "unknown", 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 "v1"
|
||||||
|
app.job_logger.start_job(db, job_id, seed, prompt_ver, prompt_hash)
|
||||||
|
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 e=e: app.log(f" Harness 초기화 경고: {e}"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types as gtypes
|
||||||
|
sdk_version = "vertex" if use_vertex else "new"
|
||||||
|
except ImportError:
|
||||||
|
if use_vertex:
|
||||||
|
if app.job_logger and db and job_id:
|
||||||
|
app.job_logger.fail_job(db, job_id, "google-genai SDK 미설치")
|
||||||
|
app.after(0, lambda: messagebox.showerror("패키지 필요",
|
||||||
|
"Vertex AI는 google-genai SDK가 필요합니다.\n"
|
||||||
|
"pip install google-genai\n"
|
||||||
|
"그리고 gcloud auth application-default login"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import google.generativeai as genai_legacy # type: ignore
|
||||||
|
sdk_version = "legacy"
|
||||||
|
except ImportError:
|
||||||
|
if app.job_logger and db and job_id:
|
||||||
|
app.job_logger.fail_job(db, job_id, "google-genai SDK 미설치")
|
||||||
|
app.after(0, lambda: messagebox.showerror("패키지 필요",
|
||||||
|
"pip install google-generativeai\n또는\npip install google-genai"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
source_img = app.capture_image if app.capture_image else app.guide_image
|
||||||
|
input_path = os.path.abspath("capture_for_ai.png")
|
||||||
|
source_img.save(input_path)
|
||||||
|
app.after(0, lambda: app.log(f" 입력 이미지: {source_img.size}"))
|
||||||
|
|
||||||
|
with open(input_path, "rb") as f:
|
||||||
|
img_bytes = f.read()
|
||||||
|
|
||||||
|
render_prompt = (
|
||||||
|
f"Transform this aerial/satellite terrain capture into a photorealistic "
|
||||||
|
f"bird's-eye view architectural rendering. "
|
||||||
|
f"CRITICAL: Preserve the EXACT terrain layout, roads, water, structures. "
|
||||||
|
f"The scene may combine a high-detail DXF area (center) with a lower-detail "
|
||||||
|
f"real DEM+satellite outer ring (edges). Render BOTH areas seamlessly — "
|
||||||
|
f"the outer ring is actual surrounding terrain, NOT filler to crop out. "
|
||||||
|
f"Keep the full image frame; do NOT trim to the central bbox. "
|
||||||
|
f"Dark road areas = freshly paved asphalt. Cut slopes = exposed earth. "
|
||||||
|
f"Enhance vegetation textures, water reflections, natural lighting. "
|
||||||
|
f"Style: {prompt}"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.after(0, lambda: app.log(
|
||||||
|
f" Gemini API 호출 중... (SDK: {sdk_version}, "
|
||||||
|
f"loc: {location if use_vertex else '-'})"))
|
||||||
|
|
||||||
|
rendered = None
|
||||||
|
|
||||||
|
if sdk_version == "vertex":
|
||||||
|
try:
|
||||||
|
client = genai.Client(
|
||||||
|
vertexai=True,
|
||||||
|
project=credential,
|
||||||
|
location=location,
|
||||||
|
)
|
||||||
|
except Exception as ce:
|
||||||
|
err = str(ce)[:160]
|
||||||
|
app.after(0, lambda: app.log(
|
||||||
|
f" Vertex AI 클라이언트 생성 실패: {err}"))
|
||||||
|
has_sa = bool(os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"))
|
||||||
|
if has_sa:
|
||||||
|
app.after(0, lambda: app.log(
|
||||||
|
" → gcp-key.json 경로 확인. 서비스 계정에 "
|
||||||
|
"aiplatform.user 권한이 있는지 확인하세요."))
|
||||||
|
else:
|
||||||
|
app.after(0, lambda: app.log(
|
||||||
|
" → gcp-key.json을 프로젝트 루트에 배치하거나 "
|
||||||
|
"gcloud auth application-default login 실행"))
|
||||||
|
if app.job_logger and db and job_id:
|
||||||
|
app.job_logger.fail_job(db, job_id, "Vertex 인증 실패")
|
||||||
|
return
|
||||||
|
|
||||||
|
model_priority = [
|
||||||
|
("gemini-3.1-flash-image-preview", location),
|
||||||
|
("gemini-3-pro-image-preview", location),
|
||||||
|
("gemini-2.5-flash-image", "us-central1"),
|
||||||
|
("gemini-2.5-flash-image-preview", "us-central1"),
|
||||||
|
("gemini-2.0-flash-preview-image-generation", "us-central1"),
|
||||||
|
]
|
||||||
|
current_loc = location
|
||||||
|
for model_name, model_loc in model_priority:
|
||||||
|
if model_loc != current_loc:
|
||||||
|
try:
|
||||||
|
client = genai.Client(
|
||||||
|
vertexai=True,
|
||||||
|
project=credential,
|
||||||
|
location=model_loc,
|
||||||
|
)
|
||||||
|
current_loc = model_loc
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model=model_name,
|
||||||
|
contents=[
|
||||||
|
gtypes.Part.from_bytes(data=img_bytes, mime_type="image/png"),
|
||||||
|
render_prompt
|
||||||
|
],
|
||||||
|
config=gtypes.GenerateContentConfig(
|
||||||
|
response_modalities=["IMAGE"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for part in response.candidates[0].content.parts:
|
||||||
|
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, _l = model_name, current_loc
|
||||||
|
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 _m=_m, _e=_e: app.log(f" [Vertex] {_m}: {_e}"))
|
||||||
|
|
||||||
|
elif sdk_version == "new":
|
||||||
|
client = genai.Client(api_key=credential)
|
||||||
|
for model_name in ["gemini-2.5-flash-image",
|
||||||
|
"gemini-2.0-flash-preview-image-generation"]:
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model=model_name,
|
||||||
|
contents=[
|
||||||
|
gtypes.Part.from_bytes(data=img_bytes, mime_type="image/png"),
|
||||||
|
render_prompt
|
||||||
|
],
|
||||||
|
config=gtypes.GenerateContentConfig(
|
||||||
|
response_modalities=["IMAGE", "TEXT"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for part in response.candidates[0].content.parts:
|
||||||
|
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 _m=_m: app.log(f" 모델 {_m} 성공"))
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
_m, _e = model_name, str(exc)[:80]
|
||||||
|
app.after(0, lambda _m=_m, _e=_e: app.log(f" {_m}: {_e}"))
|
||||||
|
|
||||||
|
else:
|
||||||
|
genai_legacy.configure(api_key=credential)
|
||||||
|
pil_img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
for model_name in ["gemini-2.5-flash-image",
|
||||||
|
"gemini-2.0-flash-preview-image-generation"]:
|
||||||
|
try:
|
||||||
|
model = genai_legacy.GenerativeModel(model_name)
|
||||||
|
response = model.generate_content(
|
||||||
|
[pil_img, render_prompt]
|
||||||
|
)
|
||||||
|
if hasattr(response, 'candidates') and response.candidates:
|
||||||
|
for part in response.candidates[0].content.parts:
|
||||||
|
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 _m=_m: app.log(f" 모델 {_m} 성공"))
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
_m, _e = model_name, str(exc)[:80]
|
||||||
|
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:
|
||||||
|
app.job_logger.fail_job(db, job_id, "Gemini 이미지 생성 실패")
|
||||||
|
app.after(0, lambda: app.log(" Gemini 이미지 생성 실패. API Key와 모델을 확인하세요."))
|
||||||
|
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
|
||||||
|
|
||||||
|
quality_score = 0.0
|
||||||
|
if app.quality_val:
|
||||||
|
try:
|
||||||
|
vr = app.quality_val.validate(Path(output_path))
|
||||||
|
quality_score = vr.score
|
||||||
|
app.after(0, lambda: app.log(f" 품질검증: {vr.summary}"))
|
||||||
|
except Exception as e:
|
||||||
|
app.after(0, lambda e=e: app.log(f" 품질검증 오류: {e}"))
|
||||||
|
|
||||||
|
if app.job_logger and db and job_id:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
app.job_logger.complete_job(db, job_id, output_path, quality_score, latency_ms)
|
||||||
|
|
||||||
|
app.after(0, lambda: app.log(
|
||||||
|
f" Gemini 렌더링 완료! → {output_path} ({rendered.size}) "
|
||||||
|
f"[{latency_ms:.0f}ms, 품질={quality_score:.2f}]"))
|
||||||
|
app.after(0, lambda: app.set_status("AI 렌더링 완료", "#2ECC71"))
|
||||||
|
app.after(0, lambda: app._show_rendered_result(output_path))
|
||||||
|
|
||||||
|
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: 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:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
db.close()
|
||||||
1074
geo_referencing.py
Normal file
0
harness/__init__.py
Normal file
136
harness/logger.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""로거 - SQLite DB + structlog 기반 작업 이력 추적."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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 # noqa: PLW0603 (module-level singleton init)
|
||||||
|
_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: Path | None = 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()
|
||||||
57
harness/prompt_registry.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""프롬프트 레지스트리 - 버전 관리 및 재현 가능성 보장."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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("prompt_v*.yaml"), reverse=True)
|
||||||
|
return [p.stem for p in yamls]
|
||||||
|
|
||||||
|
def latest_version(self) -> str | None:
|
||||||
|
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) -> 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
|
||||||
97
harness/quality_validator.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""품질 검증기 - 생성된 이미지가 기준을 충족하는지 자동 검사한다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError("opencv-python, Pillow이 필요합니다: pip install opencv-python Pillow") from e
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
65
harness/seed_manager.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Seed 관리자 - 작업별 Seed 고정 및 추적."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from harness.logger import JobRecord
|
||||||
|
|
||||||
|
|
||||||
|
class SeedManager:
|
||||||
|
"""DXF 파일 해시 기반 결정론적 seed를 생성하고 이력을 관리한다."""
|
||||||
|
|
||||||
|
MAX_SEED = 2**32 - 1
|
||||||
|
|
||||||
|
def get_seed(
|
||||||
|
self,
|
||||||
|
file_hash: str,
|
||||||
|
fixed_seed: int | None = 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: int | None = 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})"
|
||||||
454
intake_tower_3d_builder.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
"""취수탑 3D 파라메트릭 빌더.
|
||||||
|
|
||||||
|
IntakeTowerParams → PyVista 메쉬 리스트 (구성요소별)
|
||||||
|
|
||||||
|
구성요소:
|
||||||
|
1. 주 본체 (concrete shell, 직사각 or L자)
|
||||||
|
2. 연장부 (접근수로 방향, L자의 경우)
|
||||||
|
3. 수문 개구부 × N (사각 홀)
|
||||||
|
4. 수문 개폐장치 × N (원통 + 모터박스)
|
||||||
|
5. 상부 호이스트 크레인 (beam + trolley)
|
||||||
|
6. 호이스트 레일 (I-beam)
|
||||||
|
7. 점검구 커버 (계단식)
|
||||||
|
8. 각 EL 바닥 slab
|
||||||
|
9. 출입 계단 (외부)
|
||||||
|
10. 지붕
|
||||||
|
11. 난간 / 파라펫
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
from intake_tower_parser import IntakeTowerParams
|
||||||
|
|
||||||
|
|
||||||
|
# 색상 팔레트
|
||||||
|
COLORS = {
|
||||||
|
"body": "#B8B5A8", # 콘크리트
|
||||||
|
"body_light": "#C5C2B5", # 상부/내부
|
||||||
|
"gate_steel": "#3D4A5C", # 수문 강재
|
||||||
|
"actuator": "#5A4A3A", # 개폐장치 모터박스
|
||||||
|
"actuator_cyl":"#8D9BA6", # 원통 (잭)
|
||||||
|
"hoist_rail": "#4A4A4A", # H빔
|
||||||
|
"hoist_crane": "#D97706", # 호이스트 크레인 (주황)
|
||||||
|
"stairs": "#8B7D6B", # 계단
|
||||||
|
"parapet": "#A8A59B", # 난간
|
||||||
|
"floor": "#9A968C", # 바닥
|
||||||
|
"inspect": "#D4A373", # 점검구 커버
|
||||||
|
"roof": "#7A6A5A", # 지붕
|
||||||
|
"water": "#3A7AA8", # 수면
|
||||||
|
"ground": "#7F6F5F", # 지반
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IntakeTowerBuilder:
|
||||||
|
"""취수탑 파라메트릭 3D 빌더."""
|
||||||
|
|
||||||
|
def __init__(self, params: IntakeTowerParams):
|
||||||
|
self.p = params
|
||||||
|
self.meshes: list[tuple[pv.PolyData, str, float]] = []
|
||||||
|
|
||||||
|
def build_all(self) -> list[tuple[pv.PolyData, str, float]]:
|
||||||
|
self.meshes = []
|
||||||
|
|
||||||
|
self._build_main_body()
|
||||||
|
self._build_extension() # L자 연장부
|
||||||
|
self._build_gate_openings() # 수문 개구부 (구멍)
|
||||||
|
self._build_gate_actuators() # 개폐장치 × N
|
||||||
|
self._build_floor_slabs() # 각 EL 바닥
|
||||||
|
self._build_roof()
|
||||||
|
self._build_hoist_rail()
|
||||||
|
self._build_hoist_crane()
|
||||||
|
self._build_inspection_cover()
|
||||||
|
self._build_entry_stairs()
|
||||||
|
self._build_parapet()
|
||||||
|
self._build_ground_and_water()
|
||||||
|
|
||||||
|
return self.meshes
|
||||||
|
|
||||||
|
# --- 주 본체 (L자 또는 직사각) ---
|
||||||
|
|
||||||
|
def _build_main_body(self):
|
||||||
|
"""주 본체: 벽체 4면 + 바닥.
|
||||||
|
|
||||||
|
좌표계:
|
||||||
|
- 원점 = 본체 평면 중심 + 바닥 EL
|
||||||
|
- X: 가로 (body_width)
|
||||||
|
- Y: 세로 (body_depth)
|
||||||
|
- Z: 표고 (body_bottom_el ~ body_top_el)
|
||||||
|
"""
|
||||||
|
p = self.p
|
||||||
|
hw = p.body_width / 2
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
z0 = p.body_bottom_el
|
||||||
|
z1 = p.body_top_el
|
||||||
|
|
||||||
|
# 벽 두께
|
||||||
|
wall_t = 0.5
|
||||||
|
|
||||||
|
# 4개 벽: 외곽에서 안쪽으로 wall_t 두께
|
||||||
|
# 전면 벽 (Y = -hd)
|
||||||
|
self._add_wall(-hw, hw, -hd, -hd + wall_t, z0, z1, COLORS["body"])
|
||||||
|
# 후면 벽
|
||||||
|
self._add_wall(-hw, hw, hd - wall_t, hd, z0, z1, COLORS["body"])
|
||||||
|
# 좌측 벽
|
||||||
|
self._add_wall(-hw, -hw + wall_t, -hd + wall_t, hd - wall_t, z0, z1, COLORS["body"])
|
||||||
|
# 우측 벽
|
||||||
|
self._add_wall(hw - wall_t, hw, -hd + wall_t, hd - wall_t, z0, z1, COLORS["body"])
|
||||||
|
|
||||||
|
# 바닥 slab
|
||||||
|
self._add_wall(-hw, hw, -hd, hd, z0, z0 + 0.5, COLORS["body"])
|
||||||
|
|
||||||
|
def _add_wall(self, x0, x1, y0, y1, z0, z1, color):
|
||||||
|
"""박스 형태의 벽 추가."""
|
||||||
|
mesh = self._make_box(x0, x1, y0, y1, z0, z1)
|
||||||
|
self.meshes.append((mesh, color, 1.0))
|
||||||
|
|
||||||
|
def _make_box(self, x0, x1, y0, y1, z0, z1) -> pv.PolyData:
|
||||||
|
pts = np.array([
|
||||||
|
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
|
||||||
|
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
|
||||||
|
])
|
||||||
|
faces = np.hstack([
|
||||||
|
[4, 0, 3, 2, 1], [4, 4, 5, 6, 7],
|
||||||
|
[4, 0, 1, 5, 4], [4, 2, 3, 7, 6],
|
||||||
|
[4, 1, 2, 6, 5], [4, 0, 4, 7, 3],
|
||||||
|
])
|
||||||
|
return pv.PolyData(pts, faces)
|
||||||
|
|
||||||
|
# --- 연장부 (L자의 경우 수로 방향) ---
|
||||||
|
|
||||||
|
def _build_extension(self):
|
||||||
|
"""L자 연장부 — 본체 하류 방향으로 뻗은 접근수로 옹벽."""
|
||||||
|
p = self.p
|
||||||
|
if not p.has_l_extension or p.extension_length <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
hw = p.extension_width / 2
|
||||||
|
L = p.extension_length
|
||||||
|
z0 = p.extension_bottom_el
|
||||||
|
z1 = p.body_top_el - 5.0 # 연장부는 본체보다 낮음
|
||||||
|
|
||||||
|
# 연장부: Y+ 방향 (본체 후면에서 계속)
|
||||||
|
body_hd = p.body_depth / 2
|
||||||
|
|
||||||
|
# 양쪽 옹벽
|
||||||
|
wall_t = 0.5
|
||||||
|
# 좌측 옹벽
|
||||||
|
self._add_wall(-hw, -hw + wall_t, body_hd, body_hd + L, z0, z1, COLORS["body"])
|
||||||
|
# 우측 옹벽
|
||||||
|
self._add_wall(hw - wall_t, hw, body_hd, body_hd + L, z0, z1, COLORS["body"])
|
||||||
|
# 바닥 slab
|
||||||
|
self._add_wall(-hw, hw, body_hd, body_hd + L, z0, z0 + 0.5, COLORS["body"])
|
||||||
|
|
||||||
|
# --- 수문 개구부 (벽에 구멍) ---
|
||||||
|
|
||||||
|
def _build_gate_openings(self):
|
||||||
|
"""수문 개구부: 본체 전면 벽에 사각 홀로 표현.
|
||||||
|
|
||||||
|
주의: 진짜 boolean cut 대신 어두운 사각형을 벽 앞면에 배치하여 시각적 표현.
|
||||||
|
"""
|
||||||
|
p = self.p
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
|
||||||
|
for g in p.gates:
|
||||||
|
gx = g.center_x
|
||||||
|
gw = g.gate_width
|
||||||
|
gh = g.gate_height
|
||||||
|
z_center = g.elevation
|
||||||
|
z0 = z_center - gh / 2
|
||||||
|
z1 = z_center + gh / 2
|
||||||
|
|
||||||
|
# 전면 벽 앞쪽에 약간 돌출시킨 검은 사각형 (어두운 개구부 느낌)
|
||||||
|
# 벽 앞면은 Y=-hd이므로 그 앞에 얇은 판 배치
|
||||||
|
opening = self._make_box(
|
||||||
|
gx - gw / 2, gx + gw / 2,
|
||||||
|
-hd - 0.05, -hd + 0.01, # 벽 전면 바로 앞 (Y-방향으로 밖)
|
||||||
|
z0, z1,
|
||||||
|
)
|
||||||
|
self.meshes.append((opening, "#1A1A1A", 1.0)) # 어두운 홀
|
||||||
|
|
||||||
|
# --- 수문 개폐장치 (원통 + 모터박스) ---
|
||||||
|
|
||||||
|
def _build_gate_actuators(self):
|
||||||
|
"""각 수문 위에 수직 잭(원통) + 상부 모터박스."""
|
||||||
|
p = self.p
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
|
||||||
|
for g in p.gates:
|
||||||
|
gx = g.center_x
|
||||||
|
# 개폐장치는 본체 내부 (벽에서 1m 안쪽)
|
||||||
|
ay = -hd + 1.0
|
||||||
|
# 잭 원통: 수문 EL부터 상단까지
|
||||||
|
jack_bottom = g.elevation + g.gate_height / 2
|
||||||
|
jack_top = p.body_top_el - 1.5
|
||||||
|
if jack_top <= jack_bottom:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
cyl = pv.Cylinder(
|
||||||
|
center=(gx, ay, (jack_bottom + jack_top) / 2),
|
||||||
|
direction=(0, 0, 1),
|
||||||
|
radius=g.actuator_radius * 0.3, # 잭 지름은 장치 크기의 30%
|
||||||
|
height=(jack_top - jack_bottom),
|
||||||
|
resolution=16,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((cyl, COLORS["actuator_cyl"], 1.0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 상부 모터박스 (권양기)
|
||||||
|
motor_w = g.actuator_radius * 1.4
|
||||||
|
motor_h = 1.0
|
||||||
|
motor_z0 = jack_top - motor_h / 2
|
||||||
|
motor_z1 = motor_z0 + motor_h
|
||||||
|
motor = self._make_box(
|
||||||
|
gx - motor_w, gx + motor_w,
|
||||||
|
ay - motor_w * 0.7, ay + motor_w * 0.7,
|
||||||
|
motor_z0, motor_z1,
|
||||||
|
)
|
||||||
|
self.meshes.append((motor, COLORS["actuator"], 1.0))
|
||||||
|
|
||||||
|
# --- 바닥 slabs (각 EL) ---
|
||||||
|
|
||||||
|
def _build_floor_slabs(self):
|
||||||
|
"""각 중간 EL에 얇은 바닥 slab."""
|
||||||
|
p = self.p
|
||||||
|
hw = p.body_width / 2
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
slab_t = 0.3
|
||||||
|
|
||||||
|
for el in p.floor_elevations:
|
||||||
|
if el <= p.body_bottom_el + 0.5:
|
||||||
|
continue
|
||||||
|
if el >= p.body_top_el - 0.5:
|
||||||
|
continue
|
||||||
|
slab = self._make_box(
|
||||||
|
-hw + 0.5, hw - 0.5,
|
||||||
|
-hd + 0.5, hd - 0.5,
|
||||||
|
el - slab_t / 2, el + slab_t / 2,
|
||||||
|
)
|
||||||
|
self.meshes.append((slab, COLORS["floor"], 1.0))
|
||||||
|
|
||||||
|
# --- 지붕 ---
|
||||||
|
|
||||||
|
def _build_roof(self):
|
||||||
|
p = self.p
|
||||||
|
hw = p.body_width / 2 + 0.3 # 처마
|
||||||
|
hd = p.body_depth / 2 + 0.3
|
||||||
|
z0 = p.body_top_el
|
||||||
|
z1 = z0 + p.roof_thickness
|
||||||
|
|
||||||
|
roof = self._make_box(-hw, hw, -hd, hd, z0, z1)
|
||||||
|
self.meshes.append((roof, COLORS["roof"], 1.0))
|
||||||
|
|
||||||
|
# --- 호이스트 레일 ---
|
||||||
|
|
||||||
|
def _build_hoist_rail(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_hoist:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 레일은 본체 상단 천장 부근에 Y축 방향으로 길게
|
||||||
|
# 본체 폭 전체에 걸치거나 body_width의 80% 수준
|
||||||
|
hw = p.body_width / 2
|
||||||
|
rail_z = p.hoist_rail_el
|
||||||
|
rail_w = 0.3 # H빔 폭
|
||||||
|
rail_h = 0.4 # H빔 높이
|
||||||
|
|
||||||
|
# 레일 2개 (앞쪽, 뒤쪽)
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
for y_rail in [-hd + 1.5, hd - 1.5]:
|
||||||
|
rail = self._make_box(
|
||||||
|
-hw + 0.5, hw - 0.5,
|
||||||
|
y_rail - rail_w / 2, y_rail + rail_w / 2,
|
||||||
|
rail_z, rail_z + rail_h,
|
||||||
|
)
|
||||||
|
self.meshes.append((rail, COLORS["hoist_rail"], 1.0))
|
||||||
|
|
||||||
|
# --- 호이스트 크레인 (trolley) ---
|
||||||
|
|
||||||
|
def _build_hoist_crane(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_hoist:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 크레인 주황색 박스 (레일 중간에 매달린 형태)
|
||||||
|
crane_x = 0.0 # 중앙
|
||||||
|
crane_w = 1.5
|
||||||
|
crane_d = 2.5 # Y 방향
|
||||||
|
crane_h = 1.0
|
||||||
|
crane_z = p.hoist_rail_el + 0.4 # 레일 위
|
||||||
|
|
||||||
|
crane = self._make_box(
|
||||||
|
crane_x - crane_w / 2, crane_x + crane_w / 2,
|
||||||
|
-crane_d / 2, crane_d / 2,
|
||||||
|
crane_z, crane_z + crane_h,
|
||||||
|
)
|
||||||
|
self.meshes.append((crane, COLORS["hoist_crane"], 1.0))
|
||||||
|
|
||||||
|
# --- 점검구 커버 ---
|
||||||
|
|
||||||
|
def _build_inspection_cover(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_inspection_cover:
|
||||||
|
return
|
||||||
|
|
||||||
|
hw = p.body_width / 2
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
|
||||||
|
# 점검구는 지붕 위의 작은 네모 (계단식 2단)
|
||||||
|
cx = p.inspection_cover_x
|
||||||
|
cy = p.inspection_cover_y
|
||||||
|
size = p.inspection_cover_size
|
||||||
|
|
||||||
|
# 본체 내부로 위치 맞춤
|
||||||
|
cx = max(-hw + size, min(hw - size, cx))
|
||||||
|
cy = max(-hd + size, min(hd - size, cy))
|
||||||
|
|
||||||
|
z_roof = p.body_top_el
|
||||||
|
# 2단 계단
|
||||||
|
step1 = self._make_box(
|
||||||
|
cx - size / 2, cx + size / 2,
|
||||||
|
cy - size / 2, cy + size / 2,
|
||||||
|
z_roof + p.roof_thickness, z_roof + p.roof_thickness + 0.3,
|
||||||
|
)
|
||||||
|
self.meshes.append((step1, COLORS["inspect"], 1.0))
|
||||||
|
|
||||||
|
step2 = self._make_box(
|
||||||
|
cx - size / 3, cx + size / 3,
|
||||||
|
cy - size / 3, cy + size / 3,
|
||||||
|
z_roof + p.roof_thickness + 0.3, z_roof + p.roof_thickness + 0.6,
|
||||||
|
)
|
||||||
|
self.meshes.append((step2, COLORS["inspect"], 1.0))
|
||||||
|
|
||||||
|
# --- 외부 출입 계단 ---
|
||||||
|
|
||||||
|
def _build_entry_stairs(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_entry_stairs:
|
||||||
|
return
|
||||||
|
|
||||||
|
hw = p.body_width / 2
|
||||||
|
|
||||||
|
# 지상(body_bottom_el 주변)에서 body_top_el까지 계단
|
||||||
|
# 좌측에서 진입 기본
|
||||||
|
stair_w = p.stairs_width
|
||||||
|
z0 = p.body_bottom_el
|
||||||
|
z1 = p.body_top_el
|
||||||
|
total_rise = z1 - z0
|
||||||
|
|
||||||
|
# 계단 개수 (step height 0.17m 표준)
|
||||||
|
n_steps = max(int(total_rise / 0.17), 10)
|
||||||
|
step_h = total_rise / n_steps
|
||||||
|
step_d = 0.28 # 디딤판 깊이
|
||||||
|
|
||||||
|
# 계단 위치: 본체 좌측 외부
|
||||||
|
x_start = -hw - 1.0
|
||||||
|
if p.stairs_side == "right":
|
||||||
|
x_start = hw + 1.0
|
||||||
|
|
||||||
|
# 계단을 한 덩어리 경사판으로 표현 (간략화)
|
||||||
|
# 또는 각 단을 박스로
|
||||||
|
for i in range(n_steps):
|
||||||
|
z_step_bottom = z0
|
||||||
|
z_step_top = z0 + (i + 1) * step_h
|
||||||
|
if p.stairs_side == "left":
|
||||||
|
sx0 = x_start - (i + 1) * step_d
|
||||||
|
sx1 = x_start - i * step_d
|
||||||
|
else:
|
||||||
|
sx0 = x_start + i * step_d
|
||||||
|
sx1 = x_start + (i + 1) * step_d
|
||||||
|
|
||||||
|
step = self._make_box(
|
||||||
|
sx0, sx1,
|
||||||
|
-stair_w / 2, stair_w / 2,
|
||||||
|
z_step_bottom, z_step_top,
|
||||||
|
)
|
||||||
|
self.meshes.append((step, COLORS["stairs"], 1.0))
|
||||||
|
|
||||||
|
# --- 난간 / 파라펫 ---
|
||||||
|
|
||||||
|
def _build_parapet(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_parapet:
|
||||||
|
return
|
||||||
|
|
||||||
|
hw = p.body_width / 2
|
||||||
|
hd = p.body_depth / 2
|
||||||
|
z_base = p.body_top_el + p.roof_thickness
|
||||||
|
z_top = z_base + p.parapet_height
|
||||||
|
thick = 0.15
|
||||||
|
|
||||||
|
# 4면 파라펫
|
||||||
|
# 전
|
||||||
|
self._add_wall(-hw, hw, -hd, -hd + thick, z_base, z_top, COLORS["parapet"])
|
||||||
|
# 후
|
||||||
|
self._add_wall(-hw, hw, hd - thick, hd, z_base, z_top, COLORS["parapet"])
|
||||||
|
# 좌
|
||||||
|
self._add_wall(-hw, -hw + thick, -hd + thick, hd - thick, z_base, z_top, COLORS["parapet"])
|
||||||
|
# 우
|
||||||
|
self._add_wall(hw - thick, hw, -hd + thick, hd - thick, z_base, z_top, COLORS["parapet"])
|
||||||
|
|
||||||
|
# --- 지반 + 수면 ---
|
||||||
|
|
||||||
|
def _build_ground_and_water(self):
|
||||||
|
p = self.p
|
||||||
|
# 지반
|
||||||
|
ground_margin = max(p.body_width, p.body_depth) * 1.5
|
||||||
|
hw = p.body_width / 2 + ground_margin
|
||||||
|
hd = p.body_depth / 2 + ground_margin
|
||||||
|
# 연장부 고려
|
||||||
|
if p.has_l_extension:
|
||||||
|
hd += p.extension_length / 2
|
||||||
|
|
||||||
|
ground_pts = np.array([
|
||||||
|
[-hw, -hd, p.body_bottom_el - 0.5],
|
||||||
|
[hw, -hd, p.body_bottom_el - 0.5],
|
||||||
|
[hw, hd, p.body_bottom_el - 0.5],
|
||||||
|
[-hw, hd, p.body_bottom_el - 0.5],
|
||||||
|
])
|
||||||
|
ground = pv.PolyData(ground_pts, np.array([4, 0, 1, 2, 3]))
|
||||||
|
self.meshes.append((ground, COLORS["ground"], 1.0))
|
||||||
|
|
||||||
|
# 상류측 수면 (전방 Y=-hd 방향으로 평판)
|
||||||
|
if p.gates:
|
||||||
|
# 수면 EL = 가장 높은 수문 EL + 1m (상시만수위 근사)
|
||||||
|
max_gate_el = max(g.elevation for g in p.gates)
|
||||||
|
water_el = max_gate_el + 2.0 # 약간 여유
|
||||||
|
water_hw = p.body_width / 2 + 20
|
||||||
|
front_edge = -p.body_depth / 2
|
||||||
|
water_pts = np.array([
|
||||||
|
[-water_hw, front_edge - 30, water_el],
|
||||||
|
[water_hw, front_edge - 30, water_el],
|
||||||
|
[water_hw, front_edge + 0.3, water_el],
|
||||||
|
[-water_hw, front_edge + 0.3, water_el],
|
||||||
|
])
|
||||||
|
water = pv.PolyData(water_pts, np.array([4, 0, 1, 2, 3]))
|
||||||
|
self.meshes.append((water, COLORS["water"], 0.8))
|
||||||
|
|
||||||
|
|
||||||
|
# 편의 함수
|
||||||
|
def build_intake_tower_meshes(params: IntakeTowerParams):
|
||||||
|
return IntakeTowerBuilder(params).build_all()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from intake_tower_parser import parse_intake_tower
|
||||||
|
|
||||||
|
paths = [
|
||||||
|
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||||||
|
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf",
|
||||||
|
]
|
||||||
|
|
||||||
|
params = parse_intake_tower(paths)
|
||||||
|
print(params.summary())
|
||||||
|
|
||||||
|
builder = IntakeTowerBuilder(params)
|
||||||
|
meshes = builder.build_all()
|
||||||
|
print(f"\n{len(meshes)}개 구성요소 생성:")
|
||||||
|
for i, (m, c, o) in enumerate(meshes):
|
||||||
|
print(f" [{i:2}] {m.n_points:5}pts {m.n_cells:5}cells color={c}")
|
||||||
352
intake_tower_parser.py
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"""취수탑 (Intake Tower) 전용 DXF 파서.
|
||||||
|
|
||||||
|
취수탑 구조의 특성:
|
||||||
|
- L자 또는 직사각 콘크리트 본체 (여러 층 구조)
|
||||||
|
- 다수의 취수수문 (각각 다른 EL에 배치)
|
||||||
|
- 수문마다 개폐장치 (원통형)
|
||||||
|
- 상부 호이스트 크레인 + 레일
|
||||||
|
- 점검구, 계단, 사다리
|
||||||
|
- 여러 바닥 slab (각 EL별)
|
||||||
|
|
||||||
|
핵심 파싱 로직:
|
||||||
|
1. 뷰 검출: 평면도 / 정면도 / 측면도
|
||||||
|
2. 수문 위치: 정면도 내 반복되는 원(개폐장치 상징) → 개수 + 위치 + EL
|
||||||
|
3. 본체 외곽: 정면도 or 평면도의 최대 closed polygon
|
||||||
|
4. 주요 EL: 텍스트 "EL.XXX.XXX" 패턴
|
||||||
|
5. 호이스트 레일: 상단 긴 수평 LINE
|
||||||
|
6. 지붕 / 바닥 slabs: 여러 EL 별 수평선
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
parser = IntakeTowerParser()
|
||||||
|
params = parser.parse([plan_section_dxf_path])
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
|
||||||
|
from view_detector import detect_view_regions, ViewRegion
|
||||||
|
from dxf_geometry import extract_structural_geometry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 클래스
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GatePosition:
|
||||||
|
"""개별 취수수문 정보."""
|
||||||
|
index: int # 0부터
|
||||||
|
center_x: float # 본체 로컬 X (m)
|
||||||
|
elevation: float # EL (m, 해발)
|
||||||
|
actuator_radius: float = 0.6 # 개폐장치 원통 반경
|
||||||
|
gate_width: float = 2.0 # 수문 폭 (로컬 X방향)
|
||||||
|
gate_height: float = 2.0 # 수문 높이
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntakeTowerParams:
|
||||||
|
"""취수탑 파라미터 (단위: m)."""
|
||||||
|
|
||||||
|
# 본체 외곽
|
||||||
|
body_width: float = 11.2 # 가로
|
||||||
|
body_depth: float = 6.4 # 세로 (평면도에서)
|
||||||
|
body_bottom_el: float = 39.0 # 바닥 EL
|
||||||
|
body_top_el: float = 57.2 # 상단 EL
|
||||||
|
|
||||||
|
# L자 여부 (접근수로 옹벽 포함)
|
||||||
|
has_l_extension: bool = True # 한쪽으로 연장된 부분
|
||||||
|
extension_length: float = 14.5 # 연장 길이
|
||||||
|
extension_width: float = 6.4 # 연장 폭
|
||||||
|
extension_bottom_el: float = 41.0
|
||||||
|
|
||||||
|
# 수문 배치 (정면도 기준)
|
||||||
|
gates: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# 호이스트
|
||||||
|
has_hoist: bool = True
|
||||||
|
hoist_rail_el: float = 56.0 # 호이스트 레일 EL
|
||||||
|
hoist_rail_length: float = 10.0
|
||||||
|
|
||||||
|
# 지붕
|
||||||
|
roof_type: str = "flat" # flat | gabled
|
||||||
|
roof_thickness: float = 0.5
|
||||||
|
|
||||||
|
# 내부 바닥 slabs (각 EL)
|
||||||
|
floor_elevations: list = field(default_factory=list) # [43.0, 46.0, 48.5, ...]
|
||||||
|
|
||||||
|
# 외부 출입
|
||||||
|
has_entry_stairs: bool = True
|
||||||
|
stairs_width: float = 1.5
|
||||||
|
stairs_side: str = "left" # left | right | front | back
|
||||||
|
|
||||||
|
# 점검구
|
||||||
|
has_inspection_cover: bool = True
|
||||||
|
inspection_cover_x: float = 2.0 # 본체 로컬 X
|
||||||
|
inspection_cover_y: float = 3.0
|
||||||
|
inspection_cover_size: float = 2.5
|
||||||
|
|
||||||
|
# 난간
|
||||||
|
has_parapet: bool = True
|
||||||
|
parapet_height: float = 1.1
|
||||||
|
|
||||||
|
# 소스 파일
|
||||||
|
source_files: list = field(default_factory=list)
|
||||||
|
raw_annotations: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
return (
|
||||||
|
f"Intake Tower: {self.body_width:.1f} × {self.body_depth:.1f}m, "
|
||||||
|
f"EL.{self.body_bottom_el:.1f}~{self.body_top_el:.1f} "
|
||||||
|
f"(H={self.body_top_el - self.body_bottom_el:.1f}m)\n"
|
||||||
|
f" 수문 {len(self.gates)}개, 바닥 {len(self.floor_elevations)}개 EL, "
|
||||||
|
f"호이스트 {'O' if self.has_hoist else 'X'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 파서
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EL_PATTERN = re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class IntakeTowerParser:
|
||||||
|
"""취수탑 DXF 파서."""
|
||||||
|
|
||||||
|
def parse(self, dxf_paths: list[str]) -> IntakeTowerParams:
|
||||||
|
"""여러 DXF 파일에서 파라미터 추출."""
|
||||||
|
params = IntakeTowerParams()
|
||||||
|
params.source_files = list(dxf_paths)
|
||||||
|
|
||||||
|
# 모든 DXF를 순회하며 정보 수집
|
||||||
|
for path in dxf_paths:
|
||||||
|
try:
|
||||||
|
self._parse_single(path, params)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 파싱 오류 ({path}): {e}")
|
||||||
|
|
||||||
|
# 정리 및 정규화
|
||||||
|
self._finalize_params(params)
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _parse_single(self, path: str, params: IntakeTowerParams):
|
||||||
|
"""단일 DXF에서 정보 추출 → params에 누적."""
|
||||||
|
doc = ezdxf.readfile(path)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
geom = extract_structural_geometry(path)
|
||||||
|
scale = geom.unit_scale
|
||||||
|
|
||||||
|
views = detect_view_regions(path)
|
||||||
|
|
||||||
|
# 1) 표고(EL) 텍스트 수집
|
||||||
|
el_texts = self._collect_el_texts(msp, scale)
|
||||||
|
params.raw_annotations.extend(
|
||||||
|
[(f"EL.{v:.2f}", x, y) for (x, y, v) in el_texts]
|
||||||
|
)
|
||||||
|
|
||||||
|
if el_texts:
|
||||||
|
els = [v for (_, _, v) in el_texts]
|
||||||
|
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")
|
||||||
|
if front_view:
|
||||||
|
gates = self._detect_gates_in_front_view(msp, front_view, el_texts, scale)
|
||||||
|
if gates:
|
||||||
|
params.gates = gates
|
||||||
|
|
||||||
|
# 3) 평면도 영역에서 본체 크기 추정
|
||||||
|
plan_view = self._find_view(views, "plan")
|
||||||
|
if plan_view:
|
||||||
|
# 평면도 bbox를 본체 크기로 (근사)
|
||||||
|
params.body_width = max(params.body_width, plan_view.width)
|
||||||
|
params.body_depth = max(params.body_depth, plan_view.height)
|
||||||
|
|
||||||
|
# 4) 호이스트 레일 검출 (상단 긴 수평선)
|
||||||
|
hoist = self._detect_hoist_rail(msp, scale, params.body_top_el)
|
||||||
|
if hoist:
|
||||||
|
params.hoist_rail_el = hoist["el"]
|
||||||
|
params.hoist_rail_length = hoist["length"]
|
||||||
|
params.has_hoist = True
|
||||||
|
|
||||||
|
# 5) 바닥 EL 목록 (표고 텍스트 + 수문 EL)
|
||||||
|
floor_els = set()
|
||||||
|
for (_, _, v) in el_texts:
|
||||||
|
if v > params.body_bottom_el + 0.5 and v < params.body_top_el - 0.5:
|
||||||
|
floor_els.add(round(v, 1))
|
||||||
|
for g in params.gates:
|
||||||
|
floor_els.add(round(g.elevation, 1))
|
||||||
|
params.floor_elevations = sorted(floor_els)
|
||||||
|
|
||||||
|
def _collect_el_texts(self, msp, scale: float) -> list[tuple]:
|
||||||
|
"""모든 EL. 텍스트 수집 → [(x, y, value), ...]."""
|
||||||
|
results = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxftype() not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txt = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "")
|
||||||
|
m = EL_PATTERN.search(txt)
|
||||||
|
if m:
|
||||||
|
pos = e.dxf.insert
|
||||||
|
results.append((pos.x * scale, pos.y * scale, float(m.group(1))))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _find_view(self, views: list[ViewRegion], view_type: str) -> ViewRegion | None:
|
||||||
|
for v in views:
|
||||||
|
if v.view_type == view_type:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _detect_gates_in_front_view(self, msp, front_view: ViewRegion,
|
||||||
|
el_texts: list, scale: float) -> list[GatePosition]:
|
||||||
|
"""정면도 내 반복되는 원(개폐장치) → 수문 배치.
|
||||||
|
|
||||||
|
반복 조건: 같은 반경의 원이 3개 이상, 같은 X 또는 Y선상 정렬.
|
||||||
|
"""
|
||||||
|
# 정면도 bbox (월드 좌표, m)
|
||||||
|
fx0, fy0, fx1, fy1 = front_view.bounds
|
||||||
|
# margin 확대 (원이 bbox 경계 걸쳐 있을 수 있음)
|
||||||
|
margin = 1.0
|
||||||
|
fx0 -= margin; fy0 -= margin; fx1 += margin; fy1 += margin
|
||||||
|
|
||||||
|
# 정면도 영역 안의 원들 수집
|
||||||
|
circles_in_view = []
|
||||||
|
for e in msp.query("CIRCLE"):
|
||||||
|
try:
|
||||||
|
cx = e.dxf.center.x * scale
|
||||||
|
cy = e.dxf.center.y * scale
|
||||||
|
r = e.dxf.radius * scale
|
||||||
|
if fx0 <= cx <= fx1 and fy0 <= cy <= fy1:
|
||||||
|
# 너무 작은 원(볼트/리벳)은 제외
|
||||||
|
if r < 0.05:
|
||||||
|
continue
|
||||||
|
circles_in_view.append((cx, cy, r, e.dxf.layer))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(circles_in_view) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 반경별 그룹화 (0.1m 허용오차)
|
||||||
|
from collections import defaultdict
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for cx, cy, r, layer in circles_in_view:
|
||||||
|
key = round(r, 1)
|
||||||
|
groups[key].append((cx, cy, r, layer))
|
||||||
|
|
||||||
|
# 3개 이상의 그룹 우선 (수문은 보통 3문), 2개도 허용
|
||||||
|
candidate_groups = [g for g in groups.values() if len(g) >= 2]
|
||||||
|
if not candidate_groups:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 가장 큰 반경의 그룹 선택 (수문 개폐장치는 보통 큼)
|
||||||
|
candidate_groups.sort(key=lambda g: (-g[0][2], -len(g)))
|
||||||
|
main_group = candidate_groups[0]
|
||||||
|
|
||||||
|
# 수문 위치 확정: X 또는 Y 정렬 여부 확인 (현재는 정면도 가정 — 좌우/상하 배치 분기는 미구현)
|
||||||
|
# X 변화가 크면 → 수문이 좌우 배치 (평면도), Y 변화가 크면 → 상하 배치 (정면도, EL별)
|
||||||
|
gates = []
|
||||||
|
# 로컬 X 좌표 계산 (정면도 내에서 중심 기준)
|
||||||
|
front_cx = (front_view.bounds[0] + front_view.bounds[2]) / 2
|
||||||
|
|
||||||
|
for i, (cx, cy, r, layer) in enumerate(sorted(main_group, key=lambda c: c[1])):
|
||||||
|
# EL 추정: cy 좌표 근처의 EL 텍스트 찾기
|
||||||
|
best_el = 43.0 + i * 2.5 # 기본값
|
||||||
|
best_dist = 5.0
|
||||||
|
for (ex, ey, ev) in el_texts:
|
||||||
|
if abs(ey - cy) < best_dist:
|
||||||
|
best_dist = abs(ey - cy)
|
||||||
|
best_el = ev
|
||||||
|
|
||||||
|
local_x = cx - front_cx
|
||||||
|
gates.append(GatePosition(
|
||||||
|
index=i,
|
||||||
|
center_x=local_x,
|
||||||
|
elevation=best_el,
|
||||||
|
actuator_radius=r,
|
||||||
|
gate_width=max(r * 3, 1.5),
|
||||||
|
gate_height=max(r * 3, 1.5),
|
||||||
|
label=f"수문{i+1} EL.{best_el:.2f}",
|
||||||
|
))
|
||||||
|
|
||||||
|
return gates
|
||||||
|
|
||||||
|
def _detect_hoist_rail(self, msp, scale: float, top_el: float) -> dict | None:
|
||||||
|
"""상단 긴 수평선 검출 → 호이스트 레일."""
|
||||||
|
best = None
|
||||||
|
for e in msp.query("LINE"):
|
||||||
|
try:
|
||||||
|
s = e.dxf.start
|
||||||
|
en = e.dxf.end
|
||||||
|
dx = abs(en.x - s.x) * scale
|
||||||
|
dy = abs(en.y - s.y) * scale
|
||||||
|
# 수평선 + 길이 5m 이상
|
||||||
|
if dy < 0.3 and dx > 5.0:
|
||||||
|
y_el = s.y * scale
|
||||||
|
# 상단 1/3 영역만 (top_el 부근)
|
||||||
|
# y값의 절대 위치는 EL과 꼭 맞진 않음 → 도면 좌표계 기준으로 위쪽 1/3
|
||||||
|
if best is None or dx > best["length"]:
|
||||||
|
best = {"el": top_el - 1.5, "length": dx, "y_raw": y_el}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return best
|
||||||
|
|
||||||
|
def _finalize_params(self, params: IntakeTowerParams):
|
||||||
|
"""파라미터 정리 및 기본값 보완."""
|
||||||
|
# 바닥 EL은 수문 최저 EL 아래로 조정
|
||||||
|
if params.gates:
|
||||||
|
min_gate_el = min(g.elevation for g in params.gates)
|
||||||
|
if params.body_bottom_el > min_gate_el - 1:
|
||||||
|
params.body_bottom_el = min_gate_el - 4.0
|
||||||
|
|
||||||
|
# 수문이 하나도 없으면 기본 3문 가정
|
||||||
|
if not params.gates:
|
||||||
|
for i in range(3):
|
||||||
|
params.gates.append(GatePosition(
|
||||||
|
index=i,
|
||||||
|
center_x=(i - 1) * 3.0,
|
||||||
|
elevation=params.body_bottom_el + 4 + i * 2.5,
|
||||||
|
actuator_radius=0.6,
|
||||||
|
gate_width=2.0,
|
||||||
|
gate_height=2.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 호이스트 레일 EL이 상단과 불일치하면 상단 - 2m로 조정
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# 편의 함수
|
||||||
|
def parse_intake_tower(dxf_paths: list[str]) -> IntakeTowerParams:
|
||||||
|
return IntakeTowerParser().parse(dxf_paths)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
paths = sys.argv[1:] if len(sys.argv) > 1 else [
|
||||||
|
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||||||
|
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf",
|
||||||
|
]
|
||||||
|
|
||||||
|
params = parse_intake_tower(paths)
|
||||||
|
print(params.summary())
|
||||||
|
print()
|
||||||
|
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}")
|
||||||
|
if params.has_hoist:
|
||||||
|
print(f"호이스트 레일: EL.{params.hoist_rail_el:.1f}, 길이 {params.hoist_rail_length:.1f}m")
|
||||||
168
optional_detector.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""구조물 부속 컴포넌트(공도교/개폐장치/사다리/덮개/에이프런 등) 존재 여부를
|
||||||
|
DXF 레이어·엔티티·텍스트 신호로 검출하는 공통 헬퍼.
|
||||||
|
|
||||||
|
배경:
|
||||||
|
각 구조물 파서(gate, valve_chamber, intake_tower, retaining_wall, …)에서
|
||||||
|
"도면에 실제로 있는 것만 빌드"하기 위해 `has_X` 플래그를 채택. 검출 로직이
|
||||||
|
파서마다 복붙되면 유지보수 비용과 drift 위험이 크므로, 본 모듈로 통합.
|
||||||
|
|
||||||
|
사용 예:
|
||||||
|
from optional_detector import ComponentSpec, detect_components
|
||||||
|
|
||||||
|
specs = [
|
||||||
|
ComponentSpec(
|
||||||
|
name="service_bridge",
|
||||||
|
layer_tokens=("bridge", "공도교", "공도", "관리도로", "service road"),
|
||||||
|
text_keywords=("공도교", "service bridge", "관리교", "관리도로"),
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
name="hoist_housings",
|
||||||
|
layer_tokens=("hoist", "권양", "winch", "gantry"),
|
||||||
|
text_keywords=("권양기", "hoist", "winch"),
|
||||||
|
default=True, # 래디얼 게이트엔 통상 동반
|
||||||
|
preserve_default_on_no_signal=True, # 신호 부재 시 default 유지
|
||||||
|
),
|
||||||
|
]
|
||||||
|
report = detect_components(msp, specs)
|
||||||
|
params.has_service_bridge = report["service_bridge"].present
|
||||||
|
params.has_hoist_housings = report["hoist_housings"].present
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
# geometry로 간주할 엔티티 타입 (TEXT/MTEXT/DIMENSION 등 주석은 제외)
|
||||||
|
GEOM_TYPES: frozenset[str] = frozenset({
|
||||||
|
"LWPOLYLINE", "POLYLINE", "LINE", "CIRCLE", "ARC", "ELLIPSE",
|
||||||
|
"SPLINE", "3DFACE", "SOLID", "HATCH",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComponentSpec:
|
||||||
|
"""한 부속 컴포넌트의 검출 규칙."""
|
||||||
|
name: str # 결과 dict의 key
|
||||||
|
layer_tokens: tuple[str, ...] # 레이어명 부분일치 (대소문자 무시)
|
||||||
|
text_keywords: tuple[str, ...] = () # TEXT/MTEXT 부분일치 (대소문자 무시)
|
||||||
|
default: bool = False # 판정 불가 시 기본값
|
||||||
|
# True면 "신호 부재"를 False로 낮추지 않고 default 유지 (예: 개폐장치처럼
|
||||||
|
# 일반적으로 있으나 별도 레이어로 분리되지 않는 부속)
|
||||||
|
preserve_default_on_no_signal: bool = False
|
||||||
|
# geometry 대신 text 신호만으로도 True 허용 여부 (false positive 방지 기본 False)
|
||||||
|
allow_text_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComponentReport:
|
||||||
|
"""검출 결과."""
|
||||||
|
present: bool
|
||||||
|
geom_count: int
|
||||||
|
text_count: int
|
||||||
|
matched_layers: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return (f"present={self.present} (geom={self.geom_count}, "
|
||||||
|
f"txt={self.text_count}, layers={sorted(self.matched_layers)})")
|
||||||
|
|
||||||
|
|
||||||
|
def count_layer_geom(msp, tokens: Iterable[str]) -> tuple[int, set[str]]:
|
||||||
|
"""주어진 레이어 토큰에 부분일치하는 geometry 엔티티 수 + 매칭 레이어 집합.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msp: ezdxf modelspace
|
||||||
|
tokens: 레이어명에 포함되어야 할 문자열들 (대소문자 무시, 부분일치)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(count, matched_layer_names)
|
||||||
|
"""
|
||||||
|
lowered = tuple(t.lower() for t in tokens)
|
||||||
|
count = 0
|
||||||
|
matched: set[str] = set()
|
||||||
|
for e in msp:
|
||||||
|
try:
|
||||||
|
layer = e.dxf.layer
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
lname = layer.lower()
|
||||||
|
if not any(t in lname for t in lowered):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
etype = e.dxftype()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if etype in GEOM_TYPES:
|
||||||
|
count += 1
|
||||||
|
matched.add(layer)
|
||||||
|
return count, matched
|
||||||
|
|
||||||
|
|
||||||
|
def count_text_hits(msp, keywords: Iterable[str]) -> int:
|
||||||
|
"""TEXT/MTEXT에서 키워드 부분일치 엔티티 수 (대소문자 무시)."""
|
||||||
|
lowered = tuple(k.lower() for k in keywords)
|
||||||
|
if not lowered:
|
||||||
|
return 0
|
||||||
|
n = 0
|
||||||
|
for e in msp:
|
||||||
|
try:
|
||||||
|
etype = e.dxftype()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if etype not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txt = e.dxf.text if etype == "TEXT" else (e.text or "")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
tl = txt.lower()
|
||||||
|
if any(k in tl for k in lowered):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def detect_component(msp, spec: ComponentSpec) -> ComponentReport:
|
||||||
|
"""단일 컴포넌트 검출.
|
||||||
|
|
||||||
|
판정 규칙:
|
||||||
|
1) layer geometry count > 0 → present = True (확정)
|
||||||
|
2) allow_text_only=True 이고 text count > 0 → present = True
|
||||||
|
3) preserve_default_on_no_signal=True 이고 신호 둘 다 0 → default 유지
|
||||||
|
4) 그 외 → present = False (default가 True였어도 낮춤)
|
||||||
|
"""
|
||||||
|
geom_count, matched = count_layer_geom(msp, spec.layer_tokens)
|
||||||
|
text_count = count_text_hits(msp, spec.text_keywords)
|
||||||
|
|
||||||
|
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로 낮춤
|
||||||
|
# 단, default=True 이지만 preserve_default_on_no_signal=False 인 경우,
|
||||||
|
# text 약신호라도 있으면 True 유지 여지
|
||||||
|
present = bool(spec.default and text_count > 0)
|
||||||
|
|
||||||
|
return ComponentReport(
|
||||||
|
present=present,
|
||||||
|
geom_count=geom_count,
|
||||||
|
text_count=text_count,
|
||||||
|
matched_layers=sorted(matched),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_components(msp, specs: Iterable[ComponentSpec]
|
||||||
|
) -> dict[str, ComponentReport]:
|
||||||
|
"""다중 컴포넌트 일괄 검출. 결과는 name → ComponentReport."""
|
||||||
|
return {spec.name: detect_component(msp, spec) for spec in specs}
|
||||||
|
|
||||||
|
|
||||||
|
def summary_line(reports: dict[str, ComponentReport]) -> str:
|
||||||
|
"""검출 결과를 raw_text_annotations에 넣기 좋은 한 줄 요약."""
|
||||||
|
parts = []
|
||||||
|
for name, rep in reports.items():
|
||||||
|
parts.append(f"{name}={rep.present}(g={rep.geom_count},t={rep.text_count})")
|
||||||
|
return "[detect] " + ", ".join(parts)
|
||||||
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()
|
||||||
225
polygon_reconstructor.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""개방선(line-soup) 집합에서 폐합 폴리곤(면)을 복원하는 기하 유틸리티.
|
||||||
|
|
||||||
|
배경:
|
||||||
|
CAD 도면은 구조물 외곽이나 교각을 단일 폐합 폴리라인이 아닌 여러 개의 선분
|
||||||
|
(LINE + LWPOLYLINE의 인접 점 쌍 등)으로 그리는 경우가 많다. 이 모듈은
|
||||||
|
그런 "개방선 수프(line-soup)"에서 실제로 둘러싸인 영역(face)을 planar 그래프
|
||||||
|
face-enumeration 알고리즘으로 복원한다.
|
||||||
|
|
||||||
|
알고리즘:
|
||||||
|
1. 모든 선분 끝점을 공차(tol) 내에서 단일 vertex로 묶음 (그리드 해싱)
|
||||||
|
2. 무방향 인접 리스트(vertex → 이웃들) 구성
|
||||||
|
3. "Leftmost-turn traversal": 각 방향 간선 (u→v)에서 시작해, 다음 정점에서
|
||||||
|
들어온 방향 기준 **왼쪽으로 가장 많이 꺾는** 이웃을 선택, 시작점 복귀
|
||||||
|
시까지 반복. → 평면 그래프의 모든 face 순환 열거.
|
||||||
|
4. Dedup: 회전·반사로 동일한 face는 canonical form (최소 vertex id를
|
||||||
|
시작으로 하는 정방향/역방향 중 사전순 작은 것)으로 중복 제거.
|
||||||
|
5. 부호 있는 면적으로 외곽 면(가장 큰 면, 혹은 음의 signed area)은 caller가
|
||||||
|
선택해 제거 가능.
|
||||||
|
|
||||||
|
반환:
|
||||||
|
List[(polygon_pts, area_unsigned)], area 내림차순.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
Point = tuple[float, float]
|
||||||
|
Segment = tuple[Point, Point]
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_key(p: Point, grid: float) -> tuple[int, int]:
|
||||||
|
return (math.floor(p[0] / grid), math.floor(p[1] / grid))
|
||||||
|
|
||||||
|
|
||||||
|
class _VertexStore:
|
||||||
|
"""공차 내 점을 동일 vertex ID로 묶는 그리드 해시 기반 저장소."""
|
||||||
|
|
||||||
|
def __init__(self, tol: float):
|
||||||
|
self.tol = tol
|
||||||
|
self.tol_sq = tol * tol
|
||||||
|
self.grid = max(tol * 2.0, 1e-6)
|
||||||
|
self._grid: dict[tuple[int, int], list[int]] = {}
|
||||||
|
self.points: list[Point] = []
|
||||||
|
|
||||||
|
def get_or_add(self, p: Point) -> int:
|
||||||
|
key = _grid_key(p, self.grid)
|
||||||
|
# 주변 3x3 셀 검사
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
bucket = self._grid.get((key[0] + dx, key[1] + dy))
|
||||||
|
if bucket:
|
||||||
|
for vid in bucket:
|
||||||
|
q = self.points[vid]
|
||||||
|
if (q[0] - p[0]) ** 2 + (q[1] - p[1]) ** 2 <= self.tol_sq:
|
||||||
|
return vid
|
||||||
|
vid = len(self.points)
|
||||||
|
self.points.append(p)
|
||||||
|
self._grid.setdefault(key, []).append(vid)
|
||||||
|
return vid
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_area(pts: list[Point]) -> float:
|
||||||
|
a = 0.0
|
||||||
|
n = len(pts)
|
||||||
|
for i in range(n):
|
||||||
|
x1, y1 = pts[i]
|
||||||
|
x2, y2 = pts[(i + 1) % n]
|
||||||
|
a += x1 * y2 - x2 * y1
|
||||||
|
return a * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(face_ids: list[int]) -> tuple:
|
||||||
|
"""Face 순환을 rotation/reversal 무관하게 고유 식별하는 canonical tuple."""
|
||||||
|
n = len(face_ids)
|
||||||
|
if n == 0:
|
||||||
|
return ()
|
||||||
|
# 각 회전·반사 중 사전순 최소
|
||||||
|
best = None
|
||||||
|
for direction in (face_ids, list(reversed(face_ids))):
|
||||||
|
# 최소 시작 인덱스
|
||||||
|
min_id = min(direction)
|
||||||
|
start = direction.index(min_id)
|
||||||
|
rotated = tuple(direction[start:] + direction[:start])
|
||||||
|
if best is None or rotated < best:
|
||||||
|
best = rotated
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct_polygons(segments: Iterable[Segment],
|
||||||
|
tol: float = 5.0,
|
||||||
|
min_area: float = 100.0,
|
||||||
|
max_faces: int = 5000) -> list[tuple[list[Point], float]]:
|
||||||
|
"""개방선 집합에서 폐합 폴리곤(면) 복원.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: [(p1, p2), ...] 각 선분
|
||||||
|
tol: 끝점 동일시 공차 (선분 단위와 동일; DXF mm면 mm 단위)
|
||||||
|
min_area: 이 값 이하의 면은 버림 (노이즈 억제)
|
||||||
|
max_faces: 안전 상한 (무한 루프 방지)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(polygon_pts, unsigned_area), ...] 면적 내림차순. polygon_pts는 닫히지
|
||||||
|
않은 형태 (끝점이 시작점과 중복되지 않음).
|
||||||
|
"""
|
||||||
|
# 1) Vertex 단일화
|
||||||
|
vs = _VertexStore(tol)
|
||||||
|
edges: set[tuple[int, int]] = set() # 무방향 간선 (u<v)
|
||||||
|
for p1, p2 in segments:
|
||||||
|
u = vs.get_or_add(p1)
|
||||||
|
v = vs.get_or_add(p2)
|
||||||
|
if u == v:
|
||||||
|
continue
|
||||||
|
if u > v:
|
||||||
|
u, v = v, u
|
||||||
|
edges.add((u, v))
|
||||||
|
|
||||||
|
if not edges:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2) 인접 리스트 (각 vertex → 이웃 정렬 목록)
|
||||||
|
adj: dict[int, list[int]] = {}
|
||||||
|
for u, v in edges:
|
||||||
|
adj.setdefault(u, []).append(v)
|
||||||
|
adj.setdefault(v, []).append(u)
|
||||||
|
|
||||||
|
# 3) Leftmost-turn face enumeration
|
||||||
|
def _ang(a_id: int, b_id: int) -> float:
|
||||||
|
ax, ay = vs.points[a_id]
|
||||||
|
bx, by = vs.points[b_id]
|
||||||
|
return math.atan2(by - ay, bx - ax)
|
||||||
|
|
||||||
|
visited: set[tuple[int, int]] = set() # 방향 간선
|
||||||
|
canonical_faces: set[tuple] = set()
|
||||||
|
result_faces: list[list[int]] = []
|
||||||
|
|
||||||
|
# 각 방향 간선 (u→v)에 대해 한 번씩
|
||||||
|
for u in adj:
|
||||||
|
for v in adj[u]:
|
||||||
|
if (u, v) in visited:
|
||||||
|
continue
|
||||||
|
# 이 방향에서 face 한 개 추적
|
||||||
|
face = [u]
|
||||||
|
prev, curr = u, v
|
||||||
|
steps = 0
|
||||||
|
# 평면 그래프에서 face 길이 <= 전체 간선 수 × 2 안쪽
|
||||||
|
step_cap = len(edges) * 2 + 10
|
||||||
|
aborted = False
|
||||||
|
while steps < step_cap:
|
||||||
|
visited.add((prev, curr))
|
||||||
|
face.append(curr)
|
||||||
|
if curr == u:
|
||||||
|
break
|
||||||
|
# 다음 vertex: curr에서 prev로의 방향 기준 leftmost turn
|
||||||
|
# (2π 안쪽 가장 작은 양수 turn_angle 이웃 선택)
|
||||||
|
back_angle = _ang(curr, prev)
|
||||||
|
candidates = [n for n in adj[curr] if n != prev]
|
||||||
|
if not candidates:
|
||||||
|
aborted = True
|
||||||
|
break
|
||||||
|
best_n = None
|
||||||
|
best_t = None
|
||||||
|
for n in candidates:
|
||||||
|
out = _ang(curr, n)
|
||||||
|
t = (out - back_angle) % (2.0 * math.pi)
|
||||||
|
# 완전 역방향(동일 간선 되돌아가기, t≈0 또는 2π)은 마지막 수단
|
||||||
|
if t < 1e-9:
|
||||||
|
t = 2.0 * math.pi
|
||||||
|
if best_t is None or t < best_t:
|
||||||
|
best_t = t
|
||||||
|
best_n = n
|
||||||
|
prev, curr = curr, best_n
|
||||||
|
steps += 1
|
||||||
|
if len(result_faces) + len(canonical_faces) > max_faces:
|
||||||
|
aborted = True
|
||||||
|
break
|
||||||
|
if aborted or len(face) < 4:
|
||||||
|
continue
|
||||||
|
# 마지막 원소는 시작점 중복 → 제거
|
||||||
|
face_ids = face[:-1]
|
||||||
|
canon = _canonical(face_ids)
|
||||||
|
if canon in canonical_faces:
|
||||||
|
continue
|
||||||
|
canonical_faces.add(canon)
|
||||||
|
result_faces.append(face_ids)
|
||||||
|
|
||||||
|
# 4) 면적 계산 + 필터·정렬
|
||||||
|
polys: list[tuple[list[Point], float]] = []
|
||||||
|
for face_ids in result_faces:
|
||||||
|
pts = [vs.points[v] for v in face_ids]
|
||||||
|
area = abs(_signed_area(pts))
|
||||||
|
if area < min_area:
|
||||||
|
continue
|
||||||
|
polys.append((pts, area))
|
||||||
|
|
||||||
|
polys.sort(key=lambda t: -t[1])
|
||||||
|
return polys
|
||||||
|
|
||||||
|
|
||||||
|
def segments_from_lines(lines: Iterable[tuple[Point, Point]]) -> list[Segment]:
|
||||||
|
"""편의: LINE 엔티티들을 segment 리스트로 변환."""
|
||||||
|
return [(p1, p2) for p1, p2 in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def segments_from_polyline(pts: list[Point]) -> list[Segment]:
|
||||||
|
"""LWPOLYLINE 점 목록 → 인접 segment 쌍."""
|
||||||
|
from itertools import pairwise
|
||||||
|
return list(pairwise(pts))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 간단 스모크: 두 개의 겹친 사각형
|
||||||
|
segs = [
|
||||||
|
# 외곽 10x10
|
||||||
|
((0, 0), (10, 0)), ((10, 0), (10, 10)),
|
||||||
|
((10, 10), (0, 10)), ((0, 10), (0, 0)),
|
||||||
|
# 내부 사각형 2~5 x 2~5
|
||||||
|
((2, 2), (5, 2)), ((5, 2), (5, 5)),
|
||||||
|
((5, 5), (2, 5)), ((2, 5), (2, 2)),
|
||||||
|
]
|
||||||
|
polys = reconstruct_polygons(segs, tol=0.01, min_area=0.1)
|
||||||
|
print(f"found {len(polys)} polygons")
|
||||||
|
for i, (pts, area) in enumerate(polys):
|
||||||
|
print(f" #{i}: area={area:.2f}, pts={pts}")
|
||||||
59
prompt_templates/prompt_v1.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
version: "v1"
|
||||||
|
description: "S-CANVAS 조감도 렌더링 프롬프트 v1 - 구조 보존 최적화"
|
||||||
|
|
||||||
|
# 시간대별 조명/하늘 묘사
|
||||||
|
time_presets:
|
||||||
|
daytime: "bright daylight, clear blue sky, sharp shadows, vivid green vegetation"
|
||||||
|
sunset: "golden hour sunset, warm orange light, long dramatic shadows, glowing sky"
|
||||||
|
night: "nighttime aerial view, moonlight reflections on water, city lights in distance, dark blue sky"
|
||||||
|
dawn: "early dawn, soft pink and purple sky, morning mist over valleys, gentle light"
|
||||||
|
overcast: "overcast sky, diffused soft light, muted colors, atmospheric fog"
|
||||||
|
|
||||||
|
# 카메라 앙각별 시점 묘사
|
||||||
|
angle_presets:
|
||||||
|
top_down: "top-down overhead aerial view, directly above looking straight down"
|
||||||
|
high_angle: "high-angle bird's-eye view, slightly tilted perspective"
|
||||||
|
oblique: "oblique aerial perspective, 3/4 view showing terrain depth"
|
||||||
|
low_angle: "low-angle dramatic perspective, cinematic sweep"
|
||||||
|
|
||||||
|
# 앙각 → 프리셋 매핑 경계
|
||||||
|
angle_thresholds:
|
||||||
|
top_down: 70 # >= 70도
|
||||||
|
high_angle: 45 # >= 45도
|
||||||
|
oblique: 30 # >= 30도
|
||||||
|
low_angle: 0 # < 30도
|
||||||
|
|
||||||
|
# 구조 보존 핵심 지시문 (항상 포함)
|
||||||
|
structure_preservation:
|
||||||
|
- "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"
|
||||||
|
|
||||||
|
# 품질 향상 지시문
|
||||||
|
quality_enhancement:
|
||||||
|
- "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_prompt: >-
|
||||||
|
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
|
||||||
|
|
||||||
|
# Control Structure 파라미터
|
||||||
|
control_params:
|
||||||
|
control_strength: 0.85
|
||||||
|
output_format: "png"
|
||||||
|
|
||||||
|
# img2img 파라미터
|
||||||
|
img2img_params:
|
||||||
|
model: "sd3.5-large"
|
||||||
|
mode: "image-to-image"
|
||||||
|
output_format: "png"
|
||||||
|
# strength는 UI 슬라이더에서 동적으로 받음 (기본 0.4)
|
||||||
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
|
||||||
48
requirements.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.6.1
|
||||||
|
rasterio==1.4.3 # optional — for cache/dem/local.tif (NGII GeoTIFF). 미설치 시 AWS Terrarium 만 사용.
|
||||||
|
|
||||||
|
# --- Numerical ---
|
||||||
|
numpy==2.0.2
|
||||||
|
scipy==1.13.1
|
||||||
|
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
|
||||||
134
resource_paths.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""S-CANVAS 런타임 경로 해석.
|
||||||
|
|
||||||
|
PyInstaller 로 .exe 패키징 시:
|
||||||
|
- **읽기 전용 자산**(Design/, prompt_templates/, structure_types/) 은
|
||||||
|
`sys._MEIPASS` (onefile) 또는 .exe 옆 폴더(onedir) 에서 읽음.
|
||||||
|
- **쓰기 데이터**(scanvas_jobs.db, *.log, cache/) 는 사용자별 영구 폴더
|
||||||
|
`%LOCALAPPDATA%\\S-CANVAS\\` 에 저장. 설치 폴더 쓰기 권한 불필요(Program
|
||||||
|
Files 설치 OK).
|
||||||
|
|
||||||
|
개발 모드(소스 직접 실행) 에서는:
|
||||||
|
- 자산: 소스 트리 옆 (`Path(__file__).parent / "Design"` 등)
|
||||||
|
- 쓰기 데이터: 동일하게 `%LOCALAPPDATA%\\S-CANVAS\\` 사용. 개발 중에도 설치
|
||||||
|
배포본과 같은 경로에 쓰므로 행동이 일관됨. 단, 개발 편의를 위해 환경변수
|
||||||
|
`SCANVAS_DEV_LOCAL=1` 설정 시 소스 트리 옆에 쓴다(과거 호환).
|
||||||
|
|
||||||
|
환경변수 오버라이드:
|
||||||
|
- `SCANVAS_USER_DATA` — 쓰기 데이터 루트를 임의 경로로 강제(테스트/CI 용)
|
||||||
|
- `SCANVAS_DEV_LOCAL=1` — 쓰기 데이터를 소스 트리 옆에 (개발 모드)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 자산(읽기) 경로 ────────────────────────────
|
||||||
|
|
||||||
|
def _bundled() -> bool:
|
||||||
|
"""PyInstaller 번들 안인가?"""
|
||||||
|
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
|
||||||
|
|
||||||
|
|
||||||
|
def asset_root() -> Path:
|
||||||
|
"""읽기 전용 자산(Design/, prompt_templates/ 등) 의 부모 디렉토리.
|
||||||
|
|
||||||
|
- PyInstaller onefile: `sys._MEIPASS` (임시 추출 폴더)
|
||||||
|
- PyInstaller onedir: `sys._MEIPASS` 또는 .exe 옆
|
||||||
|
- dev: `Path(__file__).resolve().parent`
|
||||||
|
"""
|
||||||
|
if _bundled():
|
||||||
|
return Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def resource_path(*parts: str) -> Path:
|
||||||
|
"""asset_root 아래 상대경로 해석. e.g. resource_path("Design", "Logo.png")."""
|
||||||
|
return asset_root().joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 쓰기 데이터 경로 ────────────────────────────
|
||||||
|
|
||||||
|
def _windows_localappdata() -> Path:
|
||||||
|
base = os.environ.get("LOCALAPPDATA")
|
||||||
|
if base:
|
||||||
|
return Path(base)
|
||||||
|
# XDG-ish 폴백
|
||||||
|
home = Path.home()
|
||||||
|
return home / "AppData" / "Local"
|
||||||
|
|
||||||
|
|
||||||
|
def user_data_dir() -> Path:
|
||||||
|
"""사용자별 영구 쓰기 폴더. 없으면 생성.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. `SCANVAS_USER_DATA` 환경변수
|
||||||
|
2. `SCANVAS_DEV_LOCAL=1` 인 경우 → 소스 트리 옆 (`./` 아래)
|
||||||
|
3. Windows: `%LOCALAPPDATA%\\S-CANVAS\\`
|
||||||
|
4. 그 외 OS: `~/.scanvas/`
|
||||||
|
"""
|
||||||
|
override = os.environ.get("SCANVAS_USER_DATA")
|
||||||
|
if override:
|
||||||
|
p = Path(override)
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
if os.environ.get("SCANVAS_DEV_LOCAL") == "1" and not _bundled():
|
||||||
|
p = Path(__file__).resolve().parent
|
||||||
|
# 소스 트리에는 cache/ 가 이미 있으니 그대로 사용
|
||||||
|
return p
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
p = _windows_localappdata() / "S-CANVAS"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
p = Path.home() / "Library" / "Application Support" / "S-CANVAS"
|
||||||
|
else:
|
||||||
|
p = Path.home() / ".scanvas"
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def db_path() -> Path:
|
||||||
|
"""`scanvas_jobs.db` 절대경로 (생성 위치)."""
|
||||||
|
return user_data_dir() / "scanvas_jobs.db"
|
||||||
|
|
||||||
|
|
||||||
|
def log_path(name: str) -> Path:
|
||||||
|
"""`scanvas_*.log` 절대경로. e.g. log_path('harness') → .../scanvas_harness.log."""
|
||||||
|
return user_data_dir() / f"scanvas_{name}.log"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir(*sub: str) -> Path:
|
||||||
|
"""`cache/` 하위 폴더. 없으면 생성. e.g. cache_dir('dem'), cache_dir('icons')."""
|
||||||
|
p = user_data_dir() / "cache"
|
||||||
|
if sub:
|
||||||
|
p = p.joinpath(*sub)
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostic_log_path() -> Path:
|
||||||
|
return log_path("diagnostic")
|
||||||
|
|
||||||
|
|
||||||
|
def harness_log_path() -> Path:
|
||||||
|
return log_path("harness")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────── 디버그 헬퍼 ────────────────────────────
|
||||||
|
|
||||||
|
def describe() -> str:
|
||||||
|
"""현재 경로 설정을 한 문자열로 요약 (런타임 진단용)."""
|
||||||
|
return (
|
||||||
|
f"asset_root={asset_root()} (bundled={_bundled()})\n"
|
||||||
|
f"user_data_dir={user_data_dir()}\n"
|
||||||
|
f"db={db_path()}\n"
|
||||||
|
f"harness_log={harness_log_path()}\n"
|
||||||
|
f"diagnostic_log={diagnostic_log_path()}\n"
|
||||||
|
f"cache_root={cache_dir()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(describe())
|
||||||
372
retaining_wall_3d_builder.py
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
"""옹벽 3D 파라메트릭 빌더.
|
||||||
|
|
||||||
|
구성요소:
|
||||||
|
1. 본체 (사다리꼴 단면을 길이방향으로 sweep)
|
||||||
|
2. 기초 slab (하부 넓은 base)
|
||||||
|
3. 뒤채움 지형 (배면 토사)
|
||||||
|
4. 배면 앵커바 × N (격자 배치)
|
||||||
|
5. 상단 안전난간 (parapet)
|
||||||
|
6. 수축이음 세로선 (표면에 시각화)
|
||||||
|
7. 배수공 (weep hole)
|
||||||
|
8. 전면 지반 + 바위
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
from retaining_wall_parser import RetainingWallParams
|
||||||
|
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
|
"wall": "#A8A59B", # 콘크리트
|
||||||
|
"wall_face": "#B5B2A7", # 전면 (약간 밝게)
|
||||||
|
"base": "#8D8A80", # 기초 slab
|
||||||
|
"backfill": "#8B7355", # 뒤채움 토사
|
||||||
|
"anchor": "#2C3E50", # 앵커바 (철)
|
||||||
|
"anchor_plate": "#566573", # 앵커 판
|
||||||
|
"parapet": "#A8A59B",
|
||||||
|
"rail": "#4A4A4A", # 난간
|
||||||
|
"joint": "#5D4A33", # 수축이음 (진한 선)
|
||||||
|
"weep": "#2C3E50",
|
||||||
|
"ground": "#8B7D6B",
|
||||||
|
"rock": "#6B5D50", # 기초암반
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RetainingWallBuilder:
|
||||||
|
|
||||||
|
def __init__(self, params: RetainingWallParams):
|
||||||
|
self.p = params
|
||||||
|
self.meshes: list[tuple[pv.PolyData, str, float]] = []
|
||||||
|
|
||||||
|
def build_all(self):
|
||||||
|
self.meshes = []
|
||||||
|
|
||||||
|
self._build_base_slab()
|
||||||
|
self._build_wall_body()
|
||||||
|
self._build_backfill()
|
||||||
|
self._build_anchors()
|
||||||
|
self._build_parapet()
|
||||||
|
self._build_contraction_joints()
|
||||||
|
self._build_weep_holes()
|
||||||
|
self._build_ground()
|
||||||
|
|
||||||
|
return self.meshes
|
||||||
|
|
||||||
|
# 좌표계:
|
||||||
|
# X: 길이방향 (총 연장)
|
||||||
|
# Y: 전면(-Y) ↔ 배면(+Y) 방향
|
||||||
|
# Z: 높이 (EL)
|
||||||
|
|
||||||
|
# --- 기초 slab (바닥) ---
|
||||||
|
|
||||||
|
def _build_base_slab(self):
|
||||||
|
p = self.p
|
||||||
|
L = p.total_length
|
||||||
|
W = p.base_slab_width
|
||||||
|
T = p.base_slab_thickness
|
||||||
|
z0 = p.bottom_el
|
||||||
|
z1 = z0 + T
|
||||||
|
|
||||||
|
# 기초는 벽 하단에서 전면/배면 모두 확장
|
||||||
|
# 중심이 벽 중심과 같다고 가정
|
||||||
|
self._add_box(-L/2, L/2, -W/2, W/2, z0, z1, COLORS["base"])
|
||||||
|
|
||||||
|
# --- 본체 벽 (사다리꼴 단면 sweep) ---
|
||||||
|
|
||||||
|
def _build_wall_body(self):
|
||||||
|
p = self.p
|
||||||
|
L = p.total_length
|
||||||
|
z_bot = p.bottom_el + p.base_slab_thickness
|
||||||
|
z_top = p.top_el
|
||||||
|
|
||||||
|
W_bot = p.avg_bottom_width
|
||||||
|
W_top = p.avg_top_width
|
||||||
|
|
||||||
|
# 전면 경사: 하단이 더 앞으로 나온 사다리꼴
|
||||||
|
# 단면: YZ 평면에서
|
||||||
|
# 하단: (-W_bot/2, 0) ~ (W_bot/2, 0)
|
||||||
|
# 상단: (-W_top/2, H) ~ (W_top/2, H)
|
||||||
|
# 실제로는 배면은 수직, 전면만 경사
|
||||||
|
|
||||||
|
# 배면 벽 (Y+ 면)
|
||||||
|
# 단면:
|
||||||
|
# 전면(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: 전면이 안쪽으로 기움 (현재는 W_top - W_bot 차이로 묵시적 처리)
|
||||||
|
|
||||||
|
# 8개 코너 점
|
||||||
|
y_front_bot = -W_bot / 2
|
||||||
|
y_front_top = -W_top / 2 # 전면은 상단에서 얇아짐
|
||||||
|
y_back_bot = W_bot / 2
|
||||||
|
y_back_top = W_top / 2 # 배면도 상단에서 얇아짐 (대칭 사다리꼴) 아니면 수직
|
||||||
|
|
||||||
|
# 실제 옹벽은 배면 수직이 더 흔함. 수직으로 고정:
|
||||||
|
y_back_bot = W_bot / 2
|
||||||
|
y_back_top = W_bot / 2
|
||||||
|
|
||||||
|
pts = np.array([
|
||||||
|
[-L/2, y_front_bot, z_bot], # 0 좌하전
|
||||||
|
[L/2, y_front_bot, z_bot], # 1 우하전
|
||||||
|
[L/2, y_back_bot, z_bot], # 2 우하배
|
||||||
|
[-L/2, y_back_bot, z_bot], # 3 좌하배
|
||||||
|
[-L/2, y_front_top, z_top], # 4 좌상전
|
||||||
|
[L/2, y_front_top, z_top], # 5 우상전
|
||||||
|
[L/2, y_back_top, z_top], # 6 우상배
|
||||||
|
[-L/2, y_back_top, z_top], # 7 좌상배
|
||||||
|
])
|
||||||
|
faces = np.hstack([
|
||||||
|
[4, 0, 3, 2, 1], # 바닥
|
||||||
|
[4, 4, 5, 6, 7], # 상단
|
||||||
|
[4, 0, 1, 5, 4], # 전면 (경사)
|
||||||
|
[4, 2, 3, 7, 6], # 배면
|
||||||
|
[4, 1, 2, 6, 5], # 우측 단부
|
||||||
|
[4, 0, 4, 7, 3], # 좌측 단부
|
||||||
|
])
|
||||||
|
self.meshes.append((pv.PolyData(pts, faces), COLORS["wall"], 1.0))
|
||||||
|
|
||||||
|
# --- 뒤채움 (배면 토사) ---
|
||||||
|
|
||||||
|
def _build_backfill(self):
|
||||||
|
p = self.p
|
||||||
|
L = p.total_length
|
||||||
|
z_top = p.top_el
|
||||||
|
|
||||||
|
# 배면에서 뒤로 뻗은 토사 (30 길이)
|
||||||
|
back_depth = 15.0
|
||||||
|
y_start = p.avg_bottom_width / 2 # 벽 배면
|
||||||
|
y_end = y_start + back_depth
|
||||||
|
|
||||||
|
# 토사는 상단부터 아래로 경사 (자연 지형)
|
||||||
|
# 단면: 상단 수평, 경사면, 바닥 수평
|
||||||
|
pts = np.array([
|
||||||
|
[-L/2, y_start, z_top],
|
||||||
|
[ L/2, y_start, z_top],
|
||||||
|
[ L/2, y_end, z_top], # 뒤쪽 상단
|
||||||
|
[-L/2, y_end, z_top],
|
||||||
|
# 바닥은 z_top - 1 정도로 살짝 낮게
|
||||||
|
[-L/2, y_start, z_top - 0.1],
|
||||||
|
[ L/2, y_start, z_top - 0.1],
|
||||||
|
[ L/2, y_end, z_top - 3],
|
||||||
|
[-L/2, y_end, z_top - 3],
|
||||||
|
])
|
||||||
|
faces = np.hstack([
|
||||||
|
[4, 0, 3, 2, 1],
|
||||||
|
[4, 4, 5, 6, 7],
|
||||||
|
[4, 0, 1, 5, 4],
|
||||||
|
[4, 2, 3, 7, 6],
|
||||||
|
[4, 1, 2, 6, 5],
|
||||||
|
[4, 0, 4, 7, 3],
|
||||||
|
])
|
||||||
|
self.meshes.append((pv.PolyData(pts, faces), COLORS["backfill"], 1.0))
|
||||||
|
|
||||||
|
# --- 배면 앵커바 (격자 배치) ---
|
||||||
|
|
||||||
|
def _build_anchors(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_anchors:
|
||||||
|
return
|
||||||
|
|
||||||
|
L = p.total_length
|
||||||
|
z_bot = p.bottom_el + p.base_slab_thickness
|
||||||
|
z_top = p.top_el - 1.0 # 상단은 난간 영역 제외
|
||||||
|
|
||||||
|
# 격자 개수 결정
|
||||||
|
dx = p.anchor_spacing_h
|
||||||
|
dz = p.anchor_spacing_v
|
||||||
|
nx = max(int(L / dx), 2)
|
||||||
|
nz = max(int((z_top - z_bot) / dz), 2)
|
||||||
|
|
||||||
|
# 개수 상한 (너무 많으면 안 만듦)
|
||||||
|
max_total = 200
|
||||||
|
if nx * nz > max_total:
|
||||||
|
# 간격 늘리기
|
||||||
|
ratio = math.sqrt(nx * nz / max_total)
|
||||||
|
nx = int(nx / ratio)
|
||||||
|
nz = int(nz / ratio)
|
||||||
|
|
||||||
|
# 격자 배치
|
||||||
|
y_wall_back = p.avg_bottom_width / 2 # 벽 배면 Y 좌표
|
||||||
|
angle_rad = math.radians(p.anchor_angle_deg)
|
||||||
|
anchor_L = p.anchor_length
|
||||||
|
# 앵커는 배면에서 아래쪽으로 경사져 안으로 매입
|
||||||
|
dx_anchor = math.cos(angle_rad) * anchor_L
|
||||||
|
dz_anchor = -math.sin(angle_rad) * anchor_L
|
||||||
|
|
||||||
|
for i in range(nx):
|
||||||
|
for j in range(nz):
|
||||||
|
x = -L/2 + (i + 0.5) * (L / nx)
|
||||||
|
z = z_bot + (j + 0.5) * ((z_top - z_bot) / nz)
|
||||||
|
|
||||||
|
start = np.array([x, y_wall_back, z])
|
||||||
|
end = np.array([x, y_wall_back + dx_anchor, z + dz_anchor])
|
||||||
|
|
||||||
|
# 앵커바 (얇은 실린더)
|
||||||
|
try:
|
||||||
|
length = float(np.linalg.norm(end - start))
|
||||||
|
direction = (end - start) / length
|
||||||
|
anchor = pv.Cylinder(
|
||||||
|
center=tuple((start + end) / 2),
|
||||||
|
direction=tuple(direction),
|
||||||
|
radius=p.anchor_diameter / 2,
|
||||||
|
height=length,
|
||||||
|
resolution=8,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((anchor, COLORS["anchor"], 1.0))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 앵커 헤드 판 (벽 면에 붙은 작은 사각)
|
||||||
|
plate_size = 0.2
|
||||||
|
self._add_box(
|
||||||
|
x - plate_size / 2, x + plate_size / 2,
|
||||||
|
y_wall_back, y_wall_back + 0.05,
|
||||||
|
z - plate_size / 2, z + plate_size / 2,
|
||||||
|
COLORS["anchor_plate"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 상단 파라펫 / 난간 ---
|
||||||
|
|
||||||
|
def _build_parapet(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_parapet:
|
||||||
|
return
|
||||||
|
|
||||||
|
L = p.total_length
|
||||||
|
z0 = p.top_el
|
||||||
|
z1 = z0 + p.parapet_height
|
||||||
|
t = p.parapet_thickness
|
||||||
|
|
||||||
|
# 전면 파라펫
|
||||||
|
y_front = -p.avg_top_width / 2
|
||||||
|
self._add_box(-L/2, L/2, y_front, y_front + t, z0, z1, COLORS["parapet"])
|
||||||
|
|
||||||
|
# 배면 파라펫 (선택적)
|
||||||
|
y_back = p.avg_top_width / 2 # 상단 폭 기준
|
||||||
|
self._add_box(-L/2, L/2, y_back - t, y_back, z0, z1, COLORS["parapet"])
|
||||||
|
|
||||||
|
# 난간 (수평 바)
|
||||||
|
rail_t = 0.08
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# --- 수축이음 (표면 세로선) ---
|
||||||
|
|
||||||
|
def _build_contraction_joints(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_contraction_joints:
|
||||||
|
return
|
||||||
|
|
||||||
|
L = p.total_length
|
||||||
|
z_bot = p.bottom_el + p.base_slab_thickness
|
||||||
|
z_top = p.top_el
|
||||||
|
spacing = p.joint_spacing
|
||||||
|
n = max(int(L / spacing), 1)
|
||||||
|
|
||||||
|
# 전면(Y-)에 얇은 세로 띠 (시각적 이음)
|
||||||
|
joint_width = 0.05
|
||||||
|
y_front = -p.avg_bottom_width / 2 - 0.01 # 전면 살짝 앞
|
||||||
|
depth = 0.08
|
||||||
|
|
||||||
|
for i in range(1, n):
|
||||||
|
x = -L/2 + i * (L / n)
|
||||||
|
self._add_box(
|
||||||
|
x - joint_width / 2, x + joint_width / 2,
|
||||||
|
y_front, y_front + depth,
|
||||||
|
z_bot, z_top,
|
||||||
|
COLORS["joint"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 배수공 (전면 작은 구멍) ---
|
||||||
|
|
||||||
|
def _build_weep_holes(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_weep_holes:
|
||||||
|
return
|
||||||
|
|
||||||
|
L = p.total_length
|
||||||
|
z_row = p.bottom_el + p.base_slab_thickness + 1.0 # 바닥 약간 위 한 줄
|
||||||
|
spacing = p.weep_hole_spacing
|
||||||
|
n = max(int(L / spacing), 1)
|
||||||
|
|
||||||
|
y_front = -p.avg_bottom_width / 2 - 0.05
|
||||||
|
r = p.weep_hole_diameter / 2
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
x = -L/2 + (i + 0.5) * (L / n)
|
||||||
|
try:
|
||||||
|
hole = pv.Cylinder(
|
||||||
|
center=(x, y_front, z_row),
|
||||||
|
direction=(0, 1, 0),
|
||||||
|
radius=r,
|
||||||
|
height=0.2,
|
||||||
|
resolution=12,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((hole, COLORS["weep"], 1.0))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- 전면 지반 ---
|
||||||
|
|
||||||
|
def _build_ground(self):
|
||||||
|
p = self.p
|
||||||
|
L = p.total_length
|
||||||
|
# 전면 지반 (앞쪽으로 15m)
|
||||||
|
y_start = -p.avg_bottom_width / 2 - 15
|
||||||
|
y_end = -p.avg_bottom_width / 2
|
||||||
|
z_ground = p.ground_level
|
||||||
|
pts = np.array([
|
||||||
|
[-L/2 - 5, y_start, z_ground],
|
||||||
|
[L/2 + 5, y_start, z_ground],
|
||||||
|
[L/2 + 5, y_end, z_ground],
|
||||||
|
[-L/2 - 5, y_end, z_ground],
|
||||||
|
])
|
||||||
|
self.meshes.append((pv.PolyData(pts, np.array([4, 0, 1, 2, 3])),
|
||||||
|
COLORS["ground"], 1.0))
|
||||||
|
|
||||||
|
# 기초암반 (벽 아래에서 전면으로 노출)
|
||||||
|
rock_depth = 3.0
|
||||||
|
rock_y_start = -p.avg_bottom_width / 2 - 3
|
||||||
|
rock_y_end = -p.avg_bottom_width / 2 - 0.5
|
||||||
|
z_rock_top = p.bottom_el
|
||||||
|
z_rock_bot = p.bottom_el - rock_depth
|
||||||
|
|
||||||
|
self._add_box(
|
||||||
|
-L/2 - 3, L/2 + 3,
|
||||||
|
rock_y_start, rock_y_end,
|
||||||
|
z_rock_bot, z_rock_top,
|
||||||
|
COLORS["rock"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 헬퍼 ---
|
||||||
|
|
||||||
|
def _add_box(self, x0, x1, y0, y1, z0, z1, color):
|
||||||
|
if x1 <= x0 or y1 <= y0 or z1 <= z0:
|
||||||
|
return
|
||||||
|
pts = np.array([
|
||||||
|
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
|
||||||
|
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
|
||||||
|
])
|
||||||
|
faces = np.hstack([
|
||||||
|
[4, 0, 3, 2, 1], [4, 4, 5, 6, 7],
|
||||||
|
[4, 0, 1, 5, 4], [4, 2, 3, 7, 6],
|
||||||
|
[4, 1, 2, 6, 5], [4, 0, 4, 7, 3],
|
||||||
|
])
|
||||||
|
self.meshes.append((pv.PolyData(pts, faces), color, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def build_retaining_wall_meshes(params: RetainingWallParams):
|
||||||
|
return RetainingWallBuilder(params).build_all()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from retaining_wall_parser import parse_retaining_wall
|
||||||
|
paths = ["SAMPLE_CAD/1. 좌안옹벽 일반도 작성(2026.0109).dxf"]
|
||||||
|
p = parse_retaining_wall(paths)
|
||||||
|
print(p.summary())
|
||||||
|
meshes = RetainingWallBuilder(p).build_all()
|
||||||
|
print(f"\n{len(meshes)}개 구성요소 생성")
|
||||||
226
retaining_wall_parser.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""옹벽 (Retaining Wall) 전용 DXF 파서.
|
||||||
|
|
||||||
|
구조 특성:
|
||||||
|
- 선형 옹벽 경로 (긴 길이방향)
|
||||||
|
- 구간별로 다른 높이 (지형에 따라 변화)
|
||||||
|
- 배면(뒤쪽) 앵커바 격자 배치
|
||||||
|
- 상단 안전난간/파라펫
|
||||||
|
- 기초부 (base slab, 넓음)
|
||||||
|
- 수축이음 (균등 간격)
|
||||||
|
- 배수공 (weep hole)
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
p = parse_retaining_wall(dxf_paths)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
|
||||||
|
from view_detector import detect_view_regions
|
||||||
|
from dxf_geometry import extract_structural_geometry
|
||||||
|
from view_reconstructor import compute_oriented_bbox
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 클래스
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WallSection:
|
||||||
|
"""옹벽 구간 하나."""
|
||||||
|
start_station: float = 0.0 # 시점 측점 (m)
|
||||||
|
end_station: float = 0.0 # 종점 측점
|
||||||
|
top_el: float = 0.0 # 상단 EL
|
||||||
|
bottom_el: float = 0.0 # 바닥 EL
|
||||||
|
# 단면 치수
|
||||||
|
top_width: float = 0.5
|
||||||
|
bottom_width: float = 2.5
|
||||||
|
front_batter: float = 0.02 # 전면 경사 (1:N 비율 중 1 부분)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RetainingWallParams:
|
||||||
|
"""옹벽 파라미터."""
|
||||||
|
# 전체 선형
|
||||||
|
total_length: float = 100.0 # 총 연장 (m)
|
||||||
|
path_direction: str = "X" # "X" 또는 "Y" (주 방향)
|
||||||
|
|
||||||
|
# 높이 범위
|
||||||
|
top_el: float = 60.0
|
||||||
|
bottom_el: float = 41.5 # 기초 바닥
|
||||||
|
|
||||||
|
# 단면 (평균)
|
||||||
|
avg_top_width: float = 0.6 # 상단 폭
|
||||||
|
avg_bottom_width: float = 3.0 # 하단 폭 (중간)
|
||||||
|
base_slab_width: float = 5.0 # 기초 slab 폭
|
||||||
|
base_slab_thickness: float = 1.0 # 기초 두께
|
||||||
|
front_batter_ratio: float = 0.05 # 전면 경사 (1:20 수준)
|
||||||
|
|
||||||
|
# 구간별 (상세 있을 경우)
|
||||||
|
sections: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# 앵커바
|
||||||
|
has_anchors: bool = True
|
||||||
|
anchor_count: int = 78
|
||||||
|
anchor_spacing_h: float = 3.0 # 수평 간격
|
||||||
|
anchor_spacing_v: float = 3.0 # 수직 간격
|
||||||
|
anchor_diameter: float = 0.05 # 앵커바 직경 (50mm)
|
||||||
|
anchor_length: float = 12.0 # 앵커 매입 길이
|
||||||
|
anchor_angle_deg: float = 15 # 하향 각도
|
||||||
|
|
||||||
|
# 상단 안전난간
|
||||||
|
has_parapet: bool = True
|
||||||
|
parapet_height: float = 1.1
|
||||||
|
parapet_thickness: float = 0.15
|
||||||
|
|
||||||
|
# 수축이음
|
||||||
|
has_contraction_joints: bool = True
|
||||||
|
joint_spacing: float = 10.0 # 간격
|
||||||
|
|
||||||
|
# 배수공
|
||||||
|
has_weep_holes: bool = True
|
||||||
|
weep_hole_spacing: float = 3.0
|
||||||
|
weep_hole_diameter: float = 0.1
|
||||||
|
|
||||||
|
# 기타
|
||||||
|
ground_level: float = 41.5 # 전면 지반 EL
|
||||||
|
|
||||||
|
source_files: list = field(default_factory=list)
|
||||||
|
raw_annotations: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def total_height(self) -> float:
|
||||||
|
return self.top_el - self.bottom_el
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
return (
|
||||||
|
f"Retaining Wall: L={self.total_length:.1f}m, "
|
||||||
|
f"EL.{self.bottom_el:.1f}~{self.top_el:.1f} (H={self.total_height():.1f}m)\n"
|
||||||
|
f" 단면 상단={self.avg_top_width:.2f}m, 하단={self.avg_bottom_width:.2f}m, "
|
||||||
|
f"기초 slab {self.base_slab_width:.1f}×{self.base_slab_thickness:.1f}m\n"
|
||||||
|
f" 앵커 {self.anchor_count}개 ({self.anchor_spacing_h:.1f}×{self.anchor_spacing_v:.1f}m), "
|
||||||
|
f"난간 {'O' if self.has_parapet else 'X'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 파서
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EL_PATTERN = re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class RetainingWallParser:
|
||||||
|
|
||||||
|
def parse(self, dxf_paths: list[str]) -> RetainingWallParams:
|
||||||
|
params = RetainingWallParams()
|
||||||
|
params.source_files = list(dxf_paths)
|
||||||
|
|
||||||
|
for path in dxf_paths:
|
||||||
|
try:
|
||||||
|
self._parse_single(path, params)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 파싱 오류: {e}")
|
||||||
|
|
||||||
|
self._finalize(params)
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _parse_single(self, path: str, params: RetainingWallParams):
|
||||||
|
doc = ezdxf.readfile(path)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
geom = extract_structural_geometry(path)
|
||||||
|
scale = geom.unit_scale
|
||||||
|
|
||||||
|
views = detect_view_regions(path)
|
||||||
|
|
||||||
|
# EL 수집
|
||||||
|
els = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxftype() not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txt = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "")
|
||||||
|
m = EL_PATTERN.search(txt)
|
||||||
|
if m:
|
||||||
|
pos = e.dxf.insert
|
||||||
|
els.append((pos.x * scale, pos.y * scale, float(m.group(1))))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if els:
|
||||||
|
ev = [v for _, _, v in els]
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 평면도에서 총 연장 추정
|
||||||
|
plan_view = None
|
||||||
|
for v in views:
|
||||||
|
if v.view_type == "plan":
|
||||||
|
plan_view = v
|
||||||
|
break
|
||||||
|
|
||||||
|
if plan_view:
|
||||||
|
# 평면도 안의 shapes 점들 수집 → PCA로 길이 추정
|
||||||
|
local_shapes = plan_view.get_local_shapes()
|
||||||
|
all_pts = []
|
||||||
|
for s in local_shapes:
|
||||||
|
all_pts.extend(s.points)
|
||||||
|
|
||||||
|
if len(all_pts) >= 5:
|
||||||
|
obb = compute_oriented_bbox(all_pts)
|
||||||
|
if obb and obb["aspect_ratio"] >= 1.5:
|
||||||
|
params.total_length = max(params.total_length, obb["length"])
|
||||||
|
params.path_direction = "X" if abs(obb["axis_long"][0]) > abs(obb["axis_long"][1]) else "Y"
|
||||||
|
|
||||||
|
# 앵커바 개수 추정 (B_Dot 블록 사용)
|
||||||
|
anchor_block_count = 0
|
||||||
|
for e in msp.query("INSERT"):
|
||||||
|
name = getattr(e.dxf, "name", "")
|
||||||
|
if "B_Dot" in name or "anchor" in name.lower():
|
||||||
|
anchor_block_count += 1
|
||||||
|
if anchor_block_count > 10:
|
||||||
|
params.anchor_count = anchor_block_count
|
||||||
|
|
||||||
|
# 정면도 영역에서 앵커 격자 확인
|
||||||
|
front_view = None
|
||||||
|
for v in views:
|
||||||
|
if v.view_type == "front":
|
||||||
|
front_view = v
|
||||||
|
break
|
||||||
|
if front_view:
|
||||||
|
# 정면도 면적 / 앵커개수로 간격 추정
|
||||||
|
area = front_view.width * front_view.height
|
||||||
|
if params.anchor_count > 0:
|
||||||
|
spacing_approx = math.sqrt(area / params.anchor_count)
|
||||||
|
if 1.0 <= spacing_approx <= 6.0:
|
||||||
|
params.anchor_spacing_h = spacing_approx
|
||||||
|
params.anchor_spacing_v = spacing_approx
|
||||||
|
|
||||||
|
def _finalize(self, p: RetainingWallParams):
|
||||||
|
# 기초 slab 폭: 본체 하단 폭의 1.5배 정도
|
||||||
|
p.base_slab_width = max(p.avg_bottom_width * 1.5, 4.0)
|
||||||
|
# 높이가 너무 크면 하단 폭 증가
|
||||||
|
H = p.total_height()
|
||||||
|
if H > 10:
|
||||||
|
p.avg_bottom_width = max(p.avg_bottom_width, H * 0.15)
|
||||||
|
p.base_slab_width = max(p.base_slab_width, p.avg_bottom_width * 1.4)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_retaining_wall(paths: list[str]) -> RetainingWallParams:
|
||||||
|
return RetainingWallParser().parse(paths)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
paths = sys.argv[1:] if len(sys.argv) > 1 else [
|
||||||
|
"SAMPLE_CAD/1. 좌안옹벽 일반도 작성(2026.0109).dxf",
|
||||||
|
]
|
||||||
|
p = parse_retaining_wall(paths)
|
||||||
|
print(p.summary())
|
||||||
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
|
||||||
|
]
|
||||||
7016
scanvas_maker.py
Normal file
137
scanvas_maker.spec
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""S-CANVAS PyInstaller spec — onedir 배포 빌드 설정.
|
||||||
|
|
||||||
|
빌드:
|
||||||
|
pyinstaller --clean scanvas_maker.spec
|
||||||
|
|
||||||
|
결과:
|
||||||
|
dist/S-CANVAS/ ← 통째로 zip 해서 배포
|
||||||
|
S-CANVAS.exe ← 더블클릭 진입점
|
||||||
|
Design/, prompt_templates/, structure_types/
|
||||||
|
_internal/ ← Python 런타임 + 의존 라이브러리
|
||||||
|
...
|
||||||
|
|
||||||
|
런타임 데이터(DB·로그·캐시):
|
||||||
|
%LOCALAPPDATA%\\S-CANVAS\\ ← 사용자별 분리, 설치 폴더 쓰기 권한 불필요
|
||||||
|
"""
|
||||||
|
from PyInstaller.utils.hooks import collect_all, collect_submodules
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
PROJECT_DIR = Path(SPECPATH).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────── 자산 번들링 ──────────────────────
|
||||||
|
# (소스경로, 번들 내 상대경로) — 런타임에서 sys._MEIPASS 아래에 같은 트리로 추출됨.
|
||||||
|
datas = [
|
||||||
|
(str(PROJECT_DIR / "Design"), "Design"),
|
||||||
|
(str(PROJECT_DIR / "prompt_templates"), "prompt_templates"),
|
||||||
|
(str(PROJECT_DIR / "structure_types"), "structure_types"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ────────────────────── 거대 패키지 collect_all ──────────────────────
|
||||||
|
# pyvista/vtk: PyInstaller 자동 감지 부분이 거나 → 명시적 collect_all 로 누락 방지.
|
||||||
|
# pyproj: PROJ 데이터(proj.db) 자동 누락 빈번 → collect_all 로 datas/binaries 모두 집어 옴.
|
||||||
|
binaries = []
|
||||||
|
hiddenimports = []
|
||||||
|
for pkg in [
|
||||||
|
"pyvista",
|
||||||
|
"vtkmodules",
|
||||||
|
"pyproj",
|
||||||
|
"ezdxf",
|
||||||
|
"tkintermapview",
|
||||||
|
"structlog",
|
||||||
|
"customtkinter",
|
||||||
|
"PIL",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
_d, _b, _h = collect_all(pkg)
|
||||||
|
datas += _d
|
||||||
|
binaries += _b
|
||||||
|
hiddenimports += _h
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# google-genai 의 서브모듈은 collect_all 로 충분히 커버되지 않을 때가 있어 별도 추가
|
||||||
|
hiddenimports += collect_submodules("google.genai")
|
||||||
|
hiddenimports += [
|
||||||
|
"sqlalchemy.dialects.sqlite",
|
||||||
|
"sqlalchemy.dialects.sqlite.pysqlite",
|
||||||
|
"sqlalchemy.ext.declarative",
|
||||||
|
"scipy.spatial.transform._rotation_groups",
|
||||||
|
"scipy.special.cython_special",
|
||||||
|
"encodings.idna",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ────────────────────── 분석 단계 ──────────────────────
|
||||||
|
a = Analysis(
|
||||||
|
["scanvas_maker.py"],
|
||||||
|
pathex=[str(PROJECT_DIR)],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
# 번들 크기 절감: 불필요한 거대 패키지 제외
|
||||||
|
excludes=[
|
||||||
|
"pytest",
|
||||||
|
"IPython",
|
||||||
|
"jupyter",
|
||||||
|
"notebook",
|
||||||
|
"tornado",
|
||||||
|
"zmq",
|
||||||
|
"matplotlib.tests",
|
||||||
|
"numpy.tests",
|
||||||
|
"scipy.tests",
|
||||||
|
"pyvista.examples", # 거대 샘플 데이터 제외
|
||||||
|
"vtkmodules.test",
|
||||||
|
],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
# ────────────────────── 실행파일 ──────────────────────
|
||||||
|
# 아이콘: 빌드 전 build.bat 가 cache/icons/scanvas_S.ico 를 생성. 없으면 None 폴백.
|
||||||
|
_icon_path = PROJECT_DIR / "cache" / "icons" / "scanvas_S.ico"
|
||||||
|
_exe_icon = str(_icon_path) if _icon_path.exists() else None
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name="S-CANVAS",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True, # UPX 가 PATH 에 있으면 압축. 없어도 빌드 진행.
|
||||||
|
upx_exclude=[
|
||||||
|
"vcruntime140.dll",
|
||||||
|
"python311.dll",
|
||||||
|
"python312.dll",
|
||||||
|
"VCRUNTIME140.dll",
|
||||||
|
],
|
||||||
|
console=False, # GUI 앱 — 콘솔 창 안 띄움
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=_exe_icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ────────────────────── onedir 패키지 ──────────────────────
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name="S-CANVAS",
|
||||||
|
)
|
||||||
178
splash.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""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
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
|
||||||
|
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("[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):
|
||||||
|
with contextlib.suppress(tk.TclError):
|
||||||
|
splash.attributes("-alpha", max(0.0, min(1.0, a)))
|
||||||
|
|
||||||
|
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
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
cap.release()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
splash.destroy()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
root.quit()
|
||||||
|
|
||||||
|
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:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 단독 테스트
|
||||||
|
show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")
|
||||||
506
structure_placement.py
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
"""구조물 메쉬를 지형 위에 배치하는 유틸.
|
||||||
|
|
||||||
|
구조물 빌더들(intake_tower, valve_chamber, retaining_wall 등)은 구조물을
|
||||||
|
**원점 중심 + Z=구조물 설계 EL** 로컬 좌표계에서 생성한다.
|
||||||
|
|
||||||
|
이를 전체계획 평면도의 해당 구조물 위치에 배치하려면:
|
||||||
|
1. XY: 평면도 centroid로 평행이동
|
||||||
|
2. 회전: 평면 오리엔테이션(예: 옹벽 길이축)으로 Z축 기준 회전
|
||||||
|
3. Z: 옵션1) 구조물 bottom_el 그대로 유지 (설계 EL 기준)
|
||||||
|
옵션2) TIN 표면 고도로 이동 (지형에 내려앉힘)
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
from structure_placement import apply_placement
|
||||||
|
|
||||||
|
placed = apply_placement(
|
||||||
|
meshes=template.build_meshes(params),
|
||||||
|
plan_centroid=(x, y),
|
||||||
|
rotation_deg=0.0,
|
||||||
|
z_mode="design", # or "terrain"
|
||||||
|
terrain_mesh=tin_mesh, # z_mode="terrain" 시 필요
|
||||||
|
z_offset=0.0,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_points_z(points: np.ndarray, angle_rad: float,
|
||||||
|
cx: float = 0, cy: float = 0) -> np.ndarray:
|
||||||
|
"""Z축 기준 회전 (각 points[i] = [x, y, z])."""
|
||||||
|
cos_a = math.cos(angle_rad)
|
||||||
|
sin_a = math.sin(angle_rad)
|
||||||
|
out = points.copy()
|
||||||
|
dx = out[:, 0] - cx
|
||||||
|
dy = out[:, 1] - cy
|
||||||
|
out[:, 0] = cx + dx * cos_a - dy * sin_a
|
||||||
|
out[:, 1] = cy + dx * sin_a + dy * cos_a
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_terrain_z(tin_mesh: pv.PolyData, xy: tuple[float, float],
|
||||||
|
origin: np.ndarray = None) -> float | None:
|
||||||
|
"""TIN 표면의 (x, y) 위치에서 Z값 보간.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tin_mesh: PyVista PolyData (TIN 메쉬, 로컬 좌표)
|
||||||
|
xy: 월드 좌표 (x, y)
|
||||||
|
origin: TIN의 원점 보정 offset [x, y, z] (scanvas_maker.self.origin)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
보간된 Z값 (월드 EL, m) 또는 None (범위 밖)
|
||||||
|
"""
|
||||||
|
if tin_mesh is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scipy.interpolate import LinearNDInterpolator
|
||||||
|
pts = np.array(tin_mesh.points)
|
||||||
|
|
||||||
|
# origin 보정: xy를 TIN 로컬 좌표계로 변환
|
||||||
|
if origin is not None:
|
||||||
|
local_x = xy[0] - origin[0]
|
||||||
|
local_y = xy[1] - origin[1]
|
||||||
|
else:
|
||||||
|
local_x, local_y = xy[0], xy[1]
|
||||||
|
|
||||||
|
interp = LinearNDInterpolator(pts[:, :2], pts[:, 2])
|
||||||
|
z_local = interp(local_x, local_y)
|
||||||
|
|
||||||
|
if np.isnan(z_local):
|
||||||
|
# 범위 밖 → 중앙값 사용
|
||||||
|
z_local = float(np.median(pts[:, 2]))
|
||||||
|
|
||||||
|
# origin Z 복원 (월드 EL로)
|
||||||
|
if origin is not None:
|
||||||
|
return float(z_local) + origin[2]
|
||||||
|
return float(z_local)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_placement(
|
||||||
|
meshes: list[tuple[pv.PolyData, str, float]],
|
||||||
|
plan_centroid: tuple[float, float],
|
||||||
|
rotation_deg: float = 0.0,
|
||||||
|
z_mode: str = "design",
|
||||||
|
terrain_mesh: pv.PolyData | None = None,
|
||||||
|
terrain_origin: np.ndarray | None = None,
|
||||||
|
z_offset: float = 0.0,
|
||||||
|
structure_bottom_el: float | None = None,
|
||||||
|
skip_ground: bool = False,
|
||||||
|
scale: float = 1.0,
|
||||||
|
skip_terrain: bool = False,
|
||||||
|
pad_surface_z: float | None = None,
|
||||||
|
embed_offset: float = 0.02,
|
||||||
|
) -> list[tuple[pv.PolyData, str, float]]:
|
||||||
|
"""구조물 메쉬 리스트를 지형 위 해당 위치에 배치.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meshes: 템플릿이 생성한 로컬 좌표 메쉬들
|
||||||
|
plan_centroid: 평면도에서 구조물의 중심 월드 좌표 (x, y)
|
||||||
|
rotation_deg: Z축 기준 회전 (도). 옹벽 등 선형 구조물 방향 맞춤용
|
||||||
|
z_mode:
|
||||||
|
- "design": 구조물 설계 EL 그대로 (Z 이동 없음)
|
||||||
|
- "terrain": TIN 표면 고도에 bottom_el이 오도록 이동
|
||||||
|
- "offset": z_offset만큼 이동
|
||||||
|
terrain_mesh: z_mode="terrain"일 때 사용할 TIN
|
||||||
|
terrain_origin: TIN의 origin 보정값
|
||||||
|
z_offset: z_mode="offset"일 때 적용할 Z 이동량
|
||||||
|
structure_bottom_el: 구조물 바닥 EL (z_mode="terrain"에서 지형에 맞출 때)
|
||||||
|
skip_ground: True면 구조물 자체 "ground" 메쉬 제외 (지형 TIN과 중복 방지)
|
||||||
|
scale: XY 방향 균등 스케일 (Geo-Referencing 결과). 1.0이면 무변화.
|
||||||
|
Z는 의도적으로 유지 (구조물 설계 EL 보존).
|
||||||
|
skip_terrain: True면 물/지면/에이프런/뒤채움 관련 메쉬 전부 제외
|
||||||
|
(geo_referencing.EXCLUDE_COLORS 집합 사용)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
변환된 (mesh, color, opacity) 리스트
|
||||||
|
"""
|
||||||
|
if not meshes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 모든 좌표를 TIN 로컬 좌표계로 통일
|
||||||
|
# 템플릿 메쉬: XY=(0,0) 중심, Z=설계 EL (월드)
|
||||||
|
# TIN 메쉬: origin이 빠진 로컬 좌표
|
||||||
|
# → plan_centroid(월드) - origin = 로컬 배치 위치
|
||||||
|
origin = terrain_origin if terrain_origin is not None else np.zeros(3)
|
||||||
|
|
||||||
|
# XY 이동량: 월드 → 로컬
|
||||||
|
dx = plan_centroid[0] - origin[0]
|
||||||
|
dy = plan_centroid[1] - origin[1]
|
||||||
|
|
||||||
|
# Z 이동량
|
||||||
|
dz = 0.0
|
||||||
|
if z_mode == "terrain" and structure_bottom_el is not None:
|
||||||
|
if pad_surface_z is not None:
|
||||||
|
z_local = float(pad_surface_z)
|
||||||
|
elif terrain_mesh is not None:
|
||||||
|
# TIN 로컬 좌표에서 직접 보간 (interpolate_terrain_z는 월드 EL 반환 → 사용 안 함)
|
||||||
|
from scipy.interpolate import LinearNDInterpolator
|
||||||
|
tin_pts = np.array(terrain_mesh.points)
|
||||||
|
interp = LinearNDInterpolator(tin_pts[:, :2], tin_pts[:, 2])
|
||||||
|
z_local = float(interp(dx, dy))
|
||||||
|
if np.isnan(z_local):
|
||||||
|
z_local = float(np.median(tin_pts[:, 2]))
|
||||||
|
else:
|
||||||
|
z_local = None
|
||||||
|
if z_local is not None:
|
||||||
|
# 구조물 바닥 EL(설계 월드값)을 TIN 로컬 표면에 맞춤
|
||||||
|
# + embed_offset만큼 아래로 → TIN이 구조물 underside를 가림
|
||||||
|
dz = z_local - structure_bottom_el - float(embed_offset)
|
||||||
|
elif z_mode == "offset":
|
||||||
|
dz = z_offset
|
||||||
|
|
||||||
|
angle_rad = math.radians(rotation_deg)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
ground_colors = {"#8B7D6B", "#7F6F5F"} # ground 색상
|
||||||
|
|
||||||
|
# 물/지면류 전체 제외용 색상 집합 (geo_referencing.EXCLUDE_COLORS와 동일)
|
||||||
|
terrain_exclude_colors = {
|
||||||
|
"#3A7AA8", "#7F6F5F", "#8B7D6B", "#9A968C", "#8B7355",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _norm_color(c):
|
||||||
|
if not isinstance(c, str):
|
||||||
|
return ""
|
||||||
|
s = c.strip().upper()
|
||||||
|
if not s.startswith("#"):
|
||||||
|
s = "#" + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
# CW quad 보정 — apply_placement은 centroid/rotation 기반 폴백이므로
|
||||||
|
# plan_centroid만 주어진 경우엔 Y-flip이 무의미함. 하지만 rotation_deg가
|
||||||
|
# CW convention으로 주어졌다고 가정하면 y_sign 적용.
|
||||||
|
# 단순화를 위해 apply_placement은 현재 CW Y-flip은 적용하지 않음.
|
||||||
|
|
||||||
|
for mesh, color, opacity in meshes:
|
||||||
|
norm = _norm_color(color)
|
||||||
|
# ground 메쉬 건너뛰기 (지형이 있으면 중복)
|
||||||
|
if skip_ground and norm in {c.upper() for c in ground_colors}:
|
||||||
|
continue
|
||||||
|
# 물/지면/apron/backfill 전부 건너뛰기 (미리보기·최종 배치용)
|
||||||
|
if skip_terrain and norm in {c.upper() for c in terrain_exclude_colors}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_pts = np.array(mesh.points).copy()
|
||||||
|
|
||||||
|
# 1) XY 스케일 (Z 보존)
|
||||||
|
if abs(scale - 1.0) > 1e-6:
|
||||||
|
new_pts[:, 0] *= scale
|
||||||
|
new_pts[:, 1] *= scale
|
||||||
|
|
||||||
|
# 2) 회전 (원점 기준)
|
||||||
|
if abs(rotation_deg) > 0.01:
|
||||||
|
new_pts = rotate_points_z(new_pts, angle_rad, cx=0, cy=0)
|
||||||
|
|
||||||
|
# 3) 평행이동 (XY + Z)
|
||||||
|
new_pts[:, 0] += dx
|
||||||
|
new_pts[:, 1] += dy
|
||||||
|
new_pts[:, 2] += dz
|
||||||
|
|
||||||
|
new_mesh = mesh.copy()
|
||||||
|
new_mesh.points = new_pts
|
||||||
|
out.append((new_mesh, color, opacity))
|
||||||
|
except Exception:
|
||||||
|
# 개별 메쉬 변환 실패시 건너뜀
|
||||||
|
continue
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fit_meshes_to_quad(
|
||||||
|
meshes: list[tuple[pv.PolyData, str, float]],
|
||||||
|
quad_world_pts: list,
|
||||||
|
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: float | None = None,
|
||||||
|
embed_offset: float = 0.02,
|
||||||
|
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]]:
|
||||||
|
"""3D 메쉬를 TIN 평면도의 4점 사각형에 **강제 맞춤** (anisotropic scale).
|
||||||
|
|
||||||
|
동작(모든 모드 공통):
|
||||||
|
1. 메쉬 XY bbox 중심을 원점으로 이동
|
||||||
|
2. scale_mode에 따라 스케일 적용
|
||||||
|
3. quad 첫 엣지 각도로 XY 회전
|
||||||
|
4. quad 중심(TIN 로컬)으로 XY 이동
|
||||||
|
5. z_mode에 따라 Z 이동 (기본: 메쉬 z_min을 TIN 표면에 맞춤)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meshes: 템플릿 빌드 결과 (mesh, color_hex, opacity)
|
||||||
|
quad_world_pts: TIN 평면도 4점 (시계방향, 월드 좌표)
|
||||||
|
terrain_mesh: TIN PolyData (z_mode="terrain"일 때 표면 Z 보간)
|
||||||
|
terrain_origin: TIN 로컬 좌표 변환용 origin (self.origin)
|
||||||
|
structure_bottom_el: 호환용 (실제로는 스케일 후 mesh z_min 사용)
|
||||||
|
z_mode: "terrain"(표면 안착) | "design"(Z 유지) | "offset"(z_offset)
|
||||||
|
skip_ground: True면 ground 색상 메쉬 제외
|
||||||
|
skip_terrain: True면 물/지면/apron/backfill 색상 전체 제외
|
||||||
|
scale_mode:
|
||||||
|
- "none" (기본/권장): 스케일 안 함. 구조물 설계 크기 유지.
|
||||||
|
- "xy_only": quad에 맞춰 X/Y만 anisotropic 스케일 (Z 보존).
|
||||||
|
- "xyz_uniform": sqrt(sx·sy) 균등 스케일을 X/Y/Z 모두에 적용
|
||||||
|
+ [0.01, 100] 범위로 clamp (extreme 단위 mismatch 방지)
|
||||||
|
pad_surface_z: 굴착 pad 표면 Z (TIN 로컬). 지정 시 quad 중심 보간값 대신
|
||||||
|
이 값을 z_surface로 사용 (굴착 영역 내부는 평탄 pad이므로 quad 중심
|
||||||
|
보간이 아주 약간 흔들릴 수 있는 경우에도 안정).
|
||||||
|
embed_offset: 구조물 바닥 z_min을 표면보다 이만큼 **아래로** 내림 (m).
|
||||||
|
TIN(pad 표면)이 구조물의 아래면보다 살짝 위에 있어야 아래에서 위로
|
||||||
|
봤을 때 TIN이 underside를 가림 (z-fighting 및 관통 방지).
|
||||||
|
기본 0.02m(2cm) — bedding/거푸집 두께 수준으로 물리적으로도 자연스러움.
|
||||||
|
detail_quad_pts: 구조물 상세 평면도의 4점 (사용자가 시계방향으로 picked).
|
||||||
|
지정 시 detail ↔ TIN의 **상대 회전**을 사용하여 detail이 DXF 원본에서
|
||||||
|
비수평으로 그려진 경우도 올바르게 처리. 없으면 절대 angle(TIN only) 폴백.
|
||||||
|
plan_frame_angle_deg: detail DXF 내에서 mesh의 +X축이 향하는 각도(도).
|
||||||
|
파서가 plan outline의 PCA 주축을 추출해 전달. mesh는 이 각도만큼 먼저
|
||||||
|
회전되어 detail의 span 방향에 정렬된 후 detail→TIN 전체 변환이 적용됨.
|
||||||
|
0 = 이미 detail의 +X를 따르는 경우.
|
||||||
|
flip_y_for_cw_quad: True면 회전 전 mesh의 Y를 반전. 사용자가 picks를
|
||||||
|
**시계방향**으로 찍었을 때 기본 회전만으로는 mesh의 +Y가 quad의 edge 3→0
|
||||||
|
방향(반시계)으로 가버려 앞뒤가 뒤집힌다. Y-flip으로 mesh +Y가 edge 1→2
|
||||||
|
방향(사용자 의도의 "downward/downstream")을 따르도록 보정.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
변환된 (mesh, color, opacity) 리스트 (좌표 = TIN 로컬)
|
||||||
|
"""
|
||||||
|
if not meshes or not quad_world_pts or len(quad_world_pts) < 4:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 색상 필터
|
||||||
|
ground_colors = {"#8B7D6B", "#7F6F5F"}
|
||||||
|
terrain_colors = {"#3A7AA8", "#7F6F5F", "#8B7D6B", "#9A968C", "#8B7355"}
|
||||||
|
exclude = set()
|
||||||
|
if skip_ground:
|
||||||
|
exclude |= ground_colors
|
||||||
|
if skip_terrain:
|
||||||
|
exclude |= terrain_colors
|
||||||
|
exclude_upper = {c.upper() for c in exclude}
|
||||||
|
|
||||||
|
def _norm(c):
|
||||||
|
if not isinstance(c, str):
|
||||||
|
return ""
|
||||||
|
s = c.strip().upper()
|
||||||
|
if not s.startswith("#"):
|
||||||
|
s = "#" + s
|
||||||
|
return s
|
||||||
|
|
||||||
|
filtered = [(m, c, o) for (m, c, o) in meshes
|
||||||
|
if _norm(c) not in exclude_upper]
|
||||||
|
if not filtered:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 전체 메쉬 XY/Z bounding box (모든 컴포넌트 통합)
|
||||||
|
all_pts_list = []
|
||||||
|
for m, _, _ in filtered:
|
||||||
|
try:
|
||||||
|
pts = np.asarray(m.points, dtype=np.float64)
|
||||||
|
if pts.size:
|
||||||
|
all_pts_list.append(pts)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not all_pts_list:
|
||||||
|
return []
|
||||||
|
all_pts = np.concatenate(all_pts_list, axis=0)
|
||||||
|
mesh_xmin, mesh_ymin = float(all_pts[:, 0].min()), float(all_pts[:, 1].min())
|
||||||
|
mesh_xmax, mesh_ymax = float(all_pts[:, 0].max()), float(all_pts[:, 1].max())
|
||||||
|
aggregate_z_min = float(all_pts[:, 2].min()) # 모든 컴포넌트 중 최저 Z (상대 Z 보존용)
|
||||||
|
mesh_cx = (mesh_xmin + mesh_xmax) / 2.0
|
||||||
|
mesh_cy = (mesh_ymin + mesh_ymax) / 2.0
|
||||||
|
mesh_w = mesh_xmax - mesh_xmin
|
||||||
|
mesh_h = mesh_ymax - mesh_ymin
|
||||||
|
|
||||||
|
if mesh_w < 1e-6 or mesh_h < 1e-6:
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
# TIN quad (월드)
|
||||||
|
quad = np.asarray(quad_world_pts[:4], dtype=np.float64)
|
||||||
|
q_center_world = quad.mean(axis=0)
|
||||||
|
|
||||||
|
# 월드 → TIN 로컬 (terrain_origin 차감)
|
||||||
|
origin = np.asarray(terrain_origin if terrain_origin is not None else np.zeros(3),
|
||||||
|
dtype=np.float64)
|
||||||
|
q_center_local_x = float(q_center_world[0] - origin[0])
|
||||||
|
q_center_local_y = float(q_center_world[1] - origin[1])
|
||||||
|
|
||||||
|
edge_01 = quad[1] - quad[0]
|
||||||
|
edge_12 = quad[2] - quad[1]
|
||||||
|
q_w = float(np.linalg.norm(edge_01))
|
||||||
|
q_h = float(np.linalg.norm(edge_12))
|
||||||
|
if q_w < 1e-6 or q_h < 1e-6:
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
# TIN quad 첫 엣지 각도 (반시계 양수)
|
||||||
|
tin_angle = math.atan2(float(edge_01[1]), float(edge_01[0]))
|
||||||
|
|
||||||
|
# Detail quad의 첫 엣지 각도 (있으면 상대회전 계산용)
|
||||||
|
if detail_quad_pts and len(detail_quad_pts) >= 4:
|
||||||
|
d_quad = np.asarray(detail_quad_pts[:4], dtype=np.float64)
|
||||||
|
d_edge_01 = d_quad[1] - d_quad[0]
|
||||||
|
if np.linalg.norm(d_edge_01) >= 1e-6:
|
||||||
|
detail_angle = math.atan2(float(d_edge_01[1]), float(d_edge_01[0]))
|
||||||
|
else:
|
||||||
|
detail_angle = 0.0
|
||||||
|
else:
|
||||||
|
# 폴백: detail의 +X가 edge 0→1과 같다고 가정 (기존 동작)
|
||||||
|
detail_angle = 0.0
|
||||||
|
|
||||||
|
# mesh가 detail 프레임 내에서 frame_angle만큼 회전되어 있으면 그만큼 상쇄
|
||||||
|
# 최종 회전 = (TIN 각도) - (Detail 각도) - (mesh의 detail 내 회전)
|
||||||
|
q_angle = tin_angle - detail_angle - math.radians(plan_frame_angle_deg)
|
||||||
|
cos_a = math.cos(q_angle)
|
||||||
|
sin_a = math.sin(q_angle)
|
||||||
|
# CW quad 보정: True면 mesh Y 반전 후 회전 (mesh +Y가 edge 1→2 따라가도록)
|
||||||
|
y_sign = -1.0 if flip_y_for_cw_quad else 1.0
|
||||||
|
|
||||||
|
# Scale 선택
|
||||||
|
raw_sx = q_w / mesh_w
|
||||||
|
raw_sy = q_h / mesh_h
|
||||||
|
if scale_mode == "xy_only":
|
||||||
|
scale_x, scale_y, scale_z_val = raw_sx, raw_sy, 1.0
|
||||||
|
elif scale_mode == "xyz_uniform":
|
||||||
|
s = math.sqrt(abs(raw_sx * raw_sy))
|
||||||
|
s = max(0.01, min(100.0, s)) # extreme 방지
|
||||||
|
scale_x = scale_y = scale_z_val = s
|
||||||
|
else: # "none" 기본: 구조물 설계 크기 유지
|
||||||
|
scale_x = scale_y = scale_z_val = 1.0
|
||||||
|
|
||||||
|
# TIN 표면 Z (quad 중심에서 보간) — terrain 모드 공통
|
||||||
|
z_surface = None
|
||||||
|
if z_mode == "terrain":
|
||||||
|
if pad_surface_z is not None:
|
||||||
|
# 굴착 pad가 있으면 TIN 보간 대신 pad Z 직접 사용 (더 정확/안정)
|
||||||
|
z_surface = float(pad_surface_z)
|
||||||
|
elif terrain_mesh is not None:
|
||||||
|
try:
|
||||||
|
from scipy.interpolate import LinearNDInterpolator
|
||||||
|
tin_pts = np.asarray(terrain_mesh.points, dtype=np.float64)
|
||||||
|
interp = LinearNDInterpolator(tin_pts[:, :2], tin_pts[:, 2])
|
||||||
|
zs = float(interp(q_center_local_x, q_center_local_y))
|
||||||
|
if np.isnan(zs):
|
||||||
|
zs = float(np.median(tin_pts[:, 2]))
|
||||||
|
z_surface = zs
|
||||||
|
except Exception:
|
||||||
|
z_surface = None
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for mesh, color, opacity in filtered:
|
||||||
|
try:
|
||||||
|
pts = np.asarray(mesh.points, dtype=np.float64).copy()
|
||||||
|
|
||||||
|
# 1) XY 중심을 원점으로 이동 (rotation/scale을 원점 기준으로)
|
||||||
|
pts[:, 0] -= mesh_cx
|
||||||
|
pts[:, 1] -= mesh_cy
|
||||||
|
|
||||||
|
# 2) Anisotropic XY scale + Z scale (기하평균)
|
||||||
|
pts[:, 0] *= scale_x
|
||||||
|
pts[:, 1] *= scale_y
|
||||||
|
if abs(scale_z_val - 1.0) > 1e-9:
|
||||||
|
pts[:, 2] *= scale_z_val
|
||||||
|
|
||||||
|
# 2.5) CW quad 보정: mesh Y 반전
|
||||||
|
# 사용자가 시계방향으로 picks를 찍으면 quad의 local +Y가 edge 1→2 방향
|
||||||
|
# (수학적 CCW 기준 -Y)이 됨. mesh의 +Y를 이쪽으로 정렬하려면 먼저 Y를
|
||||||
|
# 반전한 후 표준 CCW 회전을 적용해야 함.
|
||||||
|
if y_sign < 0:
|
||||||
|
pts[:, 1] = -pts[:, 1]
|
||||||
|
|
||||||
|
# 3) XY 회전 (상대 각도: TIN - detail - plan_frame_angle)
|
||||||
|
x = pts[:, 0].copy()
|
||||||
|
y = pts[:, 1].copy()
|
||||||
|
pts[:, 0] = x * cos_a - y * sin_a
|
||||||
|
pts[:, 1] = x * sin_a + y * cos_a
|
||||||
|
|
||||||
|
# 4) XY를 quad 중심(TIN 로컬)으로 이동
|
||||||
|
pts[:, 0] += q_center_local_x
|
||||||
|
pts[:, 1] += q_center_local_y
|
||||||
|
|
||||||
|
# 5) Z 이동 — 전체 구조물 최저 z_min을 TIN 표면에 맞춤
|
||||||
|
# (컴포넌트 개별 z_min이 아니라 aggregate z_min 사용 → 상대 Z 보존)
|
||||||
|
if z_mode == "terrain" and z_surface is not None:
|
||||||
|
# embed_offset만큼 구조물을 아래로 내림 → TIN이 구조물 bottom보다
|
||||||
|
# 살짝 위에 있어 아래에서 보면 TIN이 underside를 가림
|
||||||
|
dz = z_surface - aggregate_z_min * scale_z_val - float(embed_offset)
|
||||||
|
pts[:, 2] += dz
|
||||||
|
elif z_mode == "offset":
|
||||||
|
pts[:, 2] += float(z_offset)
|
||||||
|
# z_mode == "design" 이면 Z 유지
|
||||||
|
|
||||||
|
new_mesh = mesh.copy()
|
||||||
|
new_mesh.points = pts
|
||||||
|
out.append((new_mesh, color, opacity))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_orientation_from_points(points: list) -> float:
|
||||||
|
"""점들의 PCA로 주축 각도(도) 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
주축과 X축 사이 각도 (-90 ~ +90 도)
|
||||||
|
"""
|
||||||
|
if len(points) < 3:
|
||||||
|
return 0.0
|
||||||
|
arr = np.array(points, dtype=np.float64)
|
||||||
|
arr = np.unique(arr, axis=0)
|
||||||
|
if len(arr) < 3:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
centered = arr[:, :2] - arr[:, :2].mean(axis=0)
|
||||||
|
try:
|
||||||
|
cov = np.cov(centered.T)
|
||||||
|
eigenvals, eigenvecs = np.linalg.eigh(cov)
|
||||||
|
# 최대 고유값 벡터가 주축
|
||||||
|
idx = np.argmax(eigenvals)
|
||||||
|
main_axis = eigenvecs[:, idx]
|
||||||
|
angle = math.degrees(math.atan2(main_axis[1], main_axis[0]))
|
||||||
|
# -90 ~ +90 범위로 정규화
|
||||||
|
while angle > 90:
|
||||||
|
angle -= 180
|
||||||
|
while angle < -90:
|
||||||
|
angle += 180
|
||||||
|
return angle
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def combine_meshes(mesh_groups: list[list[tuple]]) -> list[tuple]:
|
||||||
|
"""여러 구조물의 메쉬 리스트들을 하나로 합침."""
|
||||||
|
combined = []
|
||||||
|
for group in mesh_groups:
|
||||||
|
combined.extend(group)
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 간단 테스트
|
||||||
|
import pyvista as pv
|
||||||
|
# 작은 박스
|
||||||
|
box = pv.Box(bounds=(-1, 1, -1, 1, 0, 2))
|
||||||
|
meshes = [(box, "#888888", 1.0)]
|
||||||
|
placed = apply_placement(
|
||||||
|
meshes=meshes,
|
||||||
|
plan_centroid=(100.0, 50.0),
|
||||||
|
rotation_deg=45.0,
|
||||||
|
z_mode="offset",
|
||||||
|
z_offset=10.0,
|
||||||
|
)
|
||||||
|
print(f"Original bounds: {meshes[0][0].bounds}")
|
||||||
|
print(f"Placed bounds: {placed[0][0].bounds}")
|
||||||
|
print("(X=99~101 → 99~101 회전, Y=49~51, Z=10~12)")
|
||||||
1566
structure_templates.py
Normal file
168
structure_types/structure_v1.yaml
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
version: "v1"
|
||||||
|
description: "S-CANVAS 구조물 유형 레지스트리 - 댐/도로/단지/하천 범용"
|
||||||
|
|
||||||
|
types:
|
||||||
|
terrain:
|
||||||
|
name_ko: "지형 (등고선)"
|
||||||
|
render_mode: "tin"
|
||||||
|
color: "#8B7355"
|
||||||
|
description: "Z값이 있는 등고선/지형 데이터 → TIN 생성에 사용"
|
||||||
|
z_source: "entity"
|
||||||
|
|
||||||
|
excavation:
|
||||||
|
name_ko: "굴착"
|
||||||
|
render_mode: "surface_overlay"
|
||||||
|
color: "#D4A373"
|
||||||
|
z_offset: -2.0
|
||||||
|
opacity: 0.7
|
||||||
|
description: "굴착 계획 영역 (폐합 폴리라인 → 지형 아래 면)"
|
||||||
|
|
||||||
|
embankment:
|
||||||
|
name_ko: "성토/제체"
|
||||||
|
render_mode: "surface_overlay"
|
||||||
|
color: "#A0522D"
|
||||||
|
z_offset: 3.0
|
||||||
|
opacity: 0.7
|
||||||
|
description: "성토/댐 제체 영역 (폐합 → 지형 위 면)"
|
||||||
|
|
||||||
|
road:
|
||||||
|
name_ko: "도로/공사용도로"
|
||||||
|
render_mode: "path_extrude"
|
||||||
|
color: "#3D3D3D"
|
||||||
|
width: 6.0
|
||||||
|
z_offset: -0.3
|
||||||
|
slope_ratio: 1.5
|
||||||
|
description: "도로 중심선 → 지형을 깎아 평탄화 + 양쪽 굴착사면 생성 (1:1.5)"
|
||||||
|
|
||||||
|
cofferdam_upstream:
|
||||||
|
name_ko: "상류 가물막이"
|
||||||
|
render_mode: "wall_extrude"
|
||||||
|
color: "#6C757D"
|
||||||
|
height: 8.0
|
||||||
|
thickness: 2.0
|
||||||
|
description: "유수전환 상류 가물막이 (폐합/선형 → 벽체)"
|
||||||
|
|
||||||
|
cofferdam_downstream:
|
||||||
|
name_ko: "하류 가물막이"
|
||||||
|
render_mode: "wall_extrude"
|
||||||
|
color: "#8D9CA6"
|
||||||
|
height: 6.0
|
||||||
|
thickness: 2.0
|
||||||
|
description: "유수전환 하류 가물막이"
|
||||||
|
|
||||||
|
diversion:
|
||||||
|
name_ko: "유수전환 수로"
|
||||||
|
render_mode: "path_extrude"
|
||||||
|
color: "#4A90D9"
|
||||||
|
width: 8.0
|
||||||
|
z_offset: -3.0
|
||||||
|
description: "유수전환 터널/수로 중심선"
|
||||||
|
|
||||||
|
spillway:
|
||||||
|
name_ko: "여수로 (수로)"
|
||||||
|
render_mode: "path_extrude"
|
||||||
|
color: "#2E86C1"
|
||||||
|
width: 12.0
|
||||||
|
z_offset: -1.0
|
||||||
|
description: "여수로 수로 중심선 (수문 아님 — 수문은 spillway_gate)"
|
||||||
|
|
||||||
|
spillway_gate:
|
||||||
|
name_ko: "수문 (여수로 게이트)"
|
||||||
|
render_mode: "box_extrude"
|
||||||
|
color: "#34495E"
|
||||||
|
height: 8.0
|
||||||
|
description: "여수로 수문 — 평면도에서는 위치만 잡고 상세도면으로 3D 빌드"
|
||||||
|
|
||||||
|
intake_tower:
|
||||||
|
name_ko: "취수탑"
|
||||||
|
render_mode: "box_extrude"
|
||||||
|
color: "#566573"
|
||||||
|
height: 25.0
|
||||||
|
description: "취수탑 — 평면도에서는 위치만 잡고 상세도면으로 3D 빌드"
|
||||||
|
|
||||||
|
valve_chamber:
|
||||||
|
name_ko: "제수변실/밸브실"
|
||||||
|
render_mode: "box_extrude"
|
||||||
|
color: "#707B7C"
|
||||||
|
height: 3.5
|
||||||
|
description: "제수변실/밸브실 — 평면도에서는 위치만 잡고 상세도면으로 3D 빌드"
|
||||||
|
|
||||||
|
building:
|
||||||
|
name_ko: "건축물/가설건물"
|
||||||
|
render_mode: "box_extrude"
|
||||||
|
color: "#BDC3C7"
|
||||||
|
height: 5.0
|
||||||
|
description: "건축물 평면 (폐합 → 박스 extrude)"
|
||||||
|
|
||||||
|
temp_facility:
|
||||||
|
name_ko: "가설부지/야적장"
|
||||||
|
render_mode: "surface_overlay"
|
||||||
|
color: "#E8DACC"
|
||||||
|
z_offset: 0.5
|
||||||
|
opacity: 0.6
|
||||||
|
description: "임시 시설 부지 영역"
|
||||||
|
|
||||||
|
bridge:
|
||||||
|
name_ko: "교량"
|
||||||
|
render_mode: "elevated_path"
|
||||||
|
color: "#95A5A6"
|
||||||
|
width: 10.0
|
||||||
|
height: 8.0
|
||||||
|
description: "교량 (선형 → 공중 경로)"
|
||||||
|
|
||||||
|
tunnel:
|
||||||
|
name_ko: "터널"
|
||||||
|
render_mode: "path_extrude"
|
||||||
|
color: "#555555"
|
||||||
|
width: 10.0
|
||||||
|
z_offset: -15.0
|
||||||
|
description: "터널 (선형 → 지하 경로)"
|
||||||
|
|
||||||
|
retaining_wall:
|
||||||
|
name_ko: "옹벽"
|
||||||
|
render_mode: "wall_extrude"
|
||||||
|
color: "#7F8C8D"
|
||||||
|
height: 4.0
|
||||||
|
thickness: 0.5
|
||||||
|
description: "옹벽 (선형 → 수직 벽체)"
|
||||||
|
|
||||||
|
revetment:
|
||||||
|
name_ko: "호안"
|
||||||
|
render_mode: "surface_overlay"
|
||||||
|
color: "#B8B8B0"
|
||||||
|
z_offset: 0.0
|
||||||
|
opacity: 0.5
|
||||||
|
description: "하천 호안 영역"
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
name_ko: "관로"
|
||||||
|
render_mode: "tube_path"
|
||||||
|
color: "#1ABC9C"
|
||||||
|
diameter: 1.5
|
||||||
|
z_offset: -2.0
|
||||||
|
description: "관로/파이프라인 중심선"
|
||||||
|
|
||||||
|
boundary:
|
||||||
|
name_ko: "경계선 (참고용)"
|
||||||
|
render_mode: "line_only"
|
||||||
|
color: "#E74C3C"
|
||||||
|
line_width: 2.0
|
||||||
|
description: "부지 경계, 사업 범위 등 참고 표시선"
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
name_ko: "무시 (사용 안 함)"
|
||||||
|
render_mode: "none"
|
||||||
|
color: "#CCCCCC"
|
||||||
|
description: "이 레이어의 요소는 무시"
|
||||||
|
|
||||||
|
# 렌더 모드 설명
|
||||||
|
render_modes:
|
||||||
|
tin: "Z값 있는 점 → TIN 표면 생성"
|
||||||
|
surface_overlay: "폐합 폴리라인 → TIN 위에 면 오버레이"
|
||||||
|
path_extrude: "선형 → 폭/오프셋으로 도로/수로 면 생성"
|
||||||
|
wall_extrude: "선형/폐합 → 수직 벽체 생성"
|
||||||
|
box_extrude: "폐합 → 일정 높이 박스 생성"
|
||||||
|
elevated_path: "선형 → 공중 경로 (교량)"
|
||||||
|
tube_path: "선형 → 원형 단면 튜브"
|
||||||
|
line_only: "선만 표시 (3D 면 없음)"
|
||||||
|
none: "렌더링 안 함"
|
||||||
497
structure_vlm_feedback.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
"""구조물 상세도면 ↔ 3D 빌드 결과 간 VLM(Gemini Vision) 피드백 루프.
|
||||||
|
|
||||||
|
흐름:
|
||||||
|
1. 상세 DXF를 평면 PNG로 렌더(ezdxf + matplotlib)
|
||||||
|
2. 빌드된 3D 메시를 top-down PNG로 렌더(PyVista off-screen)
|
||||||
|
3. 두 이미지 + 현재 파라미터 JSON을 Gemini Vision에 전달
|
||||||
|
4. 구조화된 JSON diff 수신 (missing/incorrect/excess)
|
||||||
|
5. diff를 파라미터에 머지(사용자 승인 후)
|
||||||
|
|
||||||
|
기존 Gemini 인프라(google.genai + Vertex AI gcp-key.json)를 그대로 재사용하며,
|
||||||
|
별도 API/SDK/결제선 없음. 모델은 기본 gemini-2.5-flash(저비용) → 필요 시 2.5-pro.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 렌더링
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_dxf_to_png(dxf_paths: list[str] | str,
|
||||||
|
output_path: str,
|
||||||
|
size: int = 1400,
|
||||||
|
dpi: int = 140,
|
||||||
|
bg: str = "white",
|
||||||
|
fg: str = "black") -> str:
|
||||||
|
"""상세 DXF를 matplotlib으로 렌더링해 PNG 저장.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dxf_paths: 단일 경로 또는 경로 리스트(첫 파일만 렌더)
|
||||||
|
output_path: 저장 경로
|
||||||
|
size: 결과 이미지 한 변 픽셀 (정사각)
|
||||||
|
dpi: matplotlib dpi
|
||||||
|
bg/fg: 배경/선 색
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
output_path
|
||||||
|
"""
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg", force=True)
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import ezdxf
|
||||||
|
from ezdxf.addons.drawing import Frontend, RenderContext
|
||||||
|
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
|
||||||
|
from ezdxf.addons.drawing.config import Configuration
|
||||||
|
|
||||||
|
dxf_path = dxf_paths[0] if isinstance(dxf_paths, (list, tuple)) else dxf_paths
|
||||||
|
|
||||||
|
doc = ezdxf.readfile(dxf_path)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
fig_in = size / dpi
|
||||||
|
fig, ax = plt.subplots(figsize=(fig_in, fig_in), dpi=dpi)
|
||||||
|
fig.patch.set_facecolor(bg)
|
||||||
|
ax.set_facecolor(bg)
|
||||||
|
ax.set_aspect("equal")
|
||||||
|
ax.set_axis_off()
|
||||||
|
|
||||||
|
ctx = RenderContext(doc)
|
||||||
|
try:
|
||||||
|
cfg = Configuration()
|
||||||
|
except Exception:
|
||||||
|
cfg = None
|
||||||
|
backend = MatplotlibBackend(ax)
|
||||||
|
frontend = Frontend(ctx, backend, config=cfg) if cfg else Frontend(ctx, backend)
|
||||||
|
frontend.draw_layout(msp, finalize=True)
|
||||||
|
|
||||||
|
fig.savefig(output_path, dpi=dpi, bbox_inches="tight",
|
||||||
|
facecolor=bg, pad_inches=0.1)
|
||||||
|
plt.close(fig)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def render_meshes_topdown(meshes: list[tuple],
|
||||||
|
output_path: str,
|
||||||
|
size: int = 1400,
|
||||||
|
bg: str = "white") -> str:
|
||||||
|
"""빌드된 메시 리스트를 top-down(평면) 뷰로 렌더.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meshes: [(pv.PolyData, color, opacity), ...]
|
||||||
|
output_path: 저장 경로
|
||||||
|
size: 정사각 픽셀
|
||||||
|
bg: 배경색
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
output_path
|
||||||
|
"""
|
||||||
|
import pyvista as pv
|
||||||
|
p = pv.Plotter(off_screen=True, window_size=(size, size))
|
||||||
|
p.set_background(bg)
|
||||||
|
for item in meshes:
|
||||||
|
try:
|
||||||
|
if len(item) >= 3:
|
||||||
|
mesh, color, opacity = item[0], item[1], item[2]
|
||||||
|
else:
|
||||||
|
mesh, color, opacity = item[0], item[1], 1.0
|
||||||
|
p.add_mesh(mesh, color=color, opacity=opacity,
|
||||||
|
show_edges=True, edge_color="#888888",
|
||||||
|
line_width=0.5, smooth_shading=False)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
p.enable_parallel_projection()
|
||||||
|
p.view_xy() # +Z에서 -Z 방향 내려다봄
|
||||||
|
p.camera.zoom(1.0)
|
||||||
|
try:
|
||||||
|
p.screenshot(output_path, transparent_background=False)
|
||||||
|
finally:
|
||||||
|
p.close()
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 파라미터 직렬화
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def params_to_dict(params: Any) -> dict:
|
||||||
|
"""dataclass / dict 객체를 JSON-직렬화 가능한 dict로 변환."""
|
||||||
|
if params is None:
|
||||||
|
return {}
|
||||||
|
if is_dataclass(params):
|
||||||
|
d = asdict(params)
|
||||||
|
elif isinstance(params, dict):
|
||||||
|
d = dict(params)
|
||||||
|
else:
|
||||||
|
# 일반 객체 속성 덤프
|
||||||
|
d = {k: v for k, v in vars(params).items() if not k.startswith("_")}
|
||||||
|
return _json_safe(d)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(obj):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _json_safe(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_json_safe(v) for v in obj]
|
||||||
|
if isinstance(obj, (np.floating, np.integer)):
|
||||||
|
return float(obj) if isinstance(obj, np.floating) else int(obj)
|
||||||
|
if isinstance(obj, np.ndarray):
|
||||||
|
return obj.tolist()
|
||||||
|
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
||||||
|
return obj
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gemini 호출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DIFF_SCHEMA_PROMPT = """당신은 기계설비 설계도면을 검토하는 엔지니어링 검증 도구입니다.
|
||||||
|
|
||||||
|
첨부된 이미지 2장:
|
||||||
|
image 1 = 원본 설계 도면 (DXF의 평면도 렌더)
|
||||||
|
image 2 = 현재 파서·빌더가 생성한 3D 모델의 top-down 뷰
|
||||||
|
|
||||||
|
현재 추출된 파라미터 JSON:
|
||||||
|
```json
|
||||||
|
{params_json}
|
||||||
|
```
|
||||||
|
|
||||||
|
구조물 유형: {structure_type}
|
||||||
|
|
||||||
|
두 이미지를 비교해 도면의 의도가 3D 모델에 얼마나 정확히 반영됐는지 평가하고,
|
||||||
|
차이(missing/incorrect/excess)를 다음 JSON 스키마로만 반환:
|
||||||
|
|
||||||
|
{{
|
||||||
|
"summary": "1-2문장 한국어 요약",
|
||||||
|
"match_score": 0.0~1.0 사이 실수 (도면 반영률, 1.0=완벽),
|
||||||
|
"param_updates": [
|
||||||
|
{{"path": "필드명 (예: chamber_width, bottom_el)",
|
||||||
|
"current": "현재 값",
|
||||||
|
"suggested": "도면에서 관찰한 값",
|
||||||
|
"reason": "한국어 근거 1문장"}}
|
||||||
|
],
|
||||||
|
"valves_missing": [
|
||||||
|
{{"name": "M-NNN 또는 설명", "x": float, "y": float,
|
||||||
|
"diameter_mm": int, "valve_type": "GATE|BUTTERFLY|CHECK|BALL",
|
||||||
|
"reason": "왜 누락으로 판단했는지"}}
|
||||||
|
],
|
||||||
|
"valves_incorrect": [
|
||||||
|
{{"name": "M-NNN", "field": "수정할 필드",
|
||||||
|
"current": "현재 값", "suggested": "제안 값", "reason": "..."}}
|
||||||
|
],
|
||||||
|
"pipes_missing": [
|
||||||
|
{{"name": "식별명", "diameter_mm": int,
|
||||||
|
"start": [x, y, z], "end": [x, y, z],
|
||||||
|
"reason": "..."}}
|
||||||
|
],
|
||||||
|
"pipes_incorrect": [
|
||||||
|
{{"name": "식별명", "field": "...", "current": "...", "suggested": "...", "reason": "..."}}
|
||||||
|
],
|
||||||
|
"excess_notes": ["모델에 있지만 도면에 없는 요소 설명"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
주의:
|
||||||
|
- 확실하지 않은 항목은 제안하지 마세요(false positive 최소화).
|
||||||
|
- 좌표·직경은 chamber/구조물 로컬 좌표계 기준 meter 또는 mm를 파라미터 JSON 단위에 맞추세요.
|
||||||
|
- 단순 렌더 품질 차이(색·조명)는 무시하세요. 도면 의도만 비교.
|
||||||
|
- JSON 외 어떠한 텍스트도 반환하지 마세요.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bytes(path: str) -> bytes:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def request_structure_diff(client,
|
||||||
|
drawing_png_path: str,
|
||||||
|
render_png_path: str,
|
||||||
|
params_dict: dict,
|
||||||
|
structure_type: str = "valve_chamber",
|
||||||
|
model: str = "gemini-2.5-flash",
|
||||||
|
log_fn: Callable[[str], None] = print,
|
||||||
|
timeout_s: float = 60.0) -> dict:
|
||||||
|
"""Gemini Vision에 도면+렌더+파라미터 전달해 JSON diff 수신.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: google.genai.Client 인스턴스 (caller가 인증 설정)
|
||||||
|
drawing_png_path: 원본 도면 PNG
|
||||||
|
render_png_path: 3D top-down PNG
|
||||||
|
params_dict: 현재 파라미터 (JSON-safe)
|
||||||
|
structure_type: 구조물 종류 (프롬프트 컨텍스트)
|
||||||
|
model: Gemini 모델명 (기본 2.5-flash)
|
||||||
|
log_fn: 로그 callback
|
||||||
|
timeout_s: 호출 타임아웃 (실제로는 SDK 설정에 따름)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
diff dict (스키마는 _DIFF_SCHEMA_PROMPT 참고)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: 호출 실패 또는 JSON 파싱 실패
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from google.genai import types as gtypes
|
||||||
|
except ImportError as 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(
|
||||||
|
params_json=params_json,
|
||||||
|
structure_type=structure_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawing_bytes = _read_bytes(drawing_png_path)
|
||||||
|
render_bytes = _read_bytes(render_png_path)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
gtypes.Part.from_bytes(data=drawing_bytes, mime_type="image/png"),
|
||||||
|
gtypes.Part.from_bytes(data=render_bytes, mime_type="image/png"),
|
||||||
|
gtypes.Part.from_text(text=prompt),
|
||||||
|
]
|
||||||
|
|
||||||
|
log_fn(f" [VLM] Gemini 호출: model={model}, drawing={len(drawing_bytes)//1024}KB, render={len(render_bytes)//1024}KB")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=parts,
|
||||||
|
config=gtypes.GenerateContentConfig(
|
||||||
|
response_mime_type="application/json",
|
||||||
|
temperature=0.1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Gemini 호출 실패: {e}") from e
|
||||||
|
|
||||||
|
text = getattr(resp, "text", None) or ""
|
||||||
|
if not text:
|
||||||
|
# 일부 SDK 버전은 candidates[0].content.parts[0].text 사용
|
||||||
|
try:
|
||||||
|
text = resp.candidates[0].content.parts[0].text
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
raise RuntimeError("Gemini 응답이 비어있습니다.")
|
||||||
|
|
||||||
|
# 혹시 모를 코드블록 제거
|
||||||
|
text = re.sub(r"^```(?:json)?\s*", "", text.strip())
|
||||||
|
text = re.sub(r"\s*```$", "", text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
diff = json.loads(text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# 부분 복구 시도 (첫 {부터 마지막 }까지)
|
||||||
|
m = re.search(r"\{.*\}", text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
diff = json.loads(m.group(0))
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(f"JSON 파싱 실패: {e}\n원문: {text[:300]}") from e
|
||||||
|
else:
|
||||||
|
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', []))} "
|
||||||
|
f"v_missing={len(diff.get('valves_missing', []))} "
|
||||||
|
f"p_missing={len(diff.get('pipes_missing', []))}")
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# diff 적용
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_diff_to_params(params: Any,
|
||||||
|
diff: dict,
|
||||||
|
selections: dict | None = None,
|
||||||
|
log_fn: Callable[[str], None] = print) -> dict:
|
||||||
|
"""diff를 params 객체에 in-place 적용.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: dataclass 인스턴스 (ValveChamberParams 등)
|
||||||
|
diff: request_structure_diff 반환값
|
||||||
|
selections: {"param_updates": [bool, ...], "valves_missing": [bool, ...],
|
||||||
|
"pipes_missing": [bool, ...]} — 사용자 체크박스. None이면 모두 적용.
|
||||||
|
log_fn: 로그 callback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"applied": int, "skipped": int, "errors": [str, ...]}
|
||||||
|
"""
|
||||||
|
sel = selections or {}
|
||||||
|
applied = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# 1) 스칼라/벡터 파라미터 업데이트
|
||||||
|
for i, upd in enumerate(diff.get("param_updates", []) or []):
|
||||||
|
if sel.get("param_updates") is not None and not sel["param_updates"][i]:
|
||||||
|
continue
|
||||||
|
path = upd.get("path", "").strip()
|
||||||
|
suggested = upd.get("suggested")
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_set_by_path(params, path, suggested)
|
||||||
|
applied += 1
|
||||||
|
log_fn(f" [VLM apply] {path} = {suggested!r}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{path}: {e}")
|
||||||
|
|
||||||
|
# 2) Valve 추가
|
||||||
|
if hasattr(params, "valves") and isinstance(params.valves, list):
|
||||||
|
try:
|
||||||
|
from valve_chamber_parser import Valve
|
||||||
|
except ImportError:
|
||||||
|
Valve = None
|
||||||
|
if Valve is not None:
|
||||||
|
for i, v in enumerate(diff.get("valves_missing", []) or []):
|
||||||
|
if sel.get("valves_missing") is not None and not sel["valves_missing"][i]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dia_mm = float(v.get("diameter_mm", 400))
|
||||||
|
params.valves.append(Valve(
|
||||||
|
index=len(params.valves),
|
||||||
|
name=v.get("name", f"V+{i+1}"),
|
||||||
|
valve_type=v.get("valve_type", "GATE"),
|
||||||
|
center_x=float(v.get("x", 0.0)),
|
||||||
|
center_y=float(v.get("y", 0.0)),
|
||||||
|
elevation=float(getattr(params, "bottom_el", 0.0)) + 1.5,
|
||||||
|
diameter=dia_mm / 1000.0,
|
||||||
|
label=(v.get("name", "") + " [VLM 추가]")[:60],
|
||||||
|
))
|
||||||
|
applied += 1
|
||||||
|
log_fn(f" [VLM apply] +valve {v.get('name')}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"valve_missing[{i}]: {e}")
|
||||||
|
|
||||||
|
# 3) Pipe 추가
|
||||||
|
if hasattr(params, "pipes") and isinstance(params.pipes, list):
|
||||||
|
try:
|
||||||
|
from valve_chamber_parser import Pipe
|
||||||
|
except ImportError:
|
||||||
|
Pipe = None
|
||||||
|
if Pipe is not None:
|
||||||
|
for i, pp in enumerate(diff.get("pipes_missing", []) or []):
|
||||||
|
if sel.get("pipes_missing") is not None and not sel["pipes_missing"][i]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dia_mm = float(pp.get("diameter_mm", 800))
|
||||||
|
start = tuple(pp.get("start", (0.0, 0.0, 0.0)))
|
||||||
|
end = tuple(pp.get("end", (0.0, 0.0, 0.0)))
|
||||||
|
params.pipes.append(Pipe(
|
||||||
|
name=pp.get("name", f"P+{i+1}") + " [VLM]",
|
||||||
|
diameter=dia_mm / 1000.0,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
elevation=start[2] if len(start) > 2 else 0.0,
|
||||||
|
))
|
||||||
|
applied += 1
|
||||||
|
log_fn(f" [VLM apply] +pipe {pp.get('name')}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"pipe_missing[{i}]: {e}")
|
||||||
|
|
||||||
|
return {"applied": applied, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
def _set_by_path(obj: Any, path: str, value: Any):
|
||||||
|
"""단순 속성 경로로 값 설정 (현재 PoC는 평면 필드만 지원)."""
|
||||||
|
# a.b.c[0] 형식은 최소화 — 평면 필드만
|
||||||
|
if "." in path or "[" in path:
|
||||||
|
# 차후 확장 포인트: 지금은 경고만 기록
|
||||||
|
raise ValueError(f"중첩 경로는 미지원: {path}")
|
||||||
|
if not hasattr(obj, path):
|
||||||
|
raise AttributeError(f"속성 없음: {path}")
|
||||||
|
current = getattr(obj, path)
|
||||||
|
# 타입 강제 변환 (숫자/문자열만)
|
||||||
|
if isinstance(current, bool):
|
||||||
|
new_val = bool(value)
|
||||||
|
elif isinstance(current, int):
|
||||||
|
new_val = int(float(value))
|
||||||
|
elif isinstance(current, float):
|
||||||
|
new_val = float(value)
|
||||||
|
elif isinstance(current, str):
|
||||||
|
new_val = str(value)
|
||||||
|
else:
|
||||||
|
new_val = value
|
||||||
|
setattr(obj, path, new_val)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 클라이언트 생성 (scanvas_maker의 패턴 재사용)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_genai_client(project: str | None = None,
|
||||||
|
location: str = "global",
|
||||||
|
use_vertex: bool = True,
|
||||||
|
api_key: str | None = None,
|
||||||
|
log_fn: Callable[[str], None] = print):
|
||||||
|
"""Gemini 클라이언트 생성. Vertex AI 우선, 실패 시 API Key 폴백.
|
||||||
|
|
||||||
|
scanvas_maker의 AI 렌더링 경로와 동일 인증(gcp-key.json 또는 API Key)을 사용.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from google import genai
|
||||||
|
except ImportError as 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", "")
|
||||||
|
if proj:
|
||||||
|
try:
|
||||||
|
client = genai.Client(vertexai=True, project=proj, location=location)
|
||||||
|
log_fn(f" [VLM] Vertex AI client: project={proj}, location={location}")
|
||||||
|
return client
|
||||||
|
except Exception as e:
|
||||||
|
log_fn(f" [VLM] Vertex AI 실패 → API Key 폴백: {e}")
|
||||||
|
|
||||||
|
key = api_key or os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY", "")
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError("Gemini 인증 정보 없음 (Vertex project 또는 API key 필요)")
|
||||||
|
client = genai.Client(api_key=key)
|
||||||
|
log_fn(" [VLM] API Key client")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 편의 함수: 전체 루프 1회 실행
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_feedback_once(params: Any,
|
||||||
|
meshes: list,
|
||||||
|
dxf_paths: list[str],
|
||||||
|
client,
|
||||||
|
structure_type: str = "valve_chamber",
|
||||||
|
model: str = "gemini-2.5-flash",
|
||||||
|
work_dir: str | Path = "cache/vlm",
|
||||||
|
log_fn: Callable[[str], None] = print) -> dict:
|
||||||
|
"""1회 피드백 사이클 실행: 렌더 2장 + Gemini 호출. diff 반환만.
|
||||||
|
|
||||||
|
apply는 호출자가 사용자 승인 후 apply_diff_to_params 호출.
|
||||||
|
"""
|
||||||
|
work_dir = Path(work_dir)
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
drawing_png = str(work_dir / "drawing.png")
|
||||||
|
render_png = str(work_dir / "render_topdown.png")
|
||||||
|
|
||||||
|
log_fn(" [VLM] 도면 PNG 렌더링...")
|
||||||
|
render_dxf_to_png(dxf_paths, drawing_png)
|
||||||
|
log_fn(" [VLM] 3D top-down 렌더링...")
|
||||||
|
render_meshes_topdown(meshes, render_png)
|
||||||
|
|
||||||
|
params_dict = params_to_dict(params)
|
||||||
|
diff = request_structure_diff(
|
||||||
|
client, drawing_png, render_png, params_dict,
|
||||||
|
structure_type=structure_type, model=model, log_fn=log_fn,
|
||||||
|
)
|
||||||
|
diff["_artifacts"] = {"drawing_png": drawing_png, "render_png": render_png}
|
||||||
|
return diff
|
||||||
147
tile_downloader.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""XYZ 타일 서버에서 BBOX 영역 타일을 다운로드해 하나의 이미지로 합성.
|
||||||
|
|
||||||
|
지원:
|
||||||
|
- 일반 XYZ 템플릿 (`{x}/{y}/{z}`, 선택적 `{s}` 서브도메인)
|
||||||
|
- 줌 자동 하향 조정 (타일 수 상한 400)
|
||||||
|
- BBOX에 맞게 크롭 (타일 경계 ≠ 실제 BBOX) + 최종 resize
|
||||||
|
|
||||||
|
사용 예:
|
||||||
|
from tile_downloader import download_xyz_tiles
|
||||||
|
img = download_xyz_tiles(
|
||||||
|
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
37.5, 129.0, 37.6, 129.1,
|
||||||
|
zoom=17, log_fn=print,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
}
|
||||||
|
_SUBDOMAINS = ("0", "1", "2", "3")
|
||||||
|
|
||||||
|
|
||||||
|
def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
||||||
|
"""위경도 → WMTS 타일 좌표 (좌상단 기준)."""
|
||||||
|
lat_rad = math.radians(lat)
|
||||||
|
n = 2 ** zoom
|
||||||
|
x = int((lon + 180.0) / 360.0 * n)
|
||||||
|
y = int((1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def tile_to_latlon(x: int, y: int, zoom: int) -> tuple[float, float]:
|
||||||
|
"""타일 좌표 → 위경도 (타일 좌상단 모서리)."""
|
||||||
|
n = 2 ** zoom
|
||||||
|
lon = x / n * 360.0 - 180.0
|
||||||
|
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
|
||||||
|
return math.degrees(lat_rad), lon
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_zoom(min_lat: float, min_lon: float, max_lat: float, max_lon: float,
|
||||||
|
start_zoom: int, max_tiles: int = 400) -> int:
|
||||||
|
"""타일 수가 max_tiles 이하가 되는 최대 zoom 반환."""
|
||||||
|
for z in range(start_zoom, 10, -1):
|
||||||
|
x_min, y_min = latlon_to_tile(max_lat, min_lon, z)
|
||||||
|
x_max, y_max = latlon_to_tile(min_lat, max_lon, z)
|
||||||
|
if (x_max - x_min + 1) * (y_max - y_min + 1) <= max_tiles:
|
||||||
|
return z
|
||||||
|
return 11 # 하한
|
||||||
|
|
||||||
|
|
||||||
|
def download_xyz_tiles(url_template: str,
|
||||||
|
min_lat: float, min_lon: float,
|
||||||
|
max_lat: float, max_lon: float,
|
||||||
|
zoom: int = 17,
|
||||||
|
final_size: int = 2048,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
log_fn: Callable[[str], None] = print) -> Image.Image:
|
||||||
|
"""XYZ 타일 서버에서 BBOX 영역 타일을 다운로드·합성해 PIL Image 반환.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url_template: `{x}`, `{y}`, `{z}`, (선택) `{s}` 플레이스홀더 URL
|
||||||
|
min_lat/min_lon/max_lat/max_lon: WGS84 bounds
|
||||||
|
zoom: 시작 zoom (타일 수 과다 시 자동 하향)
|
||||||
|
final_size: 최종 결과 이미지 한 변 픽셀
|
||||||
|
timeout_s: per-tile HTTP timeout
|
||||||
|
log_fn: 진행 로그 callback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image (RGB, final_size × final_size)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 타일을 하나도 받지 못한 경우
|
||||||
|
"""
|
||||||
|
zoom = _auto_zoom(min_lat, min_lon, max_lat, max_lon, zoom)
|
||||||
|
x_min, y_min = latlon_to_tile(max_lat, min_lon, zoom)
|
||||||
|
x_max, y_max = latlon_to_tile(min_lat, max_lon, zoom)
|
||||||
|
cols = x_max - x_min + 1
|
||||||
|
rows = y_max - y_min + 1
|
||||||
|
log_fn(f"줌 레벨: {zoom}, 타일 그리드: {cols}x{rows} ({cols * rows}장)")
|
||||||
|
|
||||||
|
tile_size = 256
|
||||||
|
merged = Image.new("RGB", (cols * tile_size, rows * tile_size))
|
||||||
|
ok = 0
|
||||||
|
fail = 0
|
||||||
|
first_fail_logged = False
|
||||||
|
|
||||||
|
for ty in range(y_min, y_max + 1):
|
||||||
|
for tx in range(x_min, x_max + 1):
|
||||||
|
s = random.choice(_SUBDOMAINS)
|
||||||
|
url = (url_template
|
||||||
|
.replace("{x}", str(tx))
|
||||||
|
.replace("{y}", str(ty))
|
||||||
|
.replace("{z}", str(zoom))
|
||||||
|
.replace("{s}", s))
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=_DEFAULT_HEADERS, timeout=timeout_s)
|
||||||
|
if resp.status_code == 200 and len(resp.content) > 500:
|
||||||
|
tile_img = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||||
|
merged.paste(tile_img,
|
||||||
|
((tx - x_min) * tile_size, (ty - y_min) * tile_size))
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
if not first_fail_logged:
|
||||||
|
ct = resp.headers.get("Content-Type", "없음")
|
||||||
|
log_fn(f" 타일 실패 [{resp.status_code}] Content-Type: {ct}, 크기: {len(resp.content)}B")
|
||||||
|
first_fail_logged = True
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
fail += 1
|
||||||
|
if not first_fail_logged:
|
||||||
|
log_fn(f" 네트워크 오류: {e}")
|
||||||
|
first_fail_logged = True
|
||||||
|
|
||||||
|
log_fn(f"타일 다운로드: 성공 {ok}장, 실패 {fail}장")
|
||||||
|
|
||||||
|
if ok == 0:
|
||||||
|
raise ValueError("타일을 하나도 받지 못했습니다. 네트워크 연결을 확인하세요.")
|
||||||
|
|
||||||
|
# BBOX 크롭 (타일 경계 ≠ 실제 BBOX)
|
||||||
|
grid_lat_max, grid_lon_min = tile_to_latlon(x_min, y_min, zoom)
|
||||||
|
grid_lat_min, grid_lon_max = tile_to_latlon(x_max + 1, y_max + 1, zoom)
|
||||||
|
img_w = cols * tile_size
|
||||||
|
img_h = rows * tile_size
|
||||||
|
|
||||||
|
crop_left = int((min_lon - grid_lon_min) / (grid_lon_max - grid_lon_min) * img_w)
|
||||||
|
crop_right = int((max_lon - grid_lon_min) / (grid_lon_max - grid_lon_min) * img_w)
|
||||||
|
crop_top = int((grid_lat_max - max_lat) / (grid_lat_max - grid_lat_min) * img_h)
|
||||||
|
crop_bottom = int((grid_lat_max - min_lat) / (grid_lat_max - grid_lat_min) * img_h)
|
||||||
|
|
||||||
|
crop_left = max(0, min(crop_left, img_w - 1))
|
||||||
|
crop_right = max(crop_left + 1, min(crop_right, img_w))
|
||||||
|
crop_top = max(0, min(crop_top, img_h - 1))
|
||||||
|
crop_bottom = max(crop_top + 1, min(crop_bottom, img_h))
|
||||||
|
|
||||||
|
cropped = merged.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||||
|
return cropped.resize((final_size, final_size), Image.LANCZOS)
|
||||||
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()
|
||||||
400
valve_chamber_3d_builder.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""제수변실 + 도수관로 3D 파라메트릭 빌더.
|
||||||
|
|
||||||
|
구성요소:
|
||||||
|
1. 실 본체 (콘크리트 박스)
|
||||||
|
2. 벽체 절개 뷰 (내부가 보이도록 상부 일부 제거)
|
||||||
|
3. 도수관 (chamber 관통 파이프)
|
||||||
|
4. 송수관 (외부 연장)
|
||||||
|
5. 밸브 × N (원통 + 핸들)
|
||||||
|
6. 상단 슬라이드 뚜껑 / 맨홀
|
||||||
|
7. 내부 바닥 slabs (각 EL)
|
||||||
|
8. 외부 출입 계단
|
||||||
|
9. 지반
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
from valve_chamber_parser import ValveChamberParams, Valve
|
||||||
|
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
|
"chamber": "#A8A59B",
|
||||||
|
"slab": "#9A968C",
|
||||||
|
"pipe_steel": "#4E6E8E", # 강재도관 (강청색)
|
||||||
|
"pipe_cast": "#6B7B8C", # 주철관
|
||||||
|
"valve_body": "#27AE60", # 밸브 바디 (녹색 - 산업 표준)
|
||||||
|
"valve_handle":"#E74C3C", # 밸브 핸들 (빨강)
|
||||||
|
"valve_motor":"#F39C12", # 전동 모터 (주황)
|
||||||
|
"hatch": "#BDC3C7", # 뚜껑
|
||||||
|
"stairs": "#8B7D6B",
|
||||||
|
"parapet": "#A8A59B",
|
||||||
|
"ground": "#7F6F5F",
|
||||||
|
"pipe_conn": "#4A4A4A", # 플랜지
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ValveChamberBuilder:
|
||||||
|
def __init__(self, params: ValveChamberParams):
|
||||||
|
self.p = params
|
||||||
|
self.meshes: list[tuple[pv.PolyData, str, float]] = []
|
||||||
|
|
||||||
|
def build_all(self):
|
||||||
|
self.meshes = []
|
||||||
|
self._build_chamber_body()
|
||||||
|
self._build_floor_slabs()
|
||||||
|
self._build_main_conduit()
|
||||||
|
self._build_transmission_pipes()
|
||||||
|
self._build_valves()
|
||||||
|
self._build_hatch()
|
||||||
|
self._build_entry_stairs()
|
||||||
|
self._build_ground()
|
||||||
|
return self.meshes
|
||||||
|
|
||||||
|
# --- 실 본체 ---
|
||||||
|
|
||||||
|
def _build_chamber_body(self):
|
||||||
|
p = self.p
|
||||||
|
hw = p.chamber_width / 2
|
||||||
|
hd = p.chamber_depth / 2
|
||||||
|
wt = p.chamber_wall_thickness
|
||||||
|
z0 = p.bottom_el
|
||||||
|
z_top = p.top_el
|
||||||
|
|
||||||
|
# 4개 벽 (상단 일부는 뚜껑을 위해 빼지 않음)
|
||||||
|
# 앞 (Y = -hd)
|
||||||
|
self._add_box(-hw, hw, -hd, -hd + wt, z0, z_top, COLORS["chamber"])
|
||||||
|
# 뒤
|
||||||
|
self._add_box(-hw, hw, hd - wt, hd, z0, z_top, COLORS["chamber"])
|
||||||
|
# 좌
|
||||||
|
self._add_box(-hw, -hw + wt, -hd + wt, hd - wt, z0, z_top, COLORS["chamber"])
|
||||||
|
# 우
|
||||||
|
self._add_box(hw - wt, hw, -hd + wt, hd - wt, z0, z_top, COLORS["chamber"])
|
||||||
|
|
||||||
|
# 바닥
|
||||||
|
self._add_box(-hw, hw, -hd, hd, z0, z0 + 0.4, COLORS["chamber"])
|
||||||
|
|
||||||
|
# 상판 (지붕) - 뚜껑 공간 제외
|
||||||
|
slab_t = 0.4
|
||||||
|
if p.has_hatch and p.hatch_count > 0:
|
||||||
|
# 뚜껑 위치는 상단 중앙
|
||||||
|
hatch_s = p.hatch_size / 2
|
||||||
|
n = p.hatch_count
|
||||||
|
# 뚜껑 여러 개면 X방향 분산
|
||||||
|
for i in range(n):
|
||||||
|
hx = 0 if n == 1 else -hw * 0.5 + i / (n - 1) * hw
|
||||||
|
|
||||||
|
# 상판은 뚜껑 기준 좌우로 분할
|
||||||
|
# 좌측
|
||||||
|
if i == 0:
|
||||||
|
self._add_box(-hw, hx - hatch_s, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||||||
|
# 우측 (마지막이면 우측 끝까지)
|
||||||
|
if i == n - 1:
|
||||||
|
self._add_box(hx + hatch_s, hw, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||||||
|
# 뚜껑 주변 Y 방향 채움
|
||||||
|
self._add_box(hx - hatch_s, hx + hatch_s, -hd, -hatch_s, z_top, z_top + slab_t, COLORS["chamber"])
|
||||||
|
self._add_box(hx - hatch_s, hx + hatch_s, hatch_s, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||||||
|
else:
|
||||||
|
# 풀 상판
|
||||||
|
self._add_box(-hw, hw, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||||||
|
|
||||||
|
# --- 내부 바닥 slab (각 EL) ---
|
||||||
|
|
||||||
|
def _build_floor_slabs(self):
|
||||||
|
p = self.p
|
||||||
|
hw = p.chamber_width / 2 - p.chamber_wall_thickness - 0.1
|
||||||
|
hd = p.chamber_depth / 2 - p.chamber_wall_thickness - 0.1
|
||||||
|
for el in p.floor_elevations:
|
||||||
|
if el <= p.bottom_el + 0.4:
|
||||||
|
continue
|
||||||
|
if el >= p.top_el - 0.4:
|
||||||
|
continue
|
||||||
|
self._add_box(-hw, hw, -hd, hd, el - 0.1, el + 0.1, COLORS["slab"])
|
||||||
|
|
||||||
|
# --- 도수관 (chamber 관통) ---
|
||||||
|
|
||||||
|
def _build_main_conduit(self):
|
||||||
|
"""파서가 M-301/도수관 pipe를 추출했으면 그쪽에 위임 (no-op).
|
||||||
|
|
||||||
|
분기(has_inlet_branch)·단일 모두 파서 `_finalize`가 pipe 객체로 생성하므로
|
||||||
|
빌더는 `_build_transmission_pipes` 한 경로만 돌면 된다. 파서에서 도수관이
|
||||||
|
전혀 없을 때만(비정상 상태) 경고 출력 후 종료.
|
||||||
|
"""
|
||||||
|
p = self.p
|
||||||
|
has_main = any(
|
||||||
|
("M-301" in pp.name) or ("도수관" in pp.name)
|
||||||
|
for pp in (p.pipes or [])
|
||||||
|
)
|
||||||
|
if has_main:
|
||||||
|
return
|
||||||
|
# 여기에 도달한다면 파서가 도수관을 생성 실패한 것 — 조용히 skip하여
|
||||||
|
# "혼자 떠 있는 고아 파이프"가 생기지 않도록 한다.
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- 송수관 및 외부 연장 ---
|
||||||
|
|
||||||
|
def _build_transmission_pipes(self):
|
||||||
|
"""parser가 추출한 모든 pipe를 명시적 start/end/diameter로 빌드."""
|
||||||
|
p = self.p
|
||||||
|
if not p.pipes:
|
||||||
|
return
|
||||||
|
for pipe in p.pipes:
|
||||||
|
start = pipe.start
|
||||||
|
end = pipe.end
|
||||||
|
# 유효성 체크: zero-vector 이면 폴백 _finalize에서 처리됨
|
||||||
|
if start == (0.0, 0.0, 0.0) and end == (0.0, 0.0, 0.0):
|
||||||
|
continue
|
||||||
|
is_main = ("M-301" in pipe.name) or ("도수관" in pipe.name)
|
||||||
|
color = COLORS["pipe_steel"] if is_main else COLORS["pipe_cast"]
|
||||||
|
self._add_pipe(start, end, pipe.diameter, color)
|
||||||
|
# 양 끝 플랜지 (도수관/대구경만)
|
||||||
|
if pipe.diameter >= 0.3:
|
||||||
|
self._add_flange(start, pipe.diameter, COLORS["pipe_conn"])
|
||||||
|
self._add_flange(end, pipe.diameter, COLORS["pipe_conn"])
|
||||||
|
|
||||||
|
# --- 밸브 ---
|
||||||
|
|
||||||
|
def _build_valves(self):
|
||||||
|
p = self.p
|
||||||
|
for v in p.valves:
|
||||||
|
self._build_single_valve(v)
|
||||||
|
|
||||||
|
def _build_single_valve(self, v: Valve):
|
||||||
|
"""밸브 하나: 바디 + 핸들/모터."""
|
||||||
|
# 위치 검증
|
||||||
|
z = v.elevation
|
||||||
|
x = v.center_x
|
||||||
|
y = v.center_y
|
||||||
|
|
||||||
|
# 밸브 바디 (축정렬 원통 또는 박스)
|
||||||
|
body_r = v.diameter / 2
|
||||||
|
body_h = v.diameter * 1.5
|
||||||
|
|
||||||
|
# 밸브가 놓인 파이프 방향 추정 (X 또는 Y)
|
||||||
|
# 가장 가까운 pipe의 (end-start) 방향을 사용; 없으면 X축 기본
|
||||||
|
flow_dir = (1.0, 0.0, 0.0)
|
||||||
|
try:
|
||||||
|
best_d = None
|
||||||
|
for pp in (self.p.pipes or []):
|
||||||
|
if pp.start == (0,0,0) and pp.end == (0,0,0):
|
||||||
|
continue
|
||||||
|
# pipe 중간점과 valve 거리
|
||||||
|
mx = (pp.start[0] + pp.end[0]) / 2
|
||||||
|
my = (pp.start[1] + pp.end[1]) / 2
|
||||||
|
d = ((mx - x)**2 + (my - y)**2) ** 0.5
|
||||||
|
if best_d is None or d < best_d:
|
||||||
|
dx = pp.end[0] - pp.start[0]
|
||||||
|
dy = pp.end[1] - pp.start[1]
|
||||||
|
L = (dx*dx + dy*dy) ** 0.5
|
||||||
|
if L > 1e-6:
|
||||||
|
flow_dir = (dx/L, dy/L, 0.0)
|
||||||
|
best_d = d
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if v.valve_type == "BUTTERFLY":
|
||||||
|
# 버터플라이: 납작한 원통 (관로 방향에 직교 평면)
|
||||||
|
body = pv.Cylinder(
|
||||||
|
center=(x, y, z),
|
||||||
|
direction=flow_dir,
|
||||||
|
radius=body_r * 1.1,
|
||||||
|
height=body_h * 0.6,
|
||||||
|
resolution=20,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((body, COLORS["valve_body"], 1.0))
|
||||||
|
|
||||||
|
# 상부 모터 박스
|
||||||
|
motor_size = body_r * 1.2
|
||||||
|
motor_base = z + body_r * 1.1
|
||||||
|
motor_top = motor_base + motor_size * 2
|
||||||
|
self._add_box(
|
||||||
|
x - motor_size / 2, x + motor_size / 2,
|
||||||
|
y - motor_size / 2, y + motor_size / 2,
|
||||||
|
motor_base, motor_top,
|
||||||
|
COLORS["valve_motor"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 모터 축
|
||||||
|
stem = pv.Cylinder(
|
||||||
|
center=(x, y, (z + body_r + motor_base) / 2),
|
||||||
|
direction=(0, 0, 1),
|
||||||
|
radius=0.05,
|
||||||
|
height=(motor_base - z - body_r),
|
||||||
|
resolution=8,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((stem, COLORS["pipe_conn"], 1.0))
|
||||||
|
|
||||||
|
elif v.valve_type == "GATE":
|
||||||
|
# 게이트: 박스형 바디. 관로 방향이 X면 X축으로 길게, Y면 Y축으로 길게
|
||||||
|
if abs(flow_dir[0]) >= abs(flow_dir[1]):
|
||||||
|
# X 방향 흐름 → 박스도 X 길이↑
|
||||||
|
self._add_box(
|
||||||
|
x - body_r * 1.4, x + body_r * 1.4,
|
||||||
|
y - body_r, y + body_r,
|
||||||
|
z - body_r, z + body_r,
|
||||||
|
COLORS["valve_body"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Y 방향 흐름
|
||||||
|
self._add_box(
|
||||||
|
x - body_r, x + body_r,
|
||||||
|
y - body_r * 1.4, y + body_r * 1.4,
|
||||||
|
z - body_r, z + body_r,
|
||||||
|
COLORS["valve_body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상부 stem (게이트 밸브의 특징)
|
||||||
|
stem_h = body_r * 4
|
||||||
|
stem = pv.Cylinder(
|
||||||
|
center=(x, y, z + body_r + stem_h / 2),
|
||||||
|
direction=(0, 0, 1),
|
||||||
|
radius=0.04,
|
||||||
|
height=stem_h,
|
||||||
|
resolution=8,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((stem, COLORS["pipe_conn"], 1.0))
|
||||||
|
|
||||||
|
# 상부 핸들 or 모터
|
||||||
|
handle_r = body_r * 1.0
|
||||||
|
handle = pv.Cylinder(
|
||||||
|
center=(x, y, z + body_r + stem_h),
|
||||||
|
direction=(0, 0, 1),
|
||||||
|
radius=handle_r,
|
||||||
|
height=0.1,
|
||||||
|
resolution=16,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((handle, COLORS["valve_handle"], 1.0))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 일반: 단순 박스
|
||||||
|
self._add_box(
|
||||||
|
x - body_r, x + body_r,
|
||||||
|
y - body_r, y + body_r,
|
||||||
|
z - body_r, z + body_r,
|
||||||
|
COLORS["valve_body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 상단 슬라이드 뚜껑 ---
|
||||||
|
|
||||||
|
def _build_hatch(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_hatch:
|
||||||
|
return
|
||||||
|
hw = p.chamber_width / 2
|
||||||
|
hs = p.hatch_size / 2
|
||||||
|
z = p.top_el + 0.5 # 상판 위
|
||||||
|
|
||||||
|
for i in range(p.hatch_count):
|
||||||
|
hx = 0 if p.hatch_count == 1 else -hw * 0.5 + i / (p.hatch_count - 1) * hw
|
||||||
|
# 뚜껑 (약간 돌출)
|
||||||
|
self._add_box(
|
||||||
|
hx - hs, hx + hs,
|
||||||
|
-hs, hs,
|
||||||
|
z, z + 0.15,
|
||||||
|
COLORS["hatch"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 외부 출입 계단 ---
|
||||||
|
|
||||||
|
def _build_entry_stairs(self):
|
||||||
|
p = self.p
|
||||||
|
if not p.has_entry_stairs:
|
||||||
|
return
|
||||||
|
hw = p.chamber_width / 2
|
||||||
|
total_rise = p.top_el - p.bottom_el
|
||||||
|
n_steps = max(int(total_rise / 0.17), 8)
|
||||||
|
step_h = total_rise / n_steps
|
||||||
|
step_d = 0.28
|
||||||
|
stair_w = 1.2
|
||||||
|
z0 = p.bottom_el
|
||||||
|
|
||||||
|
# 좌측 외부 계단
|
||||||
|
x_start = -hw - 0.5
|
||||||
|
for i in range(n_steps):
|
||||||
|
sx0 = x_start - (i + 1) * step_d
|
||||||
|
sx1 = x_start - i * step_d
|
||||||
|
z_top = z0 + (i + 1) * step_h
|
||||||
|
self._add_box(sx0, sx1,
|
||||||
|
-stair_w / 2, stair_w / 2,
|
||||||
|
z0, z_top,
|
||||||
|
COLORS["stairs"])
|
||||||
|
|
||||||
|
# --- 지반 ---
|
||||||
|
|
||||||
|
def _build_ground(self):
|
||||||
|
p = self.p
|
||||||
|
margin = max(p.chamber_width, p.chamber_depth) * 0.8
|
||||||
|
hw = p.chamber_width / 2 + margin
|
||||||
|
hd = p.chamber_depth / 2 + margin
|
||||||
|
ground_el = p.bottom_el - 0.5
|
||||||
|
pts = np.array([
|
||||||
|
[-hw, -hd, ground_el], [hw, -hd, ground_el],
|
||||||
|
[hw, hd, ground_el], [-hw, hd, ground_el],
|
||||||
|
])
|
||||||
|
self.meshes.append((pv.PolyData(pts, np.array([4, 0, 1, 2, 3])),
|
||||||
|
COLORS["ground"], 1.0))
|
||||||
|
|
||||||
|
# --- 저수준 헬퍼 ---
|
||||||
|
|
||||||
|
def _add_box(self, x0, x1, y0, y1, z0, z1, color):
|
||||||
|
if x1 <= x0 or y1 <= y0 or z1 <= z0:
|
||||||
|
return
|
||||||
|
pts = np.array([
|
||||||
|
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
|
||||||
|
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
|
||||||
|
])
|
||||||
|
faces = np.hstack([
|
||||||
|
[4, 0, 3, 2, 1], [4, 4, 5, 6, 7],
|
||||||
|
[4, 0, 1, 5, 4], [4, 2, 3, 7, 6],
|
||||||
|
[4, 1, 2, 6, 5], [4, 0, 4, 7, 3],
|
||||||
|
])
|
||||||
|
self.meshes.append((pv.PolyData(pts, faces), color, 1.0))
|
||||||
|
|
||||||
|
def _add_pipe(self, start, end, diameter, color):
|
||||||
|
try:
|
||||||
|
s = np.array(start)
|
||||||
|
e = np.array(end)
|
||||||
|
length = float(np.linalg.norm(e - s))
|
||||||
|
if length < 0.1:
|
||||||
|
return
|
||||||
|
direction = (e - s) / length
|
||||||
|
center = (s + e) / 2
|
||||||
|
pipe = pv.Cylinder(
|
||||||
|
center=tuple(center),
|
||||||
|
direction=tuple(direction),
|
||||||
|
radius=diameter / 2,
|
||||||
|
height=length,
|
||||||
|
resolution=20,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((pipe, color, 1.0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _add_flange(self, pos, diameter, color):
|
||||||
|
try:
|
||||||
|
flange = pv.Cylinder(
|
||||||
|
center=pos,
|
||||||
|
direction=(1, 0, 0), # X방향 간단 배치
|
||||||
|
radius=diameter / 2 * 1.3,
|
||||||
|
height=0.15,
|
||||||
|
resolution=16,
|
||||||
|
).extract_surface()
|
||||||
|
self.meshes.append((flange, color, 1.0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def build_valve_chamber_meshes(params: ValveChamberParams):
|
||||||
|
return ValveChamberBuilder(params).build_all()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from valve_chamber_parser import parse_valve_chamber
|
||||||
|
paths = ["SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf"]
|
||||||
|
p = parse_valve_chamber(paths)
|
||||||
|
print(p.summary())
|
||||||
|
meshes = ValveChamberBuilder(p).build_all()
|
||||||
|
print(f"\n{len(meshes)}개 구성요소 생성")
|
||||||
679
valve_chamber_parser.py
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
"""제수변실 (Valve Chamber) + 도수관로 DXF 파서.
|
||||||
|
|
||||||
|
구조 특성:
|
||||||
|
- 콘크리트 실(chamber) 본체
|
||||||
|
- 내부 밸브 다수 (게이트/버터플라이/체크 등)
|
||||||
|
- 도수관 (intake main pipe, 주 입수관)
|
||||||
|
- 송수관 (transmission pipes, 여러 계통)
|
||||||
|
- 상단 슬라이드 뚜껑/맨홀
|
||||||
|
- 외부 관로 연장
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
parser = ValveChamberParser()
|
||||||
|
params = parser.parse(["valve_chamber.dxf"])
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from view_detector import detect_view_regions
|
||||||
|
from dxf_geometry import extract_structural_geometry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 클래스
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VALVE_TYPES = {
|
||||||
|
"BUTTERFLY": "버터플라이",
|
||||||
|
"GATE": "게이트",
|
||||||
|
"CHECK": "체크",
|
||||||
|
"BALL": "볼",
|
||||||
|
"UNKNOWN": "일반",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Valve:
|
||||||
|
"""개별 밸브."""
|
||||||
|
index: int = 0
|
||||||
|
name: str = "" # "M-302" 등
|
||||||
|
valve_type: str = "GATE" # BUTTERFLY / GATE / CHECK
|
||||||
|
center_x: float = 0.0 # 실 로컬 X (m)
|
||||||
|
center_y: float = 0.0 # 실 로컬 Y (m)
|
||||||
|
elevation: float = 0.0 # EL (m)
|
||||||
|
diameter: float = 0.4 # 관경 (m)
|
||||||
|
label: str = "" # "M-302 주밸브"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Pipe:
|
||||||
|
"""개별 관로 (도수관/송수관)."""
|
||||||
|
name: str = "" # "M-301 도수관"
|
||||||
|
diameter: float = 0.8 # 관경 (m)
|
||||||
|
start: tuple = (0.0, 0.0, 0.0) # 월드 좌표
|
||||||
|
end: tuple = (0.0, 0.0, 0.0)
|
||||||
|
elevation: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValveChamberParams:
|
||||||
|
"""제수변실 파라미터."""
|
||||||
|
|
||||||
|
# 실 본체
|
||||||
|
chamber_width: float = 27.0 # X 방향 가로 (실 폭)
|
||||||
|
chamber_depth: float = 9.0 # Y 방향 세로 (실 깊이)
|
||||||
|
chamber_wall_thickness: float = 0.6
|
||||||
|
bottom_el: float = 21.0
|
||||||
|
top_el: float = 28.5 # 상판 EL
|
||||||
|
|
||||||
|
# 내부 바닥 여러 EL (단형 바닥)
|
||||||
|
floor_elevations: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# 밸브
|
||||||
|
valves: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# 관로 (도수/송수)
|
||||||
|
pipes: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# 주 도수관 (chamber 관통)
|
||||||
|
main_conduit_diameter: float = 1.0
|
||||||
|
main_conduit_direction: str = "X" # "X" (가로 관통) | "Y"
|
||||||
|
main_conduit_el: float = 22.0
|
||||||
|
|
||||||
|
# 상단 슬라이드 뚜껑 / 맨홀
|
||||||
|
has_hatch: bool = True
|
||||||
|
hatch_count: int = 1
|
||||||
|
hatch_size: float = 1.0
|
||||||
|
|
||||||
|
# 외부 관로 연장 (방향별 분리 — 도면 실측에 맞춤)
|
||||||
|
external_pipe_length: float = 5.0 # legacy 단일값 (fallback·하위호환)
|
||||||
|
upstream_pipe_length: float = 3.0 # 좌측 도수관 상류(분기점 왼쪽) 길이
|
||||||
|
downstream_pipe_length: float = 4.0 # 우측 송수관 하류 길이 (실측 ≈ 3.6m)
|
||||||
|
|
||||||
|
# 상류 도수관 분기 (Y-branch): 도수관 1개 → 상단·하단 2개로 분기 후 chamber 진입
|
||||||
|
has_inlet_branch: bool = True # 분기 구조 여부 (UI 토글)
|
||||||
|
branch_spread_m: float = 4.4 # 상단·하단 관 중심 Y 간격 (실측)
|
||||||
|
branch_angle_deg: float = 35.0 # 분기 합류각 (deg, 단관→분기)
|
||||||
|
branch_trunk_length: float = 3.0 # 분기점 이전의 단일 도수관 길이
|
||||||
|
|
||||||
|
# 출입 계단
|
||||||
|
has_entry_stairs: bool = True
|
||||||
|
|
||||||
|
# 지반 / 환경
|
||||||
|
ground_level: float | None = None # 실 바닥보다 높은 지반
|
||||||
|
|
||||||
|
# 소스
|
||||||
|
source_files: list = field(default_factory=list)
|
||||||
|
raw_annotations: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
return (
|
||||||
|
f"Valve Chamber: {self.chamber_width:.1f} × {self.chamber_depth:.1f}m, "
|
||||||
|
f"EL.{self.bottom_el:.1f}~{self.top_el:.1f} (H={self.top_el - self.bottom_el:.1f}m)\n"
|
||||||
|
f" 밸브 {len(self.valves)}개, 관로 {len(self.pipes)}개, "
|
||||||
|
f"뚜껑 {'O' if self.has_hatch else 'X'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 파서
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EL_PATTERN = re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE)
|
||||||
|
|
||||||
|
VALVE_TEXT_PATTERN = re.compile(
|
||||||
|
r"(M-\d{3})[\s.]*(?:(.*?밸브)|(.*?관))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 밸브 타입 키워드
|
||||||
|
VALVE_TYPE_KEYWORDS = {
|
||||||
|
"BUTTERFLY": ["버터플라이", "butterfly"],
|
||||||
|
"GATE": ["게이트", "gate"],
|
||||||
|
"CHECK": ["체크", "check"],
|
||||||
|
"BALL": ["볼", "ball"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_mtext(raw: str) -> str:
|
||||||
|
"""MTEXT 포맷 코드(\\C4;, \\f...;, \\H...;)를 제거하고 \\P를 줄바꿈으로 변환."""
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
text = raw
|
||||||
|
# 색상 \\C#;, 폰트 \\f...;, 높이 \\H...;, 너비 \\W...; 등 일반 포맷 코드 제거
|
||||||
|
text = re.sub(r"\\C\d+;?", "", text)
|
||||||
|
text = re.sub(r"\\f[^;]*;", "", text)
|
||||||
|
text = re.sub(r"\\H[\d.]+x?;?", "", text)
|
||||||
|
text = re.sub(r"\\W[\d.]+;?", "", text)
|
||||||
|
text = re.sub(r"\\Q[\d.\-]+;?", "", text)
|
||||||
|
text = re.sub(r"\\T[\d.]+;?", "", text)
|
||||||
|
text = re.sub(r"\\A\d+;?", "", text)
|
||||||
|
# 줄바꿈
|
||||||
|
text = text.replace("\\P", "\n")
|
||||||
|
# 그룹 괄호 정리
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("{"):
|
||||||
|
text = text[1:]
|
||||||
|
if text.endswith("}"):
|
||||||
|
text = text[:-1]
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _mleader_endpoint(ml) -> tuple[float, float] | None:
|
||||||
|
"""MLEADER에서 첫 leader line의 끝점(화살표 위치, DXF raw 좌표) 반환."""
|
||||||
|
try:
|
||||||
|
ctx = ml.context
|
||||||
|
for leader in (getattr(ctx, "leaders", None) or []):
|
||||||
|
for line in (getattr(leader, "lines", None) or []):
|
||||||
|
vs = getattr(line, "vertices", None)
|
||||||
|
if vs:
|
||||||
|
return (vs[0].x, vs[0].y)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mleader_text(ml) -> str:
|
||||||
|
try:
|
||||||
|
ctx = ml.context
|
||||||
|
if ctx and getattr(ctx, "mtext", None):
|
||||||
|
return _clean_mtext(ctx.mtext.default_content or "")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class ValveChamberParser:
|
||||||
|
"""제수변실 파서."""
|
||||||
|
|
||||||
|
def parse(self, dxf_paths: list[str]) -> ValveChamberParams:
|
||||||
|
params = ValveChamberParams()
|
||||||
|
params.source_files = list(dxf_paths)
|
||||||
|
|
||||||
|
for path in dxf_paths:
|
||||||
|
try:
|
||||||
|
self._parse_single(path, params)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 파싱 오류: {e}")
|
||||||
|
|
||||||
|
self._finalize(params)
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _parse_single(self, path: str, params: ValveChamberParams):
|
||||||
|
doc = ezdxf.readfile(path)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
geom = extract_structural_geometry(path)
|
||||||
|
scale = geom.unit_scale
|
||||||
|
|
||||||
|
views = detect_view_regions(path)
|
||||||
|
|
||||||
|
# EL 텍스트 수집
|
||||||
|
el_texts = self._collect_el(msp, scale)
|
||||||
|
if el_texts:
|
||||||
|
els = [v for _, _, v in el_texts]
|
||||||
|
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
|
||||||
|
if params.bottom_el < v < params.top_el
|
||||||
|
))
|
||||||
|
params.floor_elevations = mid_els[:5] # 상위 5개
|
||||||
|
params.raw_annotations.extend(
|
||||||
|
[(f"EL.{v:.2f}", x, y) for x, y, v in el_texts]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 실 본체 크기: 평면도 영역
|
||||||
|
plan_view = None
|
||||||
|
for v in views:
|
||||||
|
if v.view_type == "plan":
|
||||||
|
plan_view = v
|
||||||
|
break
|
||||||
|
|
||||||
|
# 1순위: 평면도 영역 그대로 사용 (기본값 27×9는 폴백, 실제 도면 우선)
|
||||||
|
if plan_view:
|
||||||
|
params.chamber_width = plan_view.width
|
||||||
|
params.chamber_depth = plan_view.height
|
||||||
|
else:
|
||||||
|
# 2순위: CS-CONC-밸브실 레이어 bbox
|
||||||
|
chamber_pts = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxf.layer != "CS-CONC-밸브실":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if e.dxftype() == "LWPOLYLINE":
|
||||||
|
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))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if chamber_pts:
|
||||||
|
arr = np.array(chamber_pts)
|
||||||
|
params.chamber_width = arr[:, 0].max() - arr[:, 0].min()
|
||||||
|
params.chamber_depth = arr[:, 1].max() - arr[:, 1].min()
|
||||||
|
|
||||||
|
# 밸브/관로 추출:
|
||||||
|
# 1) MLEADER (안내선) 끝점에서 정확한 chamber-local 좌표 확보
|
||||||
|
# 2) MLEADER에 없는 항목은 TEXT 기반 폴백 (M-301 도수관 등)
|
||||||
|
ml_valves, ml_pipes = self._extract_from_mleaders(msp, plan_view, scale, params)
|
||||||
|
txt_valves, txt_pipes = self._extract_valves_and_pipes(msp, scale)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
merged_valves.extend(v for v in txt_valves if v.name and v.name not in ml_names)
|
||||||
|
merged_pipes = list(ml_pipes)
|
||||||
|
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)
|
||||||
|
# 에서 잡혀 중복 생성되는 케이스 방지. 직경이 명시된 항목 우선.
|
||||||
|
def _dedupe(items, default_dia: float):
|
||||||
|
best: dict[str, object] = {}
|
||||||
|
for it in items:
|
||||||
|
key = it.name
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
if key not in best:
|
||||||
|
best[key] = it
|
||||||
|
else:
|
||||||
|
cur = best[key]
|
||||||
|
cur_default = abs(getattr(cur, "diameter", 0) - default_dia) < 1e-6
|
||||||
|
new_default = abs(getattr(it, "diameter", 0) - default_dia) < 1e-6
|
||||||
|
if cur_default and not new_default:
|
||||||
|
best[key] = it
|
||||||
|
return list(best.values())
|
||||||
|
|
||||||
|
merged_valves = _dedupe(merged_valves, default_dia=0.4)
|
||||||
|
merged_pipes = _dedupe(merged_pipes, default_dia=0.8)
|
||||||
|
|
||||||
|
if merged_valves:
|
||||||
|
params.valves = merged_valves
|
||||||
|
if merged_pipes:
|
||||||
|
params.pipes = merged_pipes
|
||||||
|
for p in merged_pipes:
|
||||||
|
if "M-301" in p.name or "도수관" in p.name:
|
||||||
|
params.main_conduit_diameter = max(p.diameter,
|
||||||
|
params.main_conduit_diameter)
|
||||||
|
params.main_conduit_el = p.elevation or params.main_conduit_el
|
||||||
|
|
||||||
|
# 슬라이드 뚜껑 INSERT 확인
|
||||||
|
for e in msp.query("INSERT"):
|
||||||
|
if "슬라이드" in getattr(e.dxf, "name", ""):
|
||||||
|
params.has_hatch = True
|
||||||
|
params.hatch_count = max(params.hatch_count, 1)
|
||||||
|
|
||||||
|
def _collect_el(self, msp, scale: float) -> list:
|
||||||
|
out = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxftype() not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txt = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "")
|
||||||
|
m = EL_PATTERN.search(txt)
|
||||||
|
if m:
|
||||||
|
pos = e.dxf.insert
|
||||||
|
out.append((pos.x * scale, pos.y * scale, float(m.group(1))))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _extract_from_mleaders(self, msp, plan_view, scale: float,
|
||||||
|
params: ValveChamberParams
|
||||||
|
) -> tuple[list[Valve], list[Pipe]]:
|
||||||
|
"""MLEADER(안내선)로 밸브/관로의 정확한 chamber-local 위치 추출.
|
||||||
|
|
||||||
|
- leader 끝점이 plan_view bounds 안이면 chamber-local 좌표로 변환
|
||||||
|
- 텍스트에서 M-NNN, "밸브"/"도수관"/"송수관"/"D=숫자" 추출
|
||||||
|
- destination-only pipe (예: "{천상정수장 D1,200}")도 출력 관로로 인식
|
||||||
|
"""
|
||||||
|
valves: list[Valve] = []
|
||||||
|
pipes: list[Pipe] = []
|
||||||
|
|
||||||
|
if plan_view:
|
||||||
|
cx = (plan_view.bounds[0] + plan_view.bounds[2]) / 2.0
|
||||||
|
cy = (plan_view.bounds[1] + plan_view.bounds[3]) / 2.0
|
||||||
|
x0, y0, x1, y1 = plan_view.bounds
|
||||||
|
else:
|
||||||
|
cx = cy = 0.0
|
||||||
|
x0 = y0 = -1e18; x1 = y1 = 1e18
|
||||||
|
|
||||||
|
for ml in msp.query("MULTILEADER MLEADER"):
|
||||||
|
txt = _mleader_text(ml)
|
||||||
|
end = _mleader_endpoint(ml)
|
||||||
|
if not txt or end is None:
|
||||||
|
continue
|
||||||
|
ex_m = end[0] * scale
|
||||||
|
ey_m = end[1] * scale
|
||||||
|
# plan view 밖이면 (예: side view, M-306 등) 건너뜀 — chamber 평면 배치엔 안 씀
|
||||||
|
if not (x0 <= ex_m <= x1 and y0 <= ey_m <= y1):
|
||||||
|
continue
|
||||||
|
local_x = ex_m - cx
|
||||||
|
local_y = ey_m - cy
|
||||||
|
|
||||||
|
# 텍스트 분류
|
||||||
|
m_match = re.search(r"(M-\d{3})", txt)
|
||||||
|
name = m_match.group(1) if m_match else ""
|
||||||
|
txt_clean = txt.replace("\n", " ").strip()
|
||||||
|
|
||||||
|
is_valve = "밸브" in txt_clean
|
||||||
|
is_pipe_kw = any(kw in txt_clean for kw in ("도수관", "송수관", "강재도관"))
|
||||||
|
# destination + D=숫자 → 출력 송수관
|
||||||
|
is_dest_pipe = (
|
||||||
|
("정수장" in txt_clean or "계통" in txt_clean or "유지용수" in txt_clean)
|
||||||
|
and re.search(r"D[\s,.]*\d", txt_clean) is not None
|
||||||
|
and not is_valve
|
||||||
|
)
|
||||||
|
|
||||||
|
# 직경 파싱 (D1,200 / D80 / Φ800 / D=800 / %%c800)
|
||||||
|
# D 다음에 optional 공백·=·콜론, 그 뒤 숫자(쉼표/마침표 천 단위 구분자 허용)
|
||||||
|
dia_m = re.search(r"(?:Φ|%%[cC]|\bD[\s=:]*)(\d[\d,.]*)", txt_clean)
|
||||||
|
diameter = None
|
||||||
|
if dia_m:
|
||||||
|
raw = dia_m.group(1).replace(",", "").rstrip(".")
|
||||||
|
try:
|
||||||
|
val = float(raw)
|
||||||
|
# mm 단위 가정 (val>=10), 그 이하는 m로 가정
|
||||||
|
diameter = val / 1000.0 if val >= 10 else val
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if is_valve:
|
||||||
|
# 타입 추정: 부밸브/주밸브에서 직접 못 가르므로 GATE 기본
|
||||||
|
vtype = "GATE"
|
||||||
|
for vt, kws in VALVE_TYPE_KEYWORDS.items():
|
||||||
|
if any(kw in txt_clean for kw in kws):
|
||||||
|
vtype = vt
|
||||||
|
break
|
||||||
|
v = Valve(
|
||||||
|
index=len(valves),
|
||||||
|
name=name or f"V?{len(valves)+1}",
|
||||||
|
valve_type=vtype,
|
||||||
|
center_x=local_x,
|
||||||
|
center_y=local_y,
|
||||||
|
elevation=params.bottom_el + 1.5,
|
||||||
|
diameter=diameter or 0.5,
|
||||||
|
label=txt_clean[:60],
|
||||||
|
)
|
||||||
|
valves.append(v)
|
||||||
|
elif is_pipe_kw or is_dest_pipe:
|
||||||
|
# 방향: 끝점이 chamber 중심 기준 어느 쪽에 가까운지로 판단
|
||||||
|
# 좌측 가까우면 -X 외부, 우측이면 +X 외부, 위/아래는 ±Y
|
||||||
|
half_w = params.chamber_width / 2.0
|
||||||
|
half_d = params.chamber_depth / 2.0
|
||||||
|
rx = abs(local_x) / max(half_w, 1e-6)
|
||||||
|
ry = abs(local_y) / max(half_d, 1e-6)
|
||||||
|
z = params.bottom_el + 1.5
|
||||||
|
if rx >= ry:
|
||||||
|
# X 방향: 좌측=상류 도수관(has_inlet_branch 시 _finalize가 분기 생성),
|
||||||
|
# 우측=하류 송수관 → 실측 길이 분리
|
||||||
|
sx = 1.0 if local_x >= 0 else -1.0
|
||||||
|
ext = (params.downstream_pipe_length if sx > 0
|
||||||
|
else params.upstream_pipe_length)
|
||||||
|
start = (local_x, local_y, z)
|
||||||
|
end_pt = (sx * (half_w + ext), local_y, z)
|
||||||
|
else:
|
||||||
|
sy = 1.0 if local_y >= 0 else -1.0
|
||||||
|
ext = max(3.0, params.external_pipe_length)
|
||||||
|
start = (local_x, local_y, z)
|
||||||
|
end_pt = (local_x, sy * (half_d + ext), z)
|
||||||
|
p = Pipe(
|
||||||
|
name=name or txt_clean[:30],
|
||||||
|
diameter=diameter or 0.8,
|
||||||
|
start=start,
|
||||||
|
end=end_pt,
|
||||||
|
elevation=z,
|
||||||
|
)
|
||||||
|
pipes.append(p)
|
||||||
|
return valves, pipes
|
||||||
|
|
||||||
|
def _extract_valves_and_pipes(self, msp, scale: float,
|
||||||
|
proximity_radius_m: float = 5.0
|
||||||
|
) -> tuple[list[Valve], list[Pipe]]:
|
||||||
|
"""TEXT/MTEXT에서 M-번호 밸브/관로 추출 (proximity grouping).
|
||||||
|
|
||||||
|
도면에 따라 라벨이 분리될 수 있는 4가지 케이스 모두 처리:
|
||||||
|
1) 단일 TEXT: "M-302 송수관 주밸브(1)" — 한 엔티티에 모든 정보
|
||||||
|
2) MTEXT 멀티라인: "{도수관\\PM-301}" — 같은 엔티티 두 줄 (\\P 분리)
|
||||||
|
3) 인접 TEXT 분리: "M-301" + "도수관"이 인접 좌표에 별개 엔티티
|
||||||
|
4) 라벨 + 외부 직경 텍스트: "M-302 주밸브" + "Φ800"이 근처
|
||||||
|
|
||||||
|
알고리즘:
|
||||||
|
- 모든 TEXT/MTEXT 정리(MTEXT 포맷 코드 strip, \\P→\\n)
|
||||||
|
- 각 엔티티의 TEXT를 (clean_txt, x, y)로 수집
|
||||||
|
- M-NNN을 포함하는 엔티티마다 proximity_radius_m 내 다른 엔티티 텍스트를
|
||||||
|
병합해 full_label 구성 (단, 다른 M-NNN 엔티티는 제외)
|
||||||
|
- full_label에서 분류·직경 추출
|
||||||
|
"""
|
||||||
|
valves: list[Valve] = []
|
||||||
|
pipes: list[Pipe] = []
|
||||||
|
|
||||||
|
# 1) 모든 텍스트 엔티티 수집 (정리된 텍스트 + 위치)
|
||||||
|
items: list[tuple[str, float, float]] = []
|
||||||
|
for e in msp:
|
||||||
|
if e.dxftype() not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "")
|
||||||
|
txt = _clean_mtext(raw) if e.dxftype() == "MTEXT" else raw.strip()
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
pos = e.dxf.insert
|
||||||
|
items.append((txt, pos.x * scale, pos.y * scale))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2) M-NNN을 가진 인덱스 찾기 (re.search로 any-position 매칭, 케이스 1+2 커버)
|
||||||
|
m_indices: list[tuple[int, str, float, float, str]] = [] # (idx, name, x, y, txt)
|
||||||
|
for i, (txt, x, y) in enumerate(items):
|
||||||
|
m_match = re.search(r"\b(M-\d{3})\b", txt)
|
||||||
|
if m_match:
|
||||||
|
m_indices.append((i, m_match.group(1), x, y, txt))
|
||||||
|
|
||||||
|
# 3) 각 M-NNN을 분류. 우선 own_txt만으로 시도 → 모호하면 proximity로 보강.
|
||||||
|
m_idx_set = {idx for idx, *_ in m_indices}
|
||||||
|
|
||||||
|
def _classify(text: str) -> tuple[bool, bool, bool]:
|
||||||
|
is_p = any(kw in text for kw in
|
||||||
|
("도수관", "송수관", "강재도관", "pipe", "Pipe"))
|
||||||
|
is_v = any(kw in text for kw in ("밸브", "valve", "Valve"))
|
||||||
|
is_dest = (
|
||||||
|
("정수장" in text or "계통" in text or "유지용수" in text)
|
||||||
|
and re.search(r"D[\s,.=]*\d", text) is not None
|
||||||
|
and not is_v
|
||||||
|
)
|
||||||
|
return is_p, is_v, is_dest
|
||||||
|
|
||||||
|
def _parse_dia(text: str) -> float | None:
|
||||||
|
m = re.search(r"(?:Φ|%%[cC]|\bD[\s=:]*)(\d[\d,.]*)", text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
raw_d = m.group(1).replace(",", "").rstrip(".")
|
||||||
|
try:
|
||||||
|
val = float(raw_d)
|
||||||
|
return val / 1000.0 if val >= 10 else val
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for j, (idx, name, lx, ly, own_txt) in enumerate(m_indices):
|
||||||
|
# 1차: own_txt만으로 분류
|
||||||
|
is_pipe_kw, is_valve, is_dest_pipe = _classify(own_txt)
|
||||||
|
diameter = _parse_dia(own_txt)
|
||||||
|
full_txt = own_txt
|
||||||
|
|
||||||
|
# 2차: own에 분류·직경 정보가 부족할 때만 proximity로 보강
|
||||||
|
need_class = not (is_pipe_kw or is_valve or is_dest_pipe)
|
||||||
|
need_dia = diameter is None
|
||||||
|
if need_class or need_dia:
|
||||||
|
near_parts: list[str] = []
|
||||||
|
r2 = proximity_radius_m * proximity_radius_m
|
||||||
|
for k, (txt2, x2, y2) in enumerate(items):
|
||||||
|
if k == idx or k in m_idx_set:
|
||||||
|
continue
|
||||||
|
d2 = (x2 - lx) ** 2 + (y2 - ly) ** 2
|
||||||
|
if d2 <= r2:
|
||||||
|
near_parts.append(txt2)
|
||||||
|
if near_parts:
|
||||||
|
near_txt = " ".join(near_parts).replace("\n", " ").strip()
|
||||||
|
if need_class:
|
||||||
|
np_pipe, np_valve, np_dest = _classify(near_txt)
|
||||||
|
is_pipe_kw = is_pipe_kw or np_pipe
|
||||||
|
is_valve = is_valve or np_valve
|
||||||
|
is_dest_pipe = is_dest_pipe or np_dest
|
||||||
|
if need_dia:
|
||||||
|
diameter = _parse_dia(near_txt)
|
||||||
|
full_txt = (own_txt + " | " + near_txt).strip()
|
||||||
|
|
||||||
|
if is_valve:
|
||||||
|
vtype = "GATE"
|
||||||
|
for vt, kws in VALVE_TYPE_KEYWORDS.items():
|
||||||
|
if any(kw in full_txt for kw in kws):
|
||||||
|
vtype = vt
|
||||||
|
break
|
||||||
|
valves.append(Valve(
|
||||||
|
index=j,
|
||||||
|
name=name,
|
||||||
|
valve_type=vtype,
|
||||||
|
center_x=0,
|
||||||
|
center_y=0,
|
||||||
|
elevation=0,
|
||||||
|
diameter=diameter or (0.5 if vtype == "BUTTERFLY" else 0.4),
|
||||||
|
label=full_txt[:60],
|
||||||
|
))
|
||||||
|
elif is_pipe_kw or is_dest_pipe:
|
||||||
|
pipe = Pipe(name=name)
|
||||||
|
pipe.diameter = diameter if diameter is not None else 0.8
|
||||||
|
pipes.append(pipe)
|
||||||
|
# 밸브도 파이프도 아니면 스킵 (펌프/사다리/덮개 등 비처리 항목)
|
||||||
|
|
||||||
|
return valves, pipes
|
||||||
|
|
||||||
|
def _finalize(self, params: ValveChamberParams):
|
||||||
|
"""파라미터 정리. MLEADER에서 정확한 위치를 못 얻은 항목만 폴백 배치.
|
||||||
|
|
||||||
|
상류 도수관(M-301)은 `has_inlet_branch=True`일 때 **분기 구조**로 교체:
|
||||||
|
단일 trunk → Y-branch(경사 2개) → 상단·하단 평행 관(chamber 진입).
|
||||||
|
도면(신설 제수변실.dxf) 실측: branch_spread ≈ 4.4m, 우측 하류 관로 ≈ 3.6m.
|
||||||
|
"""
|
||||||
|
# 위치(center_x,y) 또는 (start,end)가 (0,0,0)인 것만 자동 배치
|
||||||
|
if params.valves:
|
||||||
|
unset = [v for v in params.valves if v.center_x == 0 and v.center_y == 0]
|
||||||
|
n = len(unset)
|
||||||
|
for i, v in enumerate(unset):
|
||||||
|
t = (i + 0.5) / max(n, 1) - 0.5
|
||||||
|
v.center_x = t * params.chamber_width * 0.7
|
||||||
|
v.center_y = 0.0
|
||||||
|
v.elevation = params.bottom_el + 2.0
|
||||||
|
|
||||||
|
# --- 상류 도수관 분기 생성 / 단일 폴백 ---
|
||||||
|
if params.pipes:
|
||||||
|
main_idx = [i for i, p in enumerate(params.pipes)
|
||||||
|
if "도수관" in p.name or p.name == "M-301"]
|
||||||
|
main_pipes = [params.pipes[i] for i in main_idx]
|
||||||
|
orig_main = main_pipes[0] if main_pipes else None
|
||||||
|
# 기존 도수관 항목 제거 (분기든 단일이든 새로 생성)
|
||||||
|
if main_idx:
|
||||||
|
params.pipes = [p for j, p in enumerate(params.pipes) if j not in main_idx]
|
||||||
|
|
||||||
|
dia = orig_main.diameter if orig_main else params.main_conduit_diameter
|
||||||
|
z = (orig_main.elevation if orig_main and orig_main.elevation
|
||||||
|
else params.main_conduit_el or (params.bottom_el + 1.0))
|
||||||
|
half_w = params.chamber_width / 2.0
|
||||||
|
|
||||||
|
if params.has_inlet_branch and orig_main is not None:
|
||||||
|
import math as _m
|
||||||
|
spread_half = params.branch_spread_m / 2.0
|
||||||
|
angle = _m.radians(max(10.0, min(70.0, params.branch_angle_deg)))
|
||||||
|
# 분기 경사 길이: spread_half = L_branch * sin(angle) → L_branch = spread_half/sin
|
||||||
|
# X 진행: L_branch * cos(angle)
|
||||||
|
L_branch = spread_half / max(_m.sin(angle), 0.1)
|
||||||
|
dx_branch = L_branch * _m.cos(angle)
|
||||||
|
# chamber 좌측벽 X = -half_w
|
||||||
|
# 상단·하단 평행 관 길이 = upstream_pipe_length (분기점 이후 → chamber 벽)
|
||||||
|
x_branch_out = -half_w - 0.0 # 상단·하단이 chamber 벽에 도달하는 X
|
||||||
|
x_branch_in = x_branch_out - params.upstream_pipe_length # 분기가 합쳐지는 지점(X 방향 안쪽 끝)
|
||||||
|
x_merge = x_branch_in - dx_branch # Y축 0으로 모이는 분기점(상류측)
|
||||||
|
|
||||||
|
# 1) 상단 평행관 (chamber 좌측벽 → 분기 상단 끝)
|
||||||
|
params.pipes.append(Pipe(
|
||||||
|
name="M-301 상단",
|
||||||
|
diameter=dia,
|
||||||
|
start=(x_branch_in, spread_half, z),
|
||||||
|
end=(x_branch_out, spread_half, z),
|
||||||
|
elevation=z,
|
||||||
|
))
|
||||||
|
# 2) 하단 평행관
|
||||||
|
params.pipes.append(Pipe(
|
||||||
|
name="M-301 하단",
|
||||||
|
diameter=dia,
|
||||||
|
start=(x_branch_in, -spread_half, z),
|
||||||
|
end=(x_branch_out, -spread_half, z),
|
||||||
|
elevation=z,
|
||||||
|
))
|
||||||
|
# 3) 상단 경사 (분기점 → 상단 끝)
|
||||||
|
params.pipes.append(Pipe(
|
||||||
|
name="M-301 분기상경사",
|
||||||
|
diameter=dia,
|
||||||
|
start=(x_merge, 0.0, z),
|
||||||
|
end=(x_branch_in, spread_half, z),
|
||||||
|
elevation=z,
|
||||||
|
))
|
||||||
|
# 4) 하단 경사
|
||||||
|
params.pipes.append(Pipe(
|
||||||
|
name="M-301 분기하경사",
|
||||||
|
diameter=dia,
|
||||||
|
start=(x_merge, 0.0, z),
|
||||||
|
end=(x_branch_in, -spread_half, z),
|
||||||
|
elevation=z,
|
||||||
|
))
|
||||||
|
# 5) 단일 trunk (분기점 → 상류로 trunk_length)
|
||||||
|
params.pipes.append(Pipe(
|
||||||
|
name="M-301 도수관",
|
||||||
|
diameter=dia,
|
||||||
|
start=(x_merge - max(0.5, params.branch_trunk_length), 0.0, z),
|
||||||
|
end=(x_merge, 0.0, z),
|
||||||
|
elevation=z,
|
||||||
|
))
|
||||||
|
elif orig_main is not None and orig_main.start != (0.0, 0.0, 0.0):
|
||||||
|
# 분기 비활성 + 원본 pipe가 명시적 경로를 가졌을 때만 유지.
|
||||||
|
# (start/end가 origin인 seed는 폐기 — 도면에 없는 관로가 만들어지지 않게)
|
||||||
|
params.pipes.append(orig_main)
|
||||||
|
|
||||||
|
# 나머지 origin-only pipe는 방향별 폴백
|
||||||
|
for p in params.pipes:
|
||||||
|
if not (p.start == (0.0, 0.0, 0.0) and p.end == (0.0, 0.0, 0.0)):
|
||||||
|
continue
|
||||||
|
half_d = params.chamber_depth / 2 + params.external_pipe_length
|
||||||
|
z2 = params.bottom_el + 1.5
|
||||||
|
p.start = (0, -half_d, z2)
|
||||||
|
p.end = (0, half_d, z2)
|
||||||
|
p.elevation = z2
|
||||||
|
|
||||||
|
|
||||||
|
def parse_valve_chamber(paths: list[str]) -> ValveChamberParams:
|
||||||
|
return ValveChamberParser().parse(paths)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
paths = sys.argv[1:] if len(sys.argv) > 1 else [
|
||||||
|
"SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf",
|
||||||
|
]
|
||||||
|
p = parse_valve_chamber(paths)
|
||||||
|
print(p.summary())
|
||||||
|
print("\n밸브:")
|
||||||
|
for v in p.valves:
|
||||||
|
print(f" {v.name} [{VALVE_TYPES.get(v.valve_type, '?')}] {v.label}")
|
||||||
|
print("\n관로:")
|
||||||
|
for pipe in p.pipes:
|
||||||
|
print(f" {pipe.name} Φ{pipe.diameter*1000:.0f}mm")
|
||||||
|
print(f"\n바닥 EL: {p.floor_elevations}")
|
||||||
515
view_detector.py
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
"""DXF 도면에서 뷰 영역(평면도/정면도/측면도/단면도) 자동 검출.
|
||||||
|
|
||||||
|
작동 원리:
|
||||||
|
1. DXF의 TEXT/MTEXT에서 뷰 라벨 패턴 매칭 ("평면도", "정면도" 등)
|
||||||
|
2. 라벨 주변의 사각형 프레임(closed LWPOLYLINE 4점) 검출
|
||||||
|
3. 라벨-사각형 매칭 (라벨이 사각형 안 또는 바로 위/아래)
|
||||||
|
4. 각 사각형 안의 지오메트리를 해당 뷰에 할당
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
from view_detector import detect_view_regions
|
||||||
|
views = detect_view_regions("plan.dxf")
|
||||||
|
for v in views:
|
||||||
|
print(f"{v.view_type}: {v.label_text} ({len(v.shapes)} shapes)")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
|
||||||
|
from dxf_geometry import (
|
||||||
|
extract_structural_geometry,
|
||||||
|
GeometryResult,
|
||||||
|
Shape,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 뷰 타입 라벨 패턴
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VIEW_LABEL_PATTERNS = {
|
||||||
|
"plan": [
|
||||||
|
r"평\s*면\s*도",
|
||||||
|
r"平\s*面\s*[圖図]",
|
||||||
|
r"\bPLAN\s*VIEW\b",
|
||||||
|
r"^PLAN$",
|
||||||
|
r"\bTOP\s*VIEW\b",
|
||||||
|
],
|
||||||
|
"front": [
|
||||||
|
r"정\s*면\s*도",
|
||||||
|
r"正\s*面\s*[圖図]",
|
||||||
|
r"\bFRONT\s*(?:VIEW|ELEVATION)\b",
|
||||||
|
r"\bELEVATION\b(?!\s*\.)", # "ELEVATION" but not "EL."
|
||||||
|
],
|
||||||
|
"side": [
|
||||||
|
r"측\s*면\s*도",
|
||||||
|
r"側\s*面\s*[圖図]",
|
||||||
|
r"\bSIDE\s*(?:VIEW|ELEVATION)\b",
|
||||||
|
],
|
||||||
|
"rear": [
|
||||||
|
r"배\s*면\s*도",
|
||||||
|
r"背\s*面\s*[圖図]",
|
||||||
|
r"\bREAR\s*VIEW\b",
|
||||||
|
r"\bBACK\s*VIEW\b",
|
||||||
|
],
|
||||||
|
"bottom": [
|
||||||
|
r"저\s*면\s*도",
|
||||||
|
r"底\s*面\s*[圖図]",
|
||||||
|
r"\bBOTTOM\s*VIEW\b",
|
||||||
|
],
|
||||||
|
"section": [
|
||||||
|
r"단\s*면\s*도",
|
||||||
|
r"斷\s*面\s*[圖図]",
|
||||||
|
r"\bSECTION\b",
|
||||||
|
r"\bSEC\.\s*[A-Z]",
|
||||||
|
r"[A-Z]\s*-\s*[A-Z]\s*단면",
|
||||||
|
],
|
||||||
|
"detail": [
|
||||||
|
r"상\s*세\s*도",
|
||||||
|
r"詳\s*細\s*[圖図]",
|
||||||
|
r"\bDETAIL\b",
|
||||||
|
],
|
||||||
|
"elevation_generic": [
|
||||||
|
r"입\s*면\s*도",
|
||||||
|
r"立\s*面\s*[圖図]",
|
||||||
|
],
|
||||||
|
"longitudinal": [
|
||||||
|
r"종\s*단\s*면\s*도",
|
||||||
|
r"종\s*단\s*면",
|
||||||
|
r"\bLONGITUDINAL\b",
|
||||||
|
],
|
||||||
|
"cross_section": [
|
||||||
|
r"횡\s*단\s*면\s*도",
|
||||||
|
r"횡\s*단\s*면",
|
||||||
|
r"\bCROSS\s*SECTION\b",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 축척(S=1:N) 패턴
|
||||||
|
SCALE_PATTERN = re.compile(r"S\s*=?\s*1\s*[::]\s*(\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 구조
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ViewRegion:
|
||||||
|
"""검출된 뷰 영역 하나."""
|
||||||
|
view_type: str # "plan" | "front" | "side" | ...
|
||||||
|
label_text: str # 원문 라벨
|
||||||
|
label_pos: tuple # (x, y) in m
|
||||||
|
bounds: tuple # (xmin, ymin, xmax, ymax) in m
|
||||||
|
shapes: list # 이 뷰 안의 Shape 목록
|
||||||
|
scale_hint: str = "" # "1:100" 등
|
||||||
|
scale_value: int | None = None # 100 (축척 분모)
|
||||||
|
has_frame: bool = True # 사각형 프레임이 명시적으로 있는지
|
||||||
|
|
||||||
|
@property
|
||||||
|
def view_type_ko(self) -> str:
|
||||||
|
mapping = {
|
||||||
|
"plan": "평면도", "front": "정면도", "side": "측면도",
|
||||||
|
"rear": "배면도", "bottom": "저면도", "section": "단면도",
|
||||||
|
"detail": "상세도", "elevation_generic": "입면도",
|
||||||
|
"longitudinal": "종단면도", "cross_section": "횡단면도",
|
||||||
|
}
|
||||||
|
return mapping.get(self.view_type, self.view_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> float:
|
||||||
|
return self.bounds[2] - self.bounds[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> float:
|
||||||
|
return self.bounds[3] - self.bounds[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center(self) -> tuple:
|
||||||
|
return ((self.bounds[0] + self.bounds[2]) / 2,
|
||||||
|
(self.bounds[1] + self.bounds[3]) / 2)
|
||||||
|
|
||||||
|
def get_local_shapes(self) -> list:
|
||||||
|
"""뷰 내 지오메트리를 뷰 좌표(bbox 좌하단을 원점)로 변환한 복사본."""
|
||||||
|
ox, oy = self.bounds[0], self.bounds[1]
|
||||||
|
out = []
|
||||||
|
for s in self.shapes:
|
||||||
|
new_pts = [(p[0] - ox, p[1] - oy) for p in s.points]
|
||||||
|
new_shape = Shape(
|
||||||
|
kind=s.kind, layer=s.layer, points=new_pts,
|
||||||
|
closed=s.closed, extra=dict(s.extra),
|
||||||
|
)
|
||||||
|
out.append(new_shape)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 라벨 검출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def classify_view_label(text: str) -> str | None:
|
||||||
|
"""텍스트가 뷰 라벨인지 확인하고 타입 반환."""
|
||||||
|
# 매우 긴 텍스트는 라벨이 아닐 가능성 (NOTES 등)
|
||||||
|
if len(text) > 30:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for view_type, patterns in VIEW_LABEL_PATTERNS.items():
|
||||||
|
for pat in patterns:
|
||||||
|
if re.search(pat, text, re.IGNORECASE):
|
||||||
|
return view_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_scale(text: str) -> tuple[str, int | None]:
|
||||||
|
"""텍스트에서 축척 추출 (e.g., 'S=1:100')."""
|
||||||
|
m = SCALE_PATTERN.search(text)
|
||||||
|
if m:
|
||||||
|
return f"1:{m.group(1)}", int(m.group(1))
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_view_labels(dxf_path: str, unit_scale: float) -> list[dict]:
|
||||||
|
"""DXF의 TEXT/MTEXT 중 뷰 라벨을 모두 수집.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"text": str, "view_type": str, "pos": (x, y),
|
||||||
|
"scale_hint": str, "scale_value": int}, ...]
|
||||||
|
"""
|
||||||
|
doc = ezdxf.readfile(dxf_path)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
labels = []
|
||||||
|
# 축척이 라벨 바로 아래에 따로 배치된 경우 대비해서 모든 TEXT 저장
|
||||||
|
all_texts = []
|
||||||
|
|
||||||
|
for e in msp:
|
||||||
|
et = e.dxftype()
|
||||||
|
if et not in ("TEXT", "MTEXT"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txt = e.dxf.text if et == "TEXT" else (e.text or "")
|
||||||
|
txt = txt.strip()
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
pos = e.dxf.insert
|
||||||
|
x = pos.x * unit_scale
|
||||||
|
y = pos.y * unit_scale
|
||||||
|
all_texts.append({"text": txt, "pos": (x, y)})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in all_texts:
|
||||||
|
txt = item["text"]
|
||||||
|
view_type = classify_view_label(txt)
|
||||||
|
if view_type is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 축척: 라벨 텍스트 안에서 찾기
|
||||||
|
scale_hint, scale_val = extract_scale(txt)
|
||||||
|
|
||||||
|
# 축척이 없으면 근처 텍스트에서 찾기 (30m 이내 아래쪽)
|
||||||
|
if not scale_hint:
|
||||||
|
lx, ly = item["pos"]
|
||||||
|
for other in all_texts:
|
||||||
|
ox, oy = other["pos"]
|
||||||
|
if abs(ox - lx) < 15.0 and -15.0 < (oy - ly) < -0.5:
|
||||||
|
# 라벨 바로 아래 15m 이내
|
||||||
|
sh, sv = extract_scale(other["text"])
|
||||||
|
if sh:
|
||||||
|
scale_hint, scale_val = sh, sv
|
||||||
|
break
|
||||||
|
|
||||||
|
labels.append({
|
||||||
|
"text": txt,
|
||||||
|
"view_type": view_type,
|
||||||
|
"pos": item["pos"],
|
||||||
|
"scale_hint": scale_hint,
|
||||||
|
"scale_value": scale_val,
|
||||||
|
})
|
||||||
|
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 사각형 프레임 검출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_rectangle(shape: Shape, tolerance: float = 0.08) -> bool:
|
||||||
|
"""Shape가 축정렬 사각형인지 확인."""
|
||||||
|
if shape.kind != "polyline" or not shape.closed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pts = shape.points
|
||||||
|
# 끝점 중복 제거
|
||||||
|
if len(pts) >= 2 and abs(pts[0][0] - pts[-1][0]) < 1e-6 and abs(pts[0][1] - pts[-1][1]) < 1e-6:
|
||||||
|
pts = pts[:-1]
|
||||||
|
|
||||||
|
if len(pts) != 4:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 각 엣지가 수평 또는 수직에 가까운지
|
||||||
|
for i in range(4):
|
||||||
|
p1 = pts[i]
|
||||||
|
p2 = pts[(i + 1) % 4]
|
||||||
|
dx = abs(p2[0] - p1[0])
|
||||||
|
dy = abs(p2[1] - p1[1])
|
||||||
|
seg_len = max(dx, dy, 1e-6)
|
||||||
|
if not (dx / seg_len < tolerance or dy / seg_len < tolerance):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def detect_rectangles(geom: GeometryResult,
|
||||||
|
min_area: float = 10.0,
|
||||||
|
max_area_ratio: float = 0.9) -> list[Shape]:
|
||||||
|
"""GeometryResult에서 축정렬 사각형들을 검출.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_area: 최소 면적 (m²) — 너무 작은 건 무시
|
||||||
|
max_area_ratio: 전체 bbox 대비 최대 면적 비율 — 제목블록/시트경계 제외
|
||||||
|
"""
|
||||||
|
if not geom.shapes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
total_area = max(
|
||||||
|
(geom.total_bounds[2] - geom.total_bounds[0]) *
|
||||||
|
(geom.total_bounds[3] - geom.total_bounds[1]),
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
max_area = total_area * max_area_ratio
|
||||||
|
|
||||||
|
rectangles = []
|
||||||
|
for s in geom.closed_shapes:
|
||||||
|
if not is_rectangle(s):
|
||||||
|
continue
|
||||||
|
if s.area < min_area or s.area > max_area:
|
||||||
|
continue
|
||||||
|
rectangles.append(s)
|
||||||
|
|
||||||
|
return rectangles
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 라벨 ↔ 사각형 매칭
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _point_in_bbox(pt: tuple, bbox: tuple, margin: float = 0.0) -> bool:
|
||||||
|
return (bbox[0] - margin <= pt[0] <= bbox[2] + margin and
|
||||||
|
bbox[1] - margin <= pt[1] <= bbox[3] + margin)
|
||||||
|
|
||||||
|
|
||||||
|
def _dist_point_to_bbox(pt: tuple, bbox: tuple) -> float:
|
||||||
|
"""점과 bbox 사이의 최소 거리."""
|
||||||
|
dx = max(bbox[0] - pt[0], 0, pt[0] - bbox[2])
|
||||||
|
dy = max(bbox[1] - pt[1], 0, pt[1] - bbox[3])
|
||||||
|
return math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
|
||||||
|
def match_label_to_rectangle(label_pos: tuple, rectangles: list[Shape],
|
||||||
|
max_distance: float = 30.0) -> Shape | None:
|
||||||
|
"""라벨 위치에 가장 적합한 사각형을 찾기.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. 라벨이 사각형 안쪽
|
||||||
|
2. 라벨이 사각형 바로 아래 (한국 토목도면 관례: 그림 아래 제목)
|
||||||
|
3. 가장 가까운 사각형 (max_distance 이내)
|
||||||
|
"""
|
||||||
|
# 1) 내부
|
||||||
|
inside = [r for r in rectangles if _point_in_bbox(label_pos, r.bbox)]
|
||||||
|
if inside:
|
||||||
|
return min(inside, key=lambda r: r.area) # 가장 작은 것 (내부 중 중첩된 경우)
|
||||||
|
|
||||||
|
# 2) 바로 아래 (라벨 Y < 사각형 Y_min)
|
||||||
|
below_candidates = []
|
||||||
|
for r in rectangles:
|
||||||
|
b = r.bbox
|
||||||
|
dy = b[1] - label_pos[1] # 사각형 아래쪽 가장자리 - 라벨 Y
|
||||||
|
# 라벨이 사각형 아래에 있고, X 범위 안에 있음
|
||||||
|
if -2.0 < dy < max_distance and b[0] - 5 < label_pos[0] < b[2] + 5:
|
||||||
|
below_candidates.append((dy, r))
|
||||||
|
if below_candidates:
|
||||||
|
below_candidates.sort(key=lambda x: x[0])
|
||||||
|
return below_candidates[0][1]
|
||||||
|
|
||||||
|
# 3) 바로 위 (뒷산 관례 등)
|
||||||
|
above_candidates = []
|
||||||
|
for r in rectangles:
|
||||||
|
b = r.bbox
|
||||||
|
dy = label_pos[1] - b[3] # 라벨 Y - 사각형 위쪽 가장자리
|
||||||
|
if -2.0 < dy < max_distance and b[0] - 5 < label_pos[0] < b[2] + 5:
|
||||||
|
above_candidates.append((dy, r))
|
||||||
|
if above_candidates:
|
||||||
|
above_candidates.sort(key=lambda x: x[0])
|
||||||
|
return above_candidates[0][1]
|
||||||
|
|
||||||
|
# 4) 가장 가까운 것 (폴백)
|
||||||
|
dists = [(_dist_point_to_bbox(label_pos, r.bbox), r) for r in rectangles]
|
||||||
|
dists = [(d, r) for d, r in dists if d < max_distance]
|
||||||
|
if dists:
|
||||||
|
dists.sort(key=lambda x: x[0])
|
||||||
|
return dists[0][1]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 라벨만 있는 경우: 라벨 주변 영역 추정 (프레임 없는 경우)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def estimate_region_without_frame(label_pos: tuple, all_labels: list[dict],
|
||||||
|
geom_bounds: tuple) -> tuple:
|
||||||
|
"""프레임 없이 라벨만 있을 때, 라벨 주변의 합리적 영역을 추정.
|
||||||
|
|
||||||
|
다른 라벨들의 위치를 경계로 사용.
|
||||||
|
"""
|
||||||
|
lx, ly = label_pos
|
||||||
|
|
||||||
|
# 현재 라벨과 다른 모든 라벨의 위치
|
||||||
|
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 기준 (default_w/h 사용 안 함 — 라벨 간 분할만 적용)
|
||||||
|
total_w = geom_bounds[2] - geom_bounds[0]
|
||||||
|
total_h = geom_bounds[3] - geom_bounds[1]
|
||||||
|
|
||||||
|
# 이웃 라벨과의 중간 지점까지를 경계로
|
||||||
|
x_min = geom_bounds[0]
|
||||||
|
x_max = geom_bounds[2]
|
||||||
|
y_min = geom_bounds[1]
|
||||||
|
y_max = geom_bounds[3]
|
||||||
|
|
||||||
|
for ox, oy in others:
|
||||||
|
# 좌우
|
||||||
|
if abs(oy - ly) < total_h * 0.3:
|
||||||
|
if ox < lx and (lx + ox) / 2 > x_min:
|
||||||
|
x_min = (lx + ox) / 2
|
||||||
|
elif ox > lx and (lx + ox) / 2 < x_max:
|
||||||
|
x_max = (lx + ox) / 2
|
||||||
|
# 상하
|
||||||
|
if abs(ox - lx) < total_w * 0.3:
|
||||||
|
if oy < ly and (ly + oy) / 2 > y_min:
|
||||||
|
y_min = (ly + oy) / 2
|
||||||
|
elif oy > ly and (ly + oy) / 2 < y_max:
|
||||||
|
y_max = (ly + oy) / 2
|
||||||
|
|
||||||
|
# 라벨 자체는 영역 아래쪽이라 가정 (라벨 위가 뷰)
|
||||||
|
return (x_min, y_min, x_max, y_max)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메인 검출 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def detect_view_regions(dxf_path: str) -> list[ViewRegion]:
|
||||||
|
"""DXF에서 뷰 영역들을 검출.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ViewRegion 목록 (검출 순서 = 뷰 타입 우선순위)
|
||||||
|
"""
|
||||||
|
# 지오메트리 + 단위 추출
|
||||||
|
geom = extract_structural_geometry(dxf_path)
|
||||||
|
|
||||||
|
# 라벨 수집
|
||||||
|
labels = collect_view_labels(dxf_path, geom.unit_scale)
|
||||||
|
|
||||||
|
if not labels:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 사각형 검출
|
||||||
|
rectangles = detect_rectangles(geom)
|
||||||
|
|
||||||
|
# 각 라벨을 사각형에 매칭
|
||||||
|
regions = []
|
||||||
|
used_rectangles = set()
|
||||||
|
|
||||||
|
for lbl in labels:
|
||||||
|
rect = match_label_to_rectangle(lbl["pos"], rectangles)
|
||||||
|
|
||||||
|
if rect is not None and id(rect) not in used_rectangles:
|
||||||
|
# 프레임 기반 영역
|
||||||
|
used_rectangles.add(id(rect))
|
||||||
|
bounds = rect.bbox
|
||||||
|
has_frame = True
|
||||||
|
else:
|
||||||
|
# 프레임 없음 → 주변 추정
|
||||||
|
bounds = estimate_region_without_frame(
|
||||||
|
lbl["pos"], labels, geom.total_bounds
|
||||||
|
)
|
||||||
|
has_frame = False
|
||||||
|
|
||||||
|
# 해당 영역 안의 지오메트리 수집 (프레임 자체는 제외)
|
||||||
|
inside_shapes = []
|
||||||
|
for s in geom.shapes:
|
||||||
|
if rect is not None and s is rect:
|
||||||
|
continue # 프레임 자체 제외
|
||||||
|
# Shape bbox 중심이 영역 안에 있으면 포함
|
||||||
|
cx = (s.bbox[0] + s.bbox[2]) / 2
|
||||||
|
cy = (s.bbox[1] + s.bbox[3]) / 2
|
||||||
|
if _point_in_bbox((cx, cy), bounds, margin=1.0):
|
||||||
|
inside_shapes.append(s)
|
||||||
|
|
||||||
|
regions.append(ViewRegion(
|
||||||
|
view_type=lbl["view_type"],
|
||||||
|
label_text=lbl["text"],
|
||||||
|
label_pos=lbl["pos"],
|
||||||
|
bounds=bounds,
|
||||||
|
shapes=inside_shapes,
|
||||||
|
scale_hint=lbl["scale_hint"],
|
||||||
|
scale_value=lbl["scale_value"],
|
||||||
|
has_frame=has_frame,
|
||||||
|
))
|
||||||
|
|
||||||
|
return regions
|
||||||
|
|
||||||
|
|
||||||
|
def detect_views_multi(dxf_paths: list[str]) -> list[ViewRegion]:
|
||||||
|
"""여러 DXF의 뷰를 모두 검출."""
|
||||||
|
all_views = []
|
||||||
|
for p in dxf_paths:
|
||||||
|
try:
|
||||||
|
views = detect_view_regions(p)
|
||||||
|
for v in views:
|
||||||
|
v.label_text = f"[{Path(p).stem[:20]}] {v.label_text}"
|
||||||
|
all_views.extend(views)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 뷰 검출 실패 ({p}): {e}")
|
||||||
|
return all_views
|
||||||
|
|
||||||
|
|
||||||
|
def get_view_by_type(views: list[ViewRegion], view_type: str) -> ViewRegion | None:
|
||||||
|
"""타입으로 뷰 검색. 같은 타입이 여러 개면 첫 번째."""
|
||||||
|
for v in views:
|
||||||
|
if v.view_type == view_type:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 테스트
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import glob
|
||||||
|
|
||||||
|
samples = sorted(glob.glob("SAMPLE_CAD/*.dxf"))
|
||||||
|
for p in samples:
|
||||||
|
print(f"\n=== {Path(p).name} ===")
|
||||||
|
try:
|
||||||
|
views = detect_view_regions(p)
|
||||||
|
if not views:
|
||||||
|
print(" (뷰 라벨 검출 안 됨)")
|
||||||
|
continue
|
||||||
|
for v in views:
|
||||||
|
frame = "프레임" if v.has_frame else "추정"
|
||||||
|
scale = f" S={v.scale_hint}" if v.scale_hint else ""
|
||||||
|
print(f" [{v.view_type_ko}] \"{v.label_text[:40]}\" "
|
||||||
|
f"({frame}{scale}, {len(v.shapes)}개 shape, "
|
||||||
|
f"bbox {v.width:.1f}×{v.height:.1f}m)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 오류: {e}")
|
||||||
916
view_reconstructor.py
Normal file
@@ -0,0 +1,916 @@
|
|||||||
|
"""뷰 기반 통합 3D 재구성.
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
여러 뷰(평면도/정면도/측면도)는 같은 하나의 구조물을 다른 방향에서 본
|
||||||
|
서로 다른 투영(projection)이다. 따라서 N개의 뷰 → 1개의 통합 3D 구조물.
|
||||||
|
|
||||||
|
좌표계 매핑 (표준 토목 정투영):
|
||||||
|
┌──────────┬───────────┬───────────┬───────────────┐
|
||||||
|
│ 뷰 타입 │ 도면의 X │ 도면의 Y │ 3D 대응 (실물) │
|
||||||
|
├──────────┼───────────┼───────────┼───────────────┤
|
||||||
|
│ 평면도 │ 실물 X │ 실물 Y │ XY (위에서 봄) │
|
||||||
|
│ 정면도 │ 실물 X │ 실물 Z │ XZ (앞에서 봄) │
|
||||||
|
│ 측면도 │ 실물 Y │ 실물 Z │ YZ (옆에서 봄) │
|
||||||
|
│ 단면도 │ 실물 X/Y │ 실물 Z │ 단면 프로파일 │
|
||||||
|
└──────────┴───────────┴───────────┴───────────────┘
|
||||||
|
|
||||||
|
알고리즘:
|
||||||
|
1. 각 뷰 종류별로 가장 대표적인 뷰 1개씩 선택 (라벨/면적 기준)
|
||||||
|
2. 각 뷰에서 메인 실루엣(silhouette) 추출 (단일 폴리곤)
|
||||||
|
3. 실루엣들로부터 3D 구조물의 (X폭, Y깊이, Z높이) 결정
|
||||||
|
4. 가장 정보량이 많은 뷰를 base로 단일 메쉬 생성
|
||||||
|
- 평면도 있으면: 평면 외곽 → Z방향 extrude
|
||||||
|
- 정면만: 정면 외곽 → Y방향 extrude (얕은 두께)
|
||||||
|
- 측면만: 측면 외곽 → X방향 extrude
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
from view_detector import ViewRegion
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 템플릿별 색상 / 키워드
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEMPLATE_COLORS = {
|
||||||
|
"spillway_gate": "#B8B5A8",
|
||||||
|
"building": "#BDC3C7",
|
||||||
|
"retaining_wall": "#7F8C8D",
|
||||||
|
"bridge": "#95A5A6",
|
||||||
|
"tunnel_portal": "#A8A59B",
|
||||||
|
"generic": "#B8B5A8",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 템플릿별 키워드 (메인 뷰 식별용)
|
||||||
|
TEMPLATE_KEYWORDS = {
|
||||||
|
"spillway_gate": ["여수로", "수문", "spillway", "gate"],
|
||||||
|
"building": ["취수탑", "탑", "건물", "사무소", "관리", "tower", "building",
|
||||||
|
"제수변실", "valve"],
|
||||||
|
"retaining_wall": ["옹벽", "벽", "방벽", "wall"],
|
||||||
|
"bridge": ["교량", "교", "bridge", "공도교"],
|
||||||
|
"tunnel_portal": ["터널", "갱구", "tunnel", "portal"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 기본 헬퍼: 폴리곤 면적/길이/외곽 추출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _polygon_area(points: list) -> float:
|
||||||
|
"""Shoelace 면적 (closed polygon 가정)."""
|
||||||
|
if len(points) < 3:
|
||||||
|
return 0.0
|
||||||
|
n = len(points)
|
||||||
|
s = 0.0
|
||||||
|
for i in range(n):
|
||||||
|
x1, y1 = points[i][0], points[i][1]
|
||||||
|
x2, y2 = points[(i + 1) % n][0], points[(i + 1) % n][1]
|
||||||
|
s += x1 * y2 - x2 * y1
|
||||||
|
return abs(s) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def _polyline_length(points: list) -> float:
|
||||||
|
if len(points) < 2:
|
||||||
|
return 0.0
|
||||||
|
arr = np.array(points, dtype=np.float64)
|
||||||
|
diffs = np.diff(arr, axis=0)
|
||||||
|
return float(np.sum(np.linalg.norm(diffs, axis=1)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메인 뷰 선택 (여러 평면도 중 어떤 게 진짜 평면도?)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pick_main_view(views: list[ViewRegion], view_type: str,
|
||||||
|
template_id: str | None = None) -> ViewRegion | None:
|
||||||
|
"""주어진 타입의 뷰 중 가장 대표적인 것 선택.
|
||||||
|
|
||||||
|
선택 기준:
|
||||||
|
1. 해당 타입이 1개면 그것
|
||||||
|
2. 라벨이 템플릿 키워드를 포함하면 우선
|
||||||
|
3. 그 외엔 가장 큰 영역
|
||||||
|
"""
|
||||||
|
candidates = [v for v in views if v.view_type == view_type]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
if len(candidates) == 1:
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
# 키워드 매칭 우선
|
||||||
|
if template_id and template_id in TEMPLATE_KEYWORDS:
|
||||||
|
keywords = TEMPLATE_KEYWORDS[template_id]
|
||||||
|
for v in candidates:
|
||||||
|
label = v.label_text.lower()
|
||||||
|
for kw in keywords:
|
||||||
|
if kw.lower() in label:
|
||||||
|
return v
|
||||||
|
|
||||||
|
# 가장 큰 영역
|
||||||
|
return max(candidates, key=lambda v: v.width * v.height)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 뷰에서 메인 실루엣(silhouette) 추출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract_main_silhouette(view: ViewRegion) -> list | None:
|
||||||
|
"""뷰에서 구조물의 외곽선을 단일 폴리곤으로 추출.
|
||||||
|
|
||||||
|
전략 (실루엣 면적이 뷰 면적의 일정 비율 이상이어야 진짜 외곽으로 간주):
|
||||||
|
1. 충분히 큰 closed 폴리곤 (뷰 면적의 10% 이상) 중 최대
|
||||||
|
2. 그것도 없으면 작은 closed 폴리곤들의 합집합 bbox
|
||||||
|
3. convex hull (모든 점 기준) — 모든 LINE/ARC가 그리는 외곽
|
||||||
|
4. 위 모두 실패 시 뷰 영역 자체 사각형
|
||||||
|
|
||||||
|
반환은 로컬 좌표 (뷰 bbox 좌하단을 원점으로).
|
||||||
|
"""
|
||||||
|
local_shapes = view.get_local_shapes()
|
||||||
|
if not local_shapes:
|
||||||
|
return _view_rect_silhouette(view)
|
||||||
|
|
||||||
|
view_area = max(view.width * view.height, 1.0)
|
||||||
|
|
||||||
|
# === 1) 충분히 큰 closed 폴리곤 우선 ===
|
||||||
|
closed_candidates = []
|
||||||
|
for s in local_shapes:
|
||||||
|
if not s.closed or len(s.points) < 3:
|
||||||
|
continue
|
||||||
|
a = _polygon_area(s.points)
|
||||||
|
# 뷰 면적의 10% 이상 + 95% 이하 (프레임 잔재 제외)
|
||||||
|
if view_area * 0.10 <= a <= view_area * 0.95:
|
||||||
|
closed_candidates.append((a, s.points))
|
||||||
|
|
||||||
|
if closed_candidates:
|
||||||
|
closed_candidates.sort(key=lambda x: -x[0])
|
||||||
|
return closed_candidates[0][1]
|
||||||
|
|
||||||
|
# === 2) Convex hull 폴백: 모든 LINE/ARC/POLYLINE 점들의 외곽 ===
|
||||||
|
# 노이즈를 줄이기 위해 너무 작은 shape는 제외
|
||||||
|
significant_shapes = []
|
||||||
|
for s in local_shapes:
|
||||||
|
if s.kind in ("polyline", "line", "arc", "circle"):
|
||||||
|
# 길이/면적이 충분히 큰 것만
|
||||||
|
if s.closed:
|
||||||
|
if _polygon_area(s.points) >= view_area * 0.001:
|
||||||
|
significant_shapes.append(s)
|
||||||
|
elif _polyline_length(s.points) >= max(view.width, view.height) * 0.05:
|
||||||
|
significant_shapes.append(s)
|
||||||
|
|
||||||
|
if significant_shapes:
|
||||||
|
try:
|
||||||
|
from scipy.spatial import ConvexHull
|
||||||
|
all_pts = []
|
||||||
|
for s in significant_shapes:
|
||||||
|
all_pts.extend(s.points)
|
||||||
|
if len(all_pts) >= 3:
|
||||||
|
arr = np.array(all_pts)
|
||||||
|
# 중복 제거
|
||||||
|
arr = np.unique(arr, axis=0)
|
||||||
|
if len(arr) >= 3:
|
||||||
|
hull = ConvexHull(arr)
|
||||||
|
hull_pts = [tuple(arr[i]) for i in hull.vertices]
|
||||||
|
# Hull이 너무 작으면 뷰 사각형 사용
|
||||||
|
hull_area = _polygon_area(hull_pts)
|
||||||
|
if hull_area >= view_area * 0.10:
|
||||||
|
return hull_pts
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# === 3) 작은 closed들의 union bbox (마지막 수단 전 단계) ===
|
||||||
|
small_closed = [s.points for s in local_shapes
|
||||||
|
if s.closed and len(s.points) >= 3
|
||||||
|
and _polygon_area(s.points) >= view_area * 0.001]
|
||||||
|
if small_closed:
|
||||||
|
# 모든 작은 closed의 점들을 모아 hull
|
||||||
|
try:
|
||||||
|
from scipy.spatial import ConvexHull
|
||||||
|
all_pts = []
|
||||||
|
for pts in small_closed:
|
||||||
|
all_pts.extend(pts)
|
||||||
|
if len(all_pts) >= 3:
|
||||||
|
arr = np.array(all_pts)
|
||||||
|
arr = np.unique(arr, axis=0)
|
||||||
|
if len(arr) >= 3:
|
||||||
|
hull = ConvexHull(arr)
|
||||||
|
return [tuple(arr[i]) for i in hull.vertices]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# === 4) 최종 폴백: 뷰 영역 사각형 자체 ===
|
||||||
|
return _view_rect_silhouette(view)
|
||||||
|
|
||||||
|
|
||||||
|
def _view_rect_silhouette(view: ViewRegion) -> list:
|
||||||
|
"""뷰 영역 사각형을 실루엣으로 반환 (로컬 좌표)."""
|
||||||
|
return [(0, 0), (view.width, 0),
|
||||||
|
(view.width, view.height), (0, view.height)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Oriented Bounding Box (PCA 기반) — 가늘고 긴 구조 감지용
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_oriented_bbox(points: list) -> dict | None:
|
||||||
|
"""점들의 PCA로 oriented bounding box 계산.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"center": (x, y),
|
||||||
|
"axis_long": (dx, dy), # 길이방향 단위벡터
|
||||||
|
"axis_short": (dx, dy), # 폭방향 단위벡터
|
||||||
|
"length": float, # 길이방향 길이
|
||||||
|
"width": float, # 폭방향 길이
|
||||||
|
"corners": [(x,y) × 4], # 사각형 4꼭지점 (반시계)
|
||||||
|
"aspect_ratio": length/width,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if len(points) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
arr = np.array(points, dtype=np.float64)
|
||||||
|
# 중복 점 제거
|
||||||
|
arr = np.unique(arr, axis=0)
|
||||||
|
if len(arr) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
center = arr.mean(axis=0)
|
||||||
|
centered = arr - center
|
||||||
|
|
||||||
|
# 공분산 + 고유벡터
|
||||||
|
try:
|
||||||
|
cov = np.cov(centered.T)
|
||||||
|
eigenvals, eigenvecs = np.linalg.eigh(cov)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 고유값 내림차순 정렬 (큰 게 길이축)
|
||||||
|
idx = np.argsort(-eigenvals)
|
||||||
|
eigenvecs = eigenvecs[:, idx]
|
||||||
|
|
||||||
|
# 주축으로 점 투영
|
||||||
|
proj = centered @ eigenvecs
|
||||||
|
long_min, long_max = float(proj[:, 0].min()), float(proj[:, 0].max())
|
||||||
|
short_min, short_max = float(proj[:, 1].min()), float(proj[:, 1].max())
|
||||||
|
|
||||||
|
length = long_max - long_min
|
||||||
|
width = max(short_max - short_min, 0.01)
|
||||||
|
|
||||||
|
# 4꼭지점 (주축 좌표계 → 원본 좌표계)
|
||||||
|
local_corners = np.array([
|
||||||
|
[long_min, short_min],
|
||||||
|
[long_max, short_min],
|
||||||
|
[long_max, short_max],
|
||||||
|
[long_min, short_max],
|
||||||
|
])
|
||||||
|
world_corners = local_corners @ eigenvecs.T + center
|
||||||
|
|
||||||
|
return {
|
||||||
|
"center": (float(center[0]), float(center[1])),
|
||||||
|
"axis_long": (float(eigenvecs[0, 0]), float(eigenvecs[1, 0])),
|
||||||
|
"axis_short": (float(eigenvecs[0, 1]), float(eigenvecs[1, 1])),
|
||||||
|
"length": length,
|
||||||
|
"width": width,
|
||||||
|
"corners": [tuple(c) for c in world_corners],
|
||||||
|
"aspect_ratio": length / width,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_elongated_structure(plan_view: ViewRegion,
|
||||||
|
min_aspect: float = 2.5) -> dict | None:
|
||||||
|
"""평면도가 가늘고 긴 구조물(옹벽 등)인지 감지.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
oriented bbox dict (is elongated인 경우)
|
||||||
|
None (그렇지 않으면)
|
||||||
|
"""
|
||||||
|
local_shapes = plan_view.get_local_shapes()
|
||||||
|
if not local_shapes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
char_size = max(plan_view.width, plan_view.height)
|
||||||
|
# 임계 길이를 매우 낮게 (전체 크기의 1%만 넘어도 포함)
|
||||||
|
min_sig_len = char_size * 0.01
|
||||||
|
min_sig_area = (char_size ** 2) * 0.0001
|
||||||
|
|
||||||
|
significant_pts = []
|
||||||
|
for s in local_shapes:
|
||||||
|
if s.kind not in ("polyline", "line", "arc", "circle"):
|
||||||
|
continue
|
||||||
|
if s.closed:
|
||||||
|
if _polygon_area(s.points) < min_sig_area:
|
||||||
|
continue
|
||||||
|
elif _polyline_length(s.points) < min_sig_len:
|
||||||
|
continue
|
||||||
|
significant_pts.extend(s.points)
|
||||||
|
|
||||||
|
# 점이 충분치 않으면 모든 점 사용
|
||||||
|
if len(significant_pts) < 10:
|
||||||
|
significant_pts = []
|
||||||
|
for s in local_shapes:
|
||||||
|
significant_pts.extend(s.points)
|
||||||
|
|
||||||
|
if len(significant_pts) < 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
obb = compute_oriented_bbox(significant_pts)
|
||||||
|
if obb is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if obb["aspect_ratio"] >= min_aspect:
|
||||||
|
return obb
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_centerline_from_obb(obb: dict, n_samples: int = 2) -> list:
|
||||||
|
"""OBB의 길이방향 중심선 점 목록 반환."""
|
||||||
|
cx, cy = obb["center"]
|
||||||
|
ax, ay = obb["axis_long"]
|
||||||
|
half_L = obb["length"] / 2
|
||||||
|
|
||||||
|
centerline = []
|
||||||
|
for i in range(n_samples):
|
||||||
|
t = -1 + 2 * i / max(n_samples - 1, 1) # -1 ~ 1
|
||||||
|
x = cx + ax * t * half_L
|
||||||
|
y = cy + ay * t * half_L
|
||||||
|
centerline.append((x, y))
|
||||||
|
return centerline
|
||||||
|
|
||||||
|
|
||||||
|
def get_obb_polygon(obb: dict) -> list:
|
||||||
|
"""OBB 사각형을 polygon 점 목록으로 반환."""
|
||||||
|
return obb["corners"]
|
||||||
|
|
||||||
|
|
||||||
|
def silhouette_bbox(silhouette: list) -> tuple:
|
||||||
|
"""실루엣의 bbox 반환 (xmin, ymin, xmax, ymax)."""
|
||||||
|
arr = np.array(silhouette)
|
||||||
|
return (float(arr[:, 0].min()), float(arr[:, 1].min()),
|
||||||
|
float(arr[:, 0].max()), float(arr[:, 1].max()))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 차원 결정: 뷰들로부터 실제 3D 구조물 (X, Y, Z) 크기 계산
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_3d_dimensions(silhouettes: dict) -> dict:
|
||||||
|
"""뷰별 실루엣들로부터 3D 구조물의 실제 크기를 추정.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
silhouettes: {"plan": [...], "front": [...], "side": [...]}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"width": X폭, "depth": Y깊이, "height": Z높이}
|
||||||
|
"""
|
||||||
|
width = depth = height = None
|
||||||
|
|
||||||
|
# 평면도: 도면X=실물X, 도면Y=실물Y
|
||||||
|
if "plan" in silhouettes:
|
||||||
|
bb = silhouette_bbox(silhouettes["plan"])
|
||||||
|
width = bb[2] - bb[0]
|
||||||
|
depth = bb[3] - bb[1]
|
||||||
|
|
||||||
|
# 정면도: 도면X=실물X, 도면Y=실물Z
|
||||||
|
if "front" in silhouettes:
|
||||||
|
bb = silhouette_bbox(silhouettes["front"])
|
||||||
|
if width is None:
|
||||||
|
width = bb[2] - bb[0]
|
||||||
|
height = bb[3] - bb[1]
|
||||||
|
|
||||||
|
# 측면도: 도면X=실물Y, 도면Y=실물Z
|
||||||
|
if "side" in silhouettes:
|
||||||
|
bb = silhouette_bbox(silhouettes["side"])
|
||||||
|
if depth is None:
|
||||||
|
depth = bb[2] - bb[0]
|
||||||
|
if height is None:
|
||||||
|
height = bb[3] - bb[1]
|
||||||
|
|
||||||
|
# 일관성 검증: width가 plan과 front 모두 있으면 더 큰 값 사용 (노이즈 대비)
|
||||||
|
# (실제로는 같아야 함. 다르면 둘 중 더 큰 것이 정확할 가능성)
|
||||||
|
# 그러나 단순함을 위해 우선 적용된 값 유지
|
||||||
|
|
||||||
|
return {
|
||||||
|
"width": width if width and width > 0 else 10.0,
|
||||||
|
"depth": depth if depth and depth > 0 else 10.0,
|
||||||
|
"height": height if height and height > 0 else 5.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메쉬 빌더 (저수준)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_extrude_mesh(profile: list, base_z: float, height: float,
|
||||||
|
color: str = "#BDC3C7", opacity: float = 1.0
|
||||||
|
) -> tuple | None:
|
||||||
|
"""폴리곤(2D)을 Z방향으로 extrude.
|
||||||
|
|
||||||
|
profile: [(x, y), ...] 로컬 좌표
|
||||||
|
"""
|
||||||
|
if not profile or len(profile) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
arr = np.array(profile)
|
||||||
|
# 끝점 중복 제거
|
||||||
|
if np.allclose(arr[0], arr[-1]):
|
||||||
|
arr = arr[:-1]
|
||||||
|
n = len(arr)
|
||||||
|
if n < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pts_3d = np.zeros((2 * n, 3))
|
||||||
|
for i, (x, y) in enumerate(arr):
|
||||||
|
pts_3d[i] = [x, y, base_z]
|
||||||
|
pts_3d[i + n] = [x, y, base_z + height]
|
||||||
|
|
||||||
|
faces = []
|
||||||
|
for i in range(n):
|
||||||
|
ni = (i + 1) % n
|
||||||
|
faces.append([3, i, ni, ni + n])
|
||||||
|
faces.append([3, i, ni + n, i + n])
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
faces.append([3, 0, i + 1, i])
|
||||||
|
faces.append([3, n, n + i, n + i + 1])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (pv.PolyData(pts_3d, np.concatenate(faces)), color, opacity)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_extrude_along_y(profile_xz: list, depth: float, base_y: float = 0,
|
||||||
|
color: str = "#BDC3C7", opacity: float = 1.0
|
||||||
|
) -> tuple | None:
|
||||||
|
"""X-Z 프로파일을 Y방향으로 depth만큼 extrude.
|
||||||
|
|
||||||
|
profile_xz: [(x, z), ...] 로컬 좌표
|
||||||
|
"""
|
||||||
|
if not profile_xz or len(profile_xz) < 3:
|
||||||
|
return None
|
||||||
|
arr = np.array(profile_xz)
|
||||||
|
if np.allclose(arr[0], arr[-1]):
|
||||||
|
arr = arr[:-1]
|
||||||
|
n = len(arr)
|
||||||
|
if n < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
front = np.zeros((n, 3))
|
||||||
|
back = np.zeros((n, 3))
|
||||||
|
for i, (x, z) in enumerate(arr):
|
||||||
|
front[i] = [x, base_y, z]
|
||||||
|
back[i] = [x, base_y + depth, z]
|
||||||
|
|
||||||
|
all_pts = np.vstack([front, back])
|
||||||
|
faces = []
|
||||||
|
for i in range(n):
|
||||||
|
ni = (i + 1) % n
|
||||||
|
faces.append([3, i, ni, ni + n])
|
||||||
|
faces.append([3, i, ni + n, i + n])
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
faces.append([3, 0, i + 1, i])
|
||||||
|
faces.append([3, n, n + i, n + i + 1])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (pv.PolyData(all_pts, np.concatenate(faces)), color, opacity)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_extrude_along_x(profile_yz: list, width: float, base_x: float = 0,
|
||||||
|
color: str = "#BDC3C7", opacity: float = 1.0
|
||||||
|
) -> tuple | None:
|
||||||
|
"""Y-Z 프로파일을 X방향으로 width만큼 extrude."""
|
||||||
|
if not profile_yz or len(profile_yz) < 3:
|
||||||
|
return None
|
||||||
|
arr = np.array(profile_yz)
|
||||||
|
if np.allclose(arr[0], arr[-1]):
|
||||||
|
arr = arr[:-1]
|
||||||
|
n = len(arr)
|
||||||
|
if n < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
left = np.zeros((n, 3))
|
||||||
|
right = np.zeros((n, 3))
|
||||||
|
for i, (y, z) in enumerate(arr):
|
||||||
|
left[i] = [base_x, y, z]
|
||||||
|
right[i] = [base_x + width, y, z]
|
||||||
|
|
||||||
|
all_pts = np.vstack([left, right])
|
||||||
|
faces = []
|
||||||
|
for i in range(n):
|
||||||
|
ni = (i + 1) % n
|
||||||
|
faces.append([3, i, ni, ni + n])
|
||||||
|
faces.append([3, i, ni + n, i + n])
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
faces.append([3, 0, i + 1, i])
|
||||||
|
faces.append([3, n, n + i, n + i + 1])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (pv.PolyData(all_pts, np.concatenate(faces)), color, opacity)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_wall_swept(obb: dict, section_2d: list,
|
||||||
|
fallback_height: float,
|
||||||
|
color: str = "#7F8C8D",
|
||||||
|
opacity: float = 1.0) -> tuple | None:
|
||||||
|
"""OBB 길이방향을 따라 단면(section)을 sweep.
|
||||||
|
|
||||||
|
section_2d: [(perp_offset, z_height), ...] 단면 프로파일 점들
|
||||||
|
폭 방향 = OBB short axis, 높이 방향 = Z
|
||||||
|
"""
|
||||||
|
if not section_2d or len(section_2d) < 3:
|
||||||
|
# 단면 없음 → 단순 OBB extrude
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 단면 프로파일 정규화: x = 폭 (중심 0), y = 높이 (바닥 0)
|
||||||
|
sec_arr = np.array(section_2d, dtype=np.float64)
|
||||||
|
if np.allclose(sec_arr[0], sec_arr[-1]):
|
||||||
|
sec_arr = sec_arr[:-1]
|
||||||
|
sec_cx = (sec_arr[:, 0].min() + sec_arr[:, 0].max()) / 2
|
||||||
|
sec_y_min = sec_arr[:, 1].min()
|
||||||
|
sec_normalized = [(p[0] - sec_cx, p[1] - sec_y_min) for p in sec_arr]
|
||||||
|
|
||||||
|
# OBB의 길이방향 양 끝점 (월드 좌표)
|
||||||
|
cx, cy = obb["center"]
|
||||||
|
ax, ay = obb["axis_long"]
|
||||||
|
nx, ny = obb["axis_short"] # 폭 방향 (단면 폭 방향)
|
||||||
|
half_L = obb["length"] / 2
|
||||||
|
|
||||||
|
# 경로: 길이방향 양 끝
|
||||||
|
path_pts = [
|
||||||
|
(cx - ax * half_L, cy - ay * half_L),
|
||||||
|
(cx + ax * half_L, cy + ay * half_L),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 각 경로 점에서 단면을 배치
|
||||||
|
n_sec = len(sec_normalized)
|
||||||
|
n_path = len(path_pts)
|
||||||
|
|
||||||
|
all_pts = []
|
||||||
|
for i, (px, py) in enumerate(path_pts):
|
||||||
|
for u, z in sec_normalized:
|
||||||
|
# 폭 방향 = OBB short axis
|
||||||
|
wx = px + nx * u
|
||||||
|
wy = py + ny * u
|
||||||
|
all_pts.append([wx, wy, z])
|
||||||
|
|
||||||
|
pts_3d = np.array(all_pts)
|
||||||
|
|
||||||
|
# 면 생성
|
||||||
|
faces = []
|
||||||
|
for i in range(n_path - 1):
|
||||||
|
for j in range(n_sec):
|
||||||
|
a = i * n_sec + j
|
||||||
|
b = i * n_sec + (j + 1) % n_sec
|
||||||
|
c = (i + 1) * n_sec + (j + 1) % n_sec
|
||||||
|
d = (i + 1) * n_sec + j
|
||||||
|
faces.append([3, a, b, c])
|
||||||
|
faces.append([3, a, c, d])
|
||||||
|
# 양끝 단면 닫기 (fan)
|
||||||
|
for j in range(1, n_sec - 1):
|
||||||
|
faces.append([3, 0, j, j + 1])
|
||||||
|
last_base = (n_path - 1) * n_sec
|
||||||
|
faces.append([3, last_base, last_base + j + 1, last_base + j])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (pv.PolyData(pts_3d, np.concatenate(faces)), color, opacity)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ground(width: float, depth: float, z: float = 0.0) -> tuple:
|
||||||
|
"""구조물 주변 지반."""
|
||||||
|
margin = max(width, depth) * 0.3
|
||||||
|
half_w = width / 2 + margin
|
||||||
|
half_d = depth / 2 + margin
|
||||||
|
pts = np.array([
|
||||||
|
[-half_w, -half_d, z], [half_w, -half_d, z],
|
||||||
|
[half_w, half_d, z], [-half_w, half_d, z],
|
||||||
|
])
|
||||||
|
return (pv.PolyData(pts, np.array([4, 0, 1, 2, 3])), "#8B7D6B", 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 실루엣 정규화 (좌표를 중심 원점으로)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _center_silhouette(silhouette: list, axis: str = "xy") -> list:
|
||||||
|
"""실루엣의 좌표 원점을 정규화.
|
||||||
|
|
||||||
|
axis="xy": 둘 다 중심으로 이동 (평면도용)
|
||||||
|
axis="x_only": X만 중심, Y는 최소값을 0으로 (정면/측면 - Z가 바닥부터 시작)
|
||||||
|
"""
|
||||||
|
if not silhouette:
|
||||||
|
return silhouette
|
||||||
|
arr = np.array(silhouette)
|
||||||
|
cx = (arr[:, 0].min() + arr[:, 0].max()) / 2
|
||||||
|
if axis == "xy":
|
||||||
|
cy = (arr[:, 1].min() + arr[:, 1].max()) / 2
|
||||||
|
return [(p[0] - cx, p[1] - cy) for p in silhouette]
|
||||||
|
else: # x_only: Y는 0부터
|
||||||
|
y_min = arr[:, 1].min()
|
||||||
|
return [(p[0] - cx, p[1] - y_min) for p in silhouette]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메인 통합 재구성 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reconstruct_from_views(views: list[ViewRegion],
|
||||||
|
template_id: str | None = None
|
||||||
|
) -> list[tuple]:
|
||||||
|
"""검출된 뷰들로부터 단일 통합 3D 구조물 메쉬 생성.
|
||||||
|
|
||||||
|
핵심: 입력 뷰가 N개여도 출력은 하나의 구조물 (+ 지반).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
views: detect_view_regions() 결과
|
||||||
|
template_id: "spillway_gate", "building", "retaining_wall" 등
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(mesh, color, opacity), (ground, color, opacity)] — 보통 2개
|
||||||
|
실패 시 빈 리스트
|
||||||
|
"""
|
||||||
|
if not views:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# === 1) 메인 뷰 선택 (각 타입별 1개씩) ===
|
||||||
|
plan_view = pick_main_view(views, "plan", template_id)
|
||||||
|
front_view = pick_main_view(views, "front", template_id)
|
||||||
|
side_view = pick_main_view(views, "side", template_id)
|
||||||
|
section_view = pick_main_view(views, "section", template_id)
|
||||||
|
# cross_section / longitudinal도 section 카테고리로 폴백
|
||||||
|
if section_view is None:
|
||||||
|
section_view = (pick_main_view(views, "cross_section", template_id) or
|
||||||
|
pick_main_view(views, "longitudinal", template_id))
|
||||||
|
# 정면도 없으면 elevation_generic 또는 입면도 시도
|
||||||
|
if front_view is None:
|
||||||
|
front_view = pick_main_view(views, "elevation_generic", template_id)
|
||||||
|
|
||||||
|
# === 2) 각 뷰에서 메인 실루엣 추출 ===
|
||||||
|
silhouettes = {}
|
||||||
|
if plan_view:
|
||||||
|
sil = extract_main_silhouette(plan_view)
|
||||||
|
if sil:
|
||||||
|
silhouettes["plan"] = sil
|
||||||
|
if front_view:
|
||||||
|
sil = extract_main_silhouette(front_view)
|
||||||
|
if sil:
|
||||||
|
silhouettes["front"] = sil
|
||||||
|
if side_view:
|
||||||
|
sil = extract_main_silhouette(side_view)
|
||||||
|
if sil:
|
||||||
|
silhouettes["side"] = sil
|
||||||
|
if section_view:
|
||||||
|
sil = extract_main_silhouette(section_view)
|
||||||
|
if sil:
|
||||||
|
silhouettes["section"] = sil
|
||||||
|
|
||||||
|
if not silhouettes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# === 3) 3D 차원 계산 ===
|
||||||
|
dims = compute_3d_dimensions(silhouettes)
|
||||||
|
|
||||||
|
# === 4) 단일 메쉬 빌드 ===
|
||||||
|
color = TEMPLATE_COLORS.get(template_id, "#B8B5A8")
|
||||||
|
|
||||||
|
section_sil = silhouettes.get("section")
|
||||||
|
main_mesh = _build_unified_structure(silhouettes, dims, color, template_id,
|
||||||
|
plan_view=plan_view,
|
||||||
|
section_silhouette=section_sil)
|
||||||
|
if main_mesh is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# === 5) 지반 추가 ===
|
||||||
|
meshes = [main_mesh]
|
||||||
|
# OBB이면 ground도 OBB 길이 기준
|
||||||
|
obb = is_elongated_structure(plan_view) if plan_view else None
|
||||||
|
if obb:
|
||||||
|
ground_w = obb["length"] * 1.2
|
||||||
|
ground_d = max(obb["width"] * 5, dims["height"] * 1.5)
|
||||||
|
else:
|
||||||
|
ground_w = dims["width"]
|
||||||
|
ground_d = dims["depth"]
|
||||||
|
meshes.append(_make_ground(ground_w, ground_d, z=-0.05))
|
||||||
|
|
||||||
|
return meshes
|
||||||
|
|
||||||
|
|
||||||
|
def _build_unified_structure(silhouettes: dict, dims: dict,
|
||||||
|
color: str, template_id: str | None,
|
||||||
|
plan_view: ViewRegion | None = None,
|
||||||
|
section_silhouette: list | None = None,
|
||||||
|
) -> tuple | None:
|
||||||
|
"""가용 실루엣들로부터 단일 3D 구조물 메쉬 생성.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. 평면도 있음 + elongated (옹벽) → sweep 또는 OBB extrude
|
||||||
|
2. 평면도 있음 → 평면 외곽 Z 방향 extrude
|
||||||
|
3. 정면도만 → Y방향 extrude
|
||||||
|
4. 측면도만 → X방향 extrude
|
||||||
|
"""
|
||||||
|
# === 케이스 1a: 옹벽 등 elongated 구조 (PCA로 감지) ===
|
||||||
|
# retaining_wall 템플릿이면 임계 낮춰 더 적극적으로 OBB 적용
|
||||||
|
obb = None
|
||||||
|
if plan_view is not None:
|
||||||
|
if template_id == "retaining_wall":
|
||||||
|
obb = is_elongated_structure(plan_view, min_aspect=1.2)
|
||||||
|
else:
|
||||||
|
obb = is_elongated_structure(plan_view, min_aspect=2.5)
|
||||||
|
|
||||||
|
if obb is not None:
|
||||||
|
# 길이방향 정확한 사각형 OBB로 평면 외곽 사용
|
||||||
|
# 단면도 있으면 sweep, 없으면 OBB extrude
|
||||||
|
if section_silhouette and template_id == "retaining_wall":
|
||||||
|
return _build_wall_swept(obb, section_silhouette,
|
||||||
|
dims["height"], color)
|
||||||
|
else:
|
||||||
|
# OBB 사각형을 평면 외곽으로 사용 → Z방향 extrude
|
||||||
|
obb_corners = obb["corners"]
|
||||||
|
# 중심을 원점으로
|
||||||
|
arr = np.array(obb_corners)
|
||||||
|
cx = arr[:, 0].mean()
|
||||||
|
cy = arr[:, 1].mean()
|
||||||
|
centered_obb = [(p[0] - cx, p[1] - cy) for p in obb_corners]
|
||||||
|
return _build_extrude_mesh(centered_obb, base_z=0,
|
||||||
|
height=dims["height"], color=color)
|
||||||
|
|
||||||
|
# === 케이스 1b: 평면도 일반 (closed polygon) ===
|
||||||
|
if "plan" in silhouettes:
|
||||||
|
plan_pts = silhouettes["plan"]
|
||||||
|
plan_centered = _center_silhouette(plan_pts, axis="xy")
|
||||||
|
return _build_extrude_mesh(plan_centered, base_z=0,
|
||||||
|
height=dims["height"], color=color)
|
||||||
|
|
||||||
|
# === 케이스 2: 정면도만 (평면도 없음) ===
|
||||||
|
if "front" in silhouettes:
|
||||||
|
front_pts = silhouettes["front"]
|
||||||
|
front_centered = _center_silhouette(front_pts, axis="x_only")
|
||||||
|
|
||||||
|
# Y 방향(깊이)는 측면도가 있으면 그 폭, 없으면 폭의 절반 정도
|
||||||
|
depth_for_extrude = dims["depth"]
|
||||||
|
# 측면도가 없으면 폭의 1/3 정도로 추정 (얇은 두께)
|
||||||
|
if "side" not in silhouettes:
|
||||||
|
depth_for_extrude = max(dims["width"] * 0.3, 2.0)
|
||||||
|
|
||||||
|
# 정면도 중심을 Y=0으로, depth/2 만큼 양쪽으로
|
||||||
|
return _build_extrude_along_y(front_centered,
|
||||||
|
depth=depth_for_extrude,
|
||||||
|
base_y=-depth_for_extrude / 2,
|
||||||
|
color=color)
|
||||||
|
|
||||||
|
# === 케이스 3: 측면도만 ===
|
||||||
|
if "side" in silhouettes:
|
||||||
|
side_pts = silhouettes["side"]
|
||||||
|
side_centered = _center_silhouette(side_pts, axis="x_only")
|
||||||
|
|
||||||
|
width_for_extrude = dims["width"]
|
||||||
|
if "front" not in silhouettes:
|
||||||
|
width_for_extrude = max(dims["depth"] * 0.3, 2.0)
|
||||||
|
|
||||||
|
return _build_extrude_along_x(side_centered,
|
||||||
|
width=width_for_extrude,
|
||||||
|
base_x=-width_for_extrude / 2,
|
||||||
|
color=color)
|
||||||
|
|
||||||
|
# === 케이스 4: 단면도만 ===
|
||||||
|
if "section" in silhouettes:
|
||||||
|
sec_pts = silhouettes["section"]
|
||||||
|
sec_centered = _center_silhouette(sec_pts, axis="x_only")
|
||||||
|
# 단면을 X방향으로 width만큼 extrude
|
||||||
|
return _build_extrude_along_x(sec_centered,
|
||||||
|
width=dims["width"],
|
||||||
|
base_x=-dims["width"] / 2,
|
||||||
|
color=color)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 진단 정보 (UI에 표시용)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def diagnose_views(views: list[ViewRegion],
|
||||||
|
template_id: str | None = None) -> dict:
|
||||||
|
"""뷰 검출/재구성 진단 정보 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"main_views": {"plan": ..., "front": ..., "side": ...},
|
||||||
|
"silhouettes_extracted": {"plan": True, ...},
|
||||||
|
"dimensions": {"width": ..., "depth": ..., "height": ...},
|
||||||
|
"build_strategy": "plan_extrude" | "front_extrude" | ...,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
info = {
|
||||||
|
"main_views": {},
|
||||||
|
"silhouettes_extracted": {},
|
||||||
|
"dimensions": {"width": 0, "depth": 0, "height": 0},
|
||||||
|
"build_strategy": "none",
|
||||||
|
"total_views": len(views),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan_view = pick_main_view(views, "plan", template_id)
|
||||||
|
front_view = pick_main_view(views, "front", template_id)
|
||||||
|
side_view = pick_main_view(views, "side", template_id)
|
||||||
|
section_view = pick_main_view(views, "section", template_id)
|
||||||
|
|
||||||
|
if plan_view:
|
||||||
|
info["main_views"]["plan"] = plan_view.label_text[:40]
|
||||||
|
if front_view:
|
||||||
|
info["main_views"]["front"] = front_view.label_text[:40]
|
||||||
|
if side_view:
|
||||||
|
info["main_views"]["side"] = side_view.label_text[:40]
|
||||||
|
if section_view:
|
||||||
|
info["main_views"]["section"] = section_view.label_text[:40]
|
||||||
|
|
||||||
|
silhouettes = {}
|
||||||
|
for name, v in [("plan", plan_view), ("front", front_view),
|
||||||
|
("side", side_view), ("section", section_view)]:
|
||||||
|
if v:
|
||||||
|
sil = extract_main_silhouette(v)
|
||||||
|
info["silhouettes_extracted"][name] = sil is not None
|
||||||
|
if sil:
|
||||||
|
silhouettes[name] = sil
|
||||||
|
|
||||||
|
if silhouettes:
|
||||||
|
info["dimensions"] = compute_3d_dimensions(silhouettes)
|
||||||
|
|
||||||
|
# OBB 정보 (옹벽 등 elongated)
|
||||||
|
obb = None
|
||||||
|
if plan_view is not None:
|
||||||
|
if template_id == "retaining_wall":
|
||||||
|
obb = is_elongated_structure(plan_view, min_aspect=1.2)
|
||||||
|
else:
|
||||||
|
obb = is_elongated_structure(plan_view, min_aspect=2.5)
|
||||||
|
info["is_elongated"] = obb is not None
|
||||||
|
if obb:
|
||||||
|
info["obb_length"] = obb["length"]
|
||||||
|
info["obb_width"] = obb["width"]
|
||||||
|
info["obb_aspect"] = obb["aspect_ratio"]
|
||||||
|
# OBB가 있으면 차원 갱신
|
||||||
|
info["dimensions"]["width"] = obb["length"]
|
||||||
|
info["dimensions"]["depth"] = obb["width"]
|
||||||
|
|
||||||
|
# 빌드 전략 결정
|
||||||
|
if obb is not None:
|
||||||
|
if "section" in silhouettes and template_id == "retaining_wall":
|
||||||
|
info["build_strategy"] = f"옹벽 sweep (길이 {obb['length']:.1f}m × 단면)"
|
||||||
|
else:
|
||||||
|
info["build_strategy"] = f"OBB extrude (길이 {obb['length']:.1f}m × 폭 {obb['width']:.1f}m × 높이)"
|
||||||
|
elif "plan" in silhouettes:
|
||||||
|
info["build_strategy"] = "평면도 → Z방향 extrude (높이는 정면/측면에서)"
|
||||||
|
elif "front" in silhouettes:
|
||||||
|
info["build_strategy"] = "정면도 → Y방향 extrude"
|
||||||
|
elif "side" in silhouettes:
|
||||||
|
info["build_strategy"] = "측면도 → X방향 extrude"
|
||||||
|
elif "section" in silhouettes:
|
||||||
|
info["build_strategy"] = "단면도 → X방향 extrude"
|
||||||
|
else:
|
||||||
|
info["build_strategy"] = "실루엣 추출 실패"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 테스트
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from pathlib import Path
|
||||||
|
from view_detector import detect_view_regions
|
||||||
|
|
||||||
|
samples = [
|
||||||
|
("SAMPLE_CAD/1. 좌안옹벽 일반도 작성(2026.0109).dxf", "retaining_wall"),
|
||||||
|
("SAMPLE_CAD/12995740-M40-001 여수로 수문 설치도(1/2).dxf", "spillway_gate"),
|
||||||
|
("SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf", "building"),
|
||||||
|
("SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf", "building"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path, tid in samples:
|
||||||
|
print(f"\n=== {Path(path).name} ({tid}) ===")
|
||||||
|
try:
|
||||||
|
views = detect_view_regions(path)
|
||||||
|
info = diagnose_views(views, tid)
|
||||||
|
print(f" 뷰 {info['total_views']}개 검출")
|
||||||
|
print(" 메인 뷰:")
|
||||||
|
for vt, label in info["main_views"].items():
|
||||||
|
ok = "✓" if info["silhouettes_extracted"].get(vt) else "✗"
|
||||||
|
print(f" [{vt}] {ok} \"{label}\"")
|
||||||
|
d = info["dimensions"]
|
||||||
|
print(f" 3D 크기: W{d['width']:.1f}m × D{d['depth']:.1f}m × H{d['height']:.1f}m")
|
||||||
|
print(f" 전략: {info['build_strategy']}")
|
||||||
|
|
||||||
|
meshes = reconstruct_from_views(views, tid)
|
||||||
|
print(f" 결과 메쉬: {len(meshes)}개")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 오류: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||